693719e85f
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
225 lines
12 KiB
Python
225 lines
12 KiB
Python
"""S5 deterministic copy + ready derivation tests (METADATA-80/85/90, REVIEW-5/25)."""
|
||
|
||
import base64
|
||
import json
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
import backend.db as db_mod
|
||
from backend import review as review_mod
|
||
|
||
|
||
@pytest.fixture
|
||
def env():
|
||
tmp = tempfile.mkdtemp(prefix="review_")
|
||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "r.db")
|
||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||
os.environ["WOO_BASE_URL"] = "https://example.com"
|
||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||
db_mod._reset_for_tests()
|
||
conn = db_mod.init_db()
|
||
# taxonomy: species + a live-edge-wood leaf + figure term
|
||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Black Walnut','black-walnut',NULL,100)")
|
||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge wood','live-edge-wood',NULL,101)")
|
||
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (1,'Figure','figure',200)")
|
||
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,1,'Figured','figured',300)")
|
||
conn.commit()
|
||
from backend.app import app
|
||
|
||
yield TestClient(app), conn
|
||
db_mod._reset_for_tests()
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
|
||
def _slab(sku="SLAB-01", **over):
|
||
base = {
|
||
"id": "s1", "sku": sku, "length_in": 114.0, "thickness_in": 1.75, "sqft": 30.0,
|
||
"bdft": 18.4, "width_min_in": 18.0, "width_max_in": 24.3, "width_avg_in": 24.3,
|
||
"species_id": 1, "wood_category_ids": json.dumps([2]), "edge_type_term_id": None,
|
||
"figure_term_ids": json.dumps([1]), "grade_term_id": None, "thickness_term_id": None,
|
||
"moisture_term_id": None, "fig_tag_ids": "[]", "feat_tag_ids": "[]",
|
||
"price": None, "price_source": None, "title": None, "short_description": None,
|
||
"description": "A fine slab.", "content_source": "manual", "status": "calibrated",
|
||
"client_rev": 1, "server_rev": 1, "deleted_at": None, "created_at": "t", "updated_at": "t",
|
||
}
|
||
base.update(over)
|
||
return base
|
||
|
||
|
||
def test_title_live_edge_no_dims(env):
|
||
_, 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()
|
||
# #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):
|
||
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["ready"] is False
|
||
# price is empty (no rate) and title/short_description generated -> missing includes price, price_source, grade/thickness/moisture terms
|
||
assert "price" in body["missing"]
|
||
|
||
|
||
def test_price_suggestion_from_rate(env):
|
||
client, conn = env
|
||
conn.execute("INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,10.0,'t','t')")
|
||
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["price_suggestion"] == 184.0 # 18.4 bdft * $10
|
||
assert body["price"] == 184.0
|
||
|
||
|
||
def test_price_suggestion_tv8_quality_90(env):
|
||
"""QUALITY-90 / TV-8: 0.96 bdft × $12.50 → 12.00."""
|
||
client, conn = env
|
||
conn.execute(
|
||
"INSERT INTO pricing_rules(species_id,price_per_bdft,created_at,updated_at) VALUES (1,12.50,'t','t')"
|
||
)
|
||
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,
|
||
0.96,
|
||
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["price_suggestion"] == 12.00
|
||
assert body["price"] == 12.00
|
||
|
||
|
||
def test_knife_blank_carries_dims(env):
|
||
client, conn = env
|
||
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (3,'Knife blanks','knife-blanks',NULL,102)")
|
||
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()
|
||
# #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"
|