Files
SlabUploader/backend/tests/test_slabs_api.py
Ty 0c00d25cc7 fix(m3): Option A must-fix #79 #82 #83 #84 #89 #98
- #79 publish SKU/create via WooClient (User-Agent + typed 403 unrecoverable)
- #82 stale_field current_options from live taxonomy + owner labels
- #83 create allowlist/extra-key safe; taxonomy+enum 4xx on create
- #84 health woo_reachable real probe (non-failing); version 0.5.0-m3
- #89 review persists assembled title/short after taxonomy lock; UI applies
- #98 live-edge from synced term; attrs resolved by slug then name
2026-09-21 12:55:17 -07:00

385 lines
22 KiB
Python

import os
import json
import uuid
import datetime
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
# Helper to set environment for tests
def _setup_env(monkeypatch, tmp):
db_path = os.path.join(tmp, "test.db")
img_dir = os.path.join(tmp, "images")
os.mkdir(img_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.setenv("SLAB_AES_KEY", "aGVsbGxvb2xvY2g=") # dummy base64
monkeypatch.setenv("LOG_LEVEL", "INFO")
db_mod._reset_for_tests()
# Taxonomy seeding helper
def _seed_taxonomy(conn):
# create some categories
conn.execute("INSERT OR REPLACE INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (1, 'Species A', 'species-a', NULL, 10)")
conn.execute("INSERT OR REPLACE INTO woo_categories(id, name, slug, parent_id, woo_id) VALUES (2, 'Wood Cat A', 'wood-cat-a', NULL, 20)")
# attribute terms
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (1, 'Edge Type', 'edge-type', 30)")
conn.execute("INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (1, 1, 'Edge A', 'edge-a', 40)")
# tags
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (1, 'Fig Tag A', 'fig-tag-a', 50)")
conn.execute("INSERT OR REPLACE INTO woo_tags(id, name, slug, woo_id) VALUES (2, 'Feat Tag A', 'feat-tag-a', 60)")
# Thickness bands (METADATA-15 / QUALITY-95) — names exactly as synced from the
# live Woo attribute, because the derivation parses them.
conn.execute("INSERT OR REPLACE INTO woo_attributes(id, name, slug, woo_id) VALUES (2, 'Thickness', 'pa_thk', 31)")
for tid, name, slug in [
(347, 'Under 1/2"', 'xthin'),
(348, '1/2" - 3/4"', '2-3qtr'),
(349, '3/4" - 1"', '3-4qtr'),
(350, '1"- 1 1/2"', '4-6qtr'),
(351, '1 1/2" - 2"', '6-8qtr'),
(352, '2" - 3"', '8-12qtr'),
(353, 'Over 3"', '12qtr'),
]:
conn.execute(
"INSERT OR REPLACE INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) "
"VALUES (?, 2, ?, ?, ?)",
(tid, name, slug, tid),
)
conn.commit()
@pytest.fixture
def db(tmp_path, monkeypatch):
_setup_env(monkeypatch, tmp_path)
conn = db_mod.get_connection()
db_mod.init_db()
_seed_taxonomy(conn)
return conn
_setup_env(monkeypatch, tmp_path)
conn = db_mod.get_connection()
db_mod.init_db()
_seed_taxonomy(conn)
return conn
# Helper to get client
@pytest.fixture
def client(db):
from backend.app import app
return TestClient(app)
# Utility to format now
_now_iso = lambda: datetime.datetime.utcnow().replace(microsecond=0).isoformat()
# --- Tests ---
def test_create_and_fetch(client, db):
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id,
"sku": "SKU001",
"length_in": 10.0,
"thickness_in": 2.0,
"sqft": 20.0,
"bdft": 5.0,
"width_min_in": 1.0,
"width_max_in": 2.0,
"width_avg_in": 1.5,
"species_id": 1,
"wood_category_ids": json.dumps([2]),
"edge_type_term_id": 1,
"figure_term_ids": json.dumps([1]),
"grade_term_id": 1,
"thickness_term_id": 1,
"moisture_term_id": 1,
"fig_tag_ids": json.dumps([1]),
"feat_tag_ids": json.dumps([2]),
"price": 100.0,
"price_source": "recommendation",
"title": "Test Slab",
"short_description": "short",
"description": "desc",
"content_source": "manual",
"species_confidence": 0.9,
"figure_confidence": 0.8,
"grade_confidence": 0.7,
"inference_status": "inferring",
"calibration_photo_id": None
}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
body = resp.json()
assert body["status"] == "calibrated"
assert body["client_rev"] == 1
assert body["server_rev"] == 1
for key in ["length_in","thickness_in","sqft","bdft","width_min_in","width_max_in","width_avg_in"]:
assert body[key] == payload[key]
def test_create_derives_thickness_band(client, db):
"""QUALITY-95: 1 in rounds up into the live synced band, not a hardcoded one.
The client-supplied thickness_term_id (1, an Edge Type term) must be ignored —
the UI has no band control, so the server's derivation is authoritative.
"""
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id, "sku": "SKU-BAND", "length_in": 10, "thickness_in": 1.0, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
"thickness_term_id": 1,
}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
assert resp.json()["thickness_term_id"] == 350 # '1"- 1 1/2"', not the sent 1
def test_update_rebands_when_thickness_changes(client, db):
"""A thickness edit moves the slab to the band the new measurement rounds into."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND2", "length_in": 10, "thickness_in": 0.5, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 348 # 1/2" - 3/4"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_in": 2.75,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 352 # 2" - 3"
def test_update_ignores_client_supplied_band(client, db):
"""A stale client band cannot pin the slab to the wrong band."""
slab_id = str(uuid.uuid4())
create = client.post("/api/v1/slabs", json={
"id": slab_id, "sku": "SKU-BAND3", "length_in": 10, "thickness_in": 1.75, "sqft": 20,
"bdft": 5, "width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
})
assert create.status_code == 201
assert create.json()["thickness_term_id"] == 351 # 1 1/2" - 2"
put = client.put(f"/api/v1/slabs/{slab_id}", json={
"client_rev": create.json()["server_rev"], "thickness_term_id": 347,
})
assert put.status_code == 200
assert put.json()["thickness_term_id"] == 351 # unchanged: still the derived band
def test_duplicate_sku(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
resp1 = client.post("/api/v1/slabs", json=payload)
assert resp1.status_code == 201
resp2 = client.post("/api/v1/slabs", json=payload)
assert resp2.status_code == 409
def test_list_and_exclude_deleted(client, db):
# create two slabs
for i in [1,2]:
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": f"SKU{i}", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
# delete first created
resp_list = client.get("/api/v1/slabs")
ids = [r["id"] for r in resp_list.json()]
client.delete(f"/api/v1/slabs/{ids[0]}")
# list again
resp2 = client.get("/api/v1/slabs")
body = resp2.json()
assert len(body) == 1
assert body[0]["id"] == ids[1]
def test_get_not_found(client, db):
resp = client.get("/api/v1/slabs/nonexistent")
assert resp.status_code == 404
def test_update_success(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
# update
update_payload = {"client_rev":1, "sku":"SKU002"}
resp = client.put(f"/api/v1/slabs/{slab_id}", json=update_payload)
assert resp.status_code == 200
body = resp.json()
assert body["sku"] == "SKU002"
assert body["server_rev"] == 2
def test_update_revision_conflict(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
# wrong client_rev
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev":99, "sku":"SKU002"})
assert resp.status_code == 409
def test_update_duplicate_sku(client, db):
# create two slabs
id1, id2 = str(uuid.uuid4()), str(uuid.uuid4())
payload1 = {"id": id1, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
payload2 = {"id": id2, "sku": "SKU002", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload1)
client.post("/api/v1/slabs", json=payload2)
# update first to sku of second
resp = client.put(f"/api/v1/slabs/{id1}", json={"client_rev":1, "sku":"SKU002"})
assert resp.status_code == 409
def test_soft_delete(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
client.delete(f"/api/v1/slabs/{slab_id}")
resp = client.get("/api/v1/slabs")
body = resp.json()
assert all(r["id"] != slab_id for r in body)
resp_get = client.get(f"/api/v1/slabs/{slab_id}")
assert resp_get.status_code == 404
def test_create_empty_sku_stored_as_null(client, db):
# Business rule (middle tier): empty SKU at create = "not set yet" -> stored as NULL,
# not rejected by the DB CHECK. This is the UAT "Continue to Review" path.
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
assert resp.json()["sku"] is None
# A second empty-SKU slab is allowed (NULLs are not unique-conflicts).
resp2 = client.post("/api/v1/slabs", json={**payload, "id": str(uuid.uuid4())})
assert resp2.status_code == 201
def test_create_null_sku_allowed(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": None, "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 201
assert resp.json()["sku"] is None
def test_gate_reads_flat_confidence_keys(client, db):
# Regression: the gate's confidence keys are 'species_confidence' / 'edge_confidence'
# etc., NOT f'{field}_confidence' (field 'species_id' would give the wrong key). A
# high-confidence response must surface its picks; low-confidence ones must not.
from backend.inference import _gate, _parse_json
good = '```json\n{"species_id": 284, "species_confidence": 0.85, "wood_category_ids": [251], "wood_category_confidence": 1.0, "edge_type_term_id": 323, "edge_confidence": 0.95, "figure_term_ids": [332], "figure_confidence": 0.9, "feature_tag_ids": [1], "feature_tag_confidence": 0.8, "character": [], "inclusions": ["bark"], "voids": [], "checks": []}\n```'
out = _gate(_parse_json(good))
assert out["species_id"] == 284
assert out["wood_category_ids"] == [251]
assert out["edge_type_term_id"] == 323
assert out["figure_term_ids"] == [332]
assert out["feature_tag_ids"] == [1]
# grade must never be gated in (METADATA-40)
assert "grade_term_id" not in out
# free-text bags dropped (#86)
assert "character" not in out
assert "inclusions" not in out
# low-confidence fields are dropped (METADATA-45)
low = '```json\n{"species_id": 284, "species_confidence": 0.4, "wood_category_ids": [251], "wood_category_confidence": 0.8, "edge_type_term_id": 323, "edge_confidence": 0.2, "figure_term_ids": [332], "figure_confidence": 0.9}\n```'
out2 = _gate(_parse_json(low))
assert out2["wood_category_ids"] == [251]
assert out2["figure_term_ids"] == [332]
assert "species_id" not in out2
assert "edge_type_term_id" not in out2
def test_ready_gate_rejects_malformed_sku(client, db):
# REVIEW-45 enforced in the middle tier: a present-but-out-of-range/char-set SKU is not ready.
from backend.review import compute_ready, sku_valid
base = {"length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual"}
# valid
assert sku_valid("AB-1") is True
ready, missing = compute_ready({**base, "sku": "AB-1"})
assert ready is True and "sku" not in missing
# too short
assert sku_valid("AB") is False
ready, missing = compute_ready({**base, "sku": "AB"})
assert ready is False and "sku" in missing
# bad char
ready, missing = compute_ready({**base, "sku": "AB!"})
assert ready is False and "sku" in missing
# too long
ready, missing = compute_ready({**base, "sku": "A" * 25})
assert ready is False and "sku" in missing
# missing (None)
ready, missing = compute_ready({**base, "sku": None})
assert ready is False and "sku" in missing
def test_delete_published_conflict(client, db):
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5,"species_id":1,"wood_category_ids":json.dumps([2]),"edge_type_term_id":1,"figure_term_ids":json.dumps([1]),"grade_term_id":1,"thickness_term_id":1,"moisture_term_id":1,"fig_tag_ids":json.dumps([1]),"feat_tag_ids":json.dumps([2]),"price":100,"price_source":"recommendation","title":"Test","short_description":"short","description":"desc","content_source":"manual","species_confidence":0.9,"figure_confidence":0.8,"grade_confidence":0.7,"inference_status":"inferring","calibration_photo_id":None}
client.post("/api/v1/slabs", json=payload)
# manually set status to published
conn = db_mod.get_connection()
conn.execute("UPDATE slabs SET status = 'published' WHERE id = ?", (slab_id,))
conn.commit()
resp = client.delete(f"/api/v1/slabs/{slab_id}")
assert resp.status_code == 409
def test_create_rejects_unknown_fields(client, db):
# #83: POST /slabs with extra (non-ALLOWED_FIELDS) keys → 400, not a silent drop.
slab_id = str(uuid.uuid4())
payload = {"id": slab_id, "sku": "SKU001", "length_in":10,"thickness_in":2,"sqft":20,"bdft":5,"width_min_in":1,"width_max_in":2,"width_avg_in":1.5, "bogus_field": 42}
resp = client.post("/api/v1/slabs", json=payload)
assert resp.status_code == 400
body = resp.json()
assert body["error"]["code"] == "validation"
assert "bogus_field" in body["error"]["detail"]["fields"]
def test_update_stale_field_returns_live_options(client, db):
# #82: stale_field returns LIVE taxonomy options (id+name), not the submitted id.
slab_id = str(uuid.uuid4())
payload = {
"id": slab_id, "sku": "SKU-STALE", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
}
assert client.post("/api/v1/slabs", json=payload).status_code == 201
resp = client.put(f"/api/v1/slabs/{slab_id}", json={"client_rev": 1, "edge_type_term_id": 999})
assert resp.status_code == 422
body = resp.json()
assert body["error"]["code"] == "stale_field"
opts = body["error"]["detail"]["current_options"]
assert isinstance(opts, list) and opts
assert all(isinstance(o, dict) and "id" in o and "name" in o for o in opts)
assert 999 not in [o["id"] for o in opts]
assert "Edge type" in body["error"]["message"] or "edge" in body["error"]["message"].lower()
def test_create_extra_key_and_bad_taxonomy_are_4xx(client, db):
"""#83: server-managed/extra keys and bad taxonomy/enums are 4xx, not 500."""
base = {
"id": str(uuid.uuid4()), "sku": "SKU-XTRA", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5,
"status": "published", # server-managed → unknown field 400
}
r = client.post("/api/v1/slabs", json=base)
assert r.status_code == 400, r.text
assert "status" in r.json()["error"]["detail"]["fields"]
# Bad taxonomy on create
r2 = client.post("/api/v1/slabs", json={
"id": str(uuid.uuid4()), "sku": "SKU-BADTAX", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "species_id": 999,
})
assert r2.status_code == 422
assert r2.json()["error"]["code"] == "stale_field"
# Bad enum
r3 = client.post("/api/v1/slabs", json={
"id": str(uuid.uuid4()), "sku": "SKU-BADENUM", "length_in": 10, "thickness_in": 2, "sqft": 20, "bdft": 5,
"width_min_in": 1, "width_max_in": 2, "width_avg_in": 1.5, "price_source": "not-a-source",
})
assert r3.status_code == 400
assert r3.json()["error"]["code"] == "validation"