Files
SlabUploader/backend/tests/test_review.py
T
Ty 1a50fad0a5 merge issue-89-metadata-lock → main (Create-string model, #89/#90)
Drop the metadata_locked column + "Save and lock" UI end-to-end. The
review endpoint now builds the deterministic title/short_description on
every call; the owner's Create title / Create short desc buttons fire
the build and write the result (force-overwrite contract). commitReview
auto-fills only empty fields (no owner-clobber) and folds the
deterministic copy into its single PUT (kills the client_rev 409
cascade).

Taken wholesale from the branch: photo strip, wood primary/secondary,
server-side thickness band (hidden from UI, passed to Woo on publish),
and fixes #80/#82/#83/#84.

- backend: remove metadata_locked from schema.sql, db sentinels,
  slabs.py client fields/exposure, review.py lock action; review
  always computes title/short_desc
- frontend: slabs.ts + Review.svelte drop the lock model, add
  Create-string workflow + wood primary + photo strip
- e2e: m3-create + review-photo-strip rewritten for the Create-string
  contract; thickness-band test asserts absence (per #90)
- deploy: recreate LAN binds (override-m3.yml was absent); verify
  metadata_locked gone, review builds title, PUT persists

Gates: backend 117/117, unit 88/88, tsc clean, e2e 6/6 (m3-create +
photo-strip). Live UAT verified at 192.168.1.202:10881.
2026-09-21 11:54:48 -07:00

197 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 "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