"""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: the review always returns the deterministic title/short_description # built from the current metadata (the Create button's source of truth). r = env[0].post("/api/v1/slabs/s1/review", json={}) assert r.status_code == 200, r.text body = r.json() 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 body["title_built"] is True assert body["title_build_missing"] == [] assert "18.4 bdft" in body["short_description"] 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: the deterministic build runs on every review. r = client.post("/api/v1/slabs/s1/review", json={}) assert r.json()["title"] == "Figured Black Walnut Knife Blank (12 x 6.5 x 1.5 in)" def test_review_always_reports_deterministic_title(env): """#89: the review always returns the deterministic title/short_description built from the current metadata. The client's Create button writes these into the fields; the server does not persist them from the review payload.""" 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["title"] == "Figured Black Walnut Live Edge Slab" assert body["short_description"].startswith("Figured Black Walnut Live Edge Slab,") # The review does NOT persist title/short_description from the payload. got = client.get("/api/v1/slabs/s1").json() assert got["title"] is None assert got["short_description"] is None def test_review_no_species_reports_stored_title(env): """With no species, there is no deterministic pattern; the review reports the stored title (None here) and lists title as missing.""" 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,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={}) body = r.json() assert body["title"] is None assert "title" in body["missing"] def test_review_persists_owner_overrides(env): """#80: regular review persists price/sku overrides from the payload to the DB, so the client doesn't need a separate PUT for each. (title/short_description are NOT persisted here — they come from the deterministic build, not the payload.)""" 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() # Review with owner overrides for price + sku. r = client.post("/api/v1/slabs/s1/review", json={ "price": 250.0, "sku": "SLAB-99", }) assert r.status_code == 200 body = r.json() assert body["price"] == 250.0 # The overrides are persisted: GET reflects them. got = client.get("/api/v1/slabs/s1").json() assert got["price"] == 250.0 assert got["sku"] == "SLAB-99" # server_rev was incremented (the UPDATE fired). assert got["server_rev"] == 2 def test_ready_requires_figure_term_ids(env): """UX requires ≥1 figure — figure_term_ids is in _REQUIRED.""" from backend.review import missing_required, compute_ready slab = _slab(figure_term_ids=[]) miss = missing_required(slab) assert "figure_term_ids" in miss ready, missing = compute_ready({**slab, "price": 1.0, "price_source": "manual", "title": "T", "short_description": "S", "edge_type_term_id": 1, "grade_term_id": 1, "thickness_term_id": 1, "moisture_term_id": 1}) assert ready is False assert "figure_term_ids" in missing ready2, missing2 = compute_ready({ **slab, "figure_term_ids": [1], "price": 1.0, "price_source": "manual", "title": "T", "short_description": "S", "edge_type_term_id": 1, "grade_term_id": 1, "thickness_term_id": 1, "moisture_term_id": 1, }) # May still miss other fields depending on fixture; figure must not be listed assert "figure_term_ids" not in missing2 def test_review_title_built_false_without_species_or_primary(env): """Create-title silent-fail fix: report title_built=false when taxonomy cannot resolve.""" 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", None, 114.0, 1.75, 30.0, 18.4, 18.0, 24.3, 24.3, None, "[]", "[]", None, None, "Typed Title", None, None, "manual", "calibrated", 1, 1, "t", "t"), ) conn.commit() r = client.post("/api/v1/slabs/s1/review", json={}) assert r.status_code == 200, r.text body = r.json() assert body["title_built"] is False assert "species" in body["title_build_missing"] assert "primary_wood_category" in body["title_build_missing"] assert body["title"] == "Typed Title" assert body["short_description"] is None