fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review

The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.

- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
  stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
  ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
  inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
  ready→publish→done); mock respects optimistic concurrency (client_rev) so
  stale PUTs 409 like the real backend
This commit is contained in:
Ty
2026-09-20 23:36:31 -07:00
parent 41401a7266
commit 693719e85f
8 changed files with 329 additions and 170 deletions
+88 -2
View File
@@ -55,12 +55,19 @@ def test_title_live_edge_no_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = env[0].post("/api/v1/slabs/s1/review", json={})
# #89: title/short_description are built AND persisted only after an explicit lock.
r = env[0].post("/api/v1/slabs/s1/review", json={"action": "lock"})
assert r.status_code == 200, r.text
body = r.json()
assert body["locked"] is True
assert body["title"] == "Figured Black Walnut Live Edge Slab"
assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in")
assert "18.4 bdft" in body["short_description"]
# GET shows the stored (persisted) copy — it matches the deterministic pattern.
got = env[0].get("/api/v1/slabs/s1").json()
assert got["title"] == "Figured Black Walnut Live Edge Slab"
assert got["short_description"].startswith("Figured Black Walnut Live Edge Slab,")
assert got["metadata_locked"] is True
def test_ready_requires_full_set(env):
@@ -134,5 +141,84 @@ def test_knife_blank_carries_dims(env):
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",12.0,1.5,6.0,0.7,6.0,6.5,6.5,1,json.dumps([3]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
# #89: build happens on lock.
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)"
# --- #89: explicit lock gate for deterministic title/short_description ---
def test_review_before_lock_reports_no_title(env):
"""#89 AC: until lock, title/short_description are not set (no 'ready' copy
from an unlocked AI draft). A plain review reports the stored (empty) values."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={})
body = r.json()
assert body["locked"] is False
assert body["title"] is None
assert body["short_description"] is None
# title/short_description are in the missing set (not ready until locked).
assert "title" in body["missing"] and "short_description" in body["missing"]
# Nothing was persisted: GET still shows empty copy.
assert client.get("/api/v1/slabs/s1").json()["title"] is None
def test_lock_persists_title_and_get_matches(env):
"""#89 AC: after lock with species + primary wood category, title/short_desc
are populated deterministically, stored, and GET reflects them."""
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
assert r.status_code == 200
body = r.json()
assert body["locked"] is True
assert body["title"] == "Figured Black Walnut Live Edge Slab"
got = client.get("/api/v1/slabs/s1").json()
assert got["title"] == body["title"]
assert got["short_description"] == body["short_description"]
assert got["metadata_locked"] is True
def test_lock_requires_species(env):
client, conn = env
# No species_id -> lock is rejected with a plain message.
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,None,json.dumps([2]),None,None,None,"calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
assert r.status_code == 422
assert r.json()["error"]["code"] == "not_ready"
def test_lock_requires_wood_category(env):
client, conn = env
# species present but no wood category -> rejected.
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,price,title,short_description,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([]),None,None,None,"calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={"action": "lock"})
assert r.status_code == 422
assert r.json()["error"]["code"] == "not_ready"
def test_lock_owner_prose_wins(env):
# METADATA-50: typed owner title/short_description win over the pattern.
client, conn = env
conn.execute("INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in,species_id,wood_category_ids,figure_term_ids,price,price_source,title,short_description,description,content_source,status,server_rev,client_rev,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("s1","SLAB-01",114.0,1.75,30.0,18.4,18.0,24.3,24.3,1,json.dumps([2]),json.dumps([1]),None,None,None,None,"A slab","manual","calibrated",1,1,"t","t"))
conn.commit()
r = client.post("/api/v1/slabs/s1/review", json={
"action": "lock",
"title": "My Handcrafted Walnut Slab",
"short_description": "Owner-authored short description.",
})
body = r.json()
assert body["title"] == "My Handcrafted Walnut Slab"
assert body["short_description"] == "Owner-authored short description."
got = client.get("/api/v1/slabs/s1").json()
assert got["title"] == "My Handcrafted Walnut Slab"