0c00d25cc7
- #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
363 lines
16 KiB
Python
363 lines
16 KiB
Python
"""S6 publish sequence tests (PUBLISH-5/10/15/25/30/31). Woo transport is mocked."""
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import backend.db as db_mod
|
|
|
|
|
|
def _full_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": 1,
|
|
"figure_term_ids": json.dumps([1]), "grade_term_id": 1, "thickness_term_id": 1,
|
|
"moisture_term_id": 1, "fig_tag_ids": json.dumps([]), "feat_tag_ids": json.dumps([]),
|
|
"price": 184.0, "price_source": "recommendation",
|
|
"title": "Figured Black Walnut Live Edge Slab", "short_description": "slab, 9ft 6in",
|
|
"description": "A fine slab.", "content_source": "manual", "status": "ready",
|
|
"client_rev": 1, "server_rev": 1, "deleted_at": None, "created_at": "t", "updated_at": "t",
|
|
"woo_product_id": None,
|
|
}
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
@pytest.fixture
|
|
def env(monkeypatch):
|
|
tmp = tempfile.mkdtemp(prefix="pub_")
|
|
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db")
|
|
os.environ["SLAB_IMAGES_DIR"] = tmp
|
|
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
|
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
|
db_mod._reset_for_tests()
|
|
from backend.crypto import encrypt
|
|
|
|
conn = db_mod.init_db()
|
|
# taxonomy
|
|
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 (8,'Edge Type','edge-type',200)")
|
|
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)")
|
|
# Thickness bands, names as synced (the derivation parses them) — QUALITY-95.
|
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (9,'Thickness','pa_thk',201)")
|
|
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 INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (?,9,?,?,?)",
|
|
(tid, name, slug, tid),
|
|
)
|
|
# Moisture keeps its own attribute so the five locked attributes stay distinct.
|
|
conn.execute("INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (10,'Moisture','pa_mst',202)")
|
|
conn.execute("INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (326,10,'Kiln dried','kd',326)")
|
|
# creds
|
|
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_wp_username','admin',NULL)")
|
|
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('woo_app_password',NULL,?)", (encrypt("secret"),))
|
|
# fresh sync stamp so we skip re-sync
|
|
from datetime import datetime, timezone
|
|
|
|
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('taxonomy_last_sync_at',?,NULL)",
|
|
(datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),))
|
|
conn.commit()
|
|
from backend.app import app
|
|
|
|
yield TestClient(app), conn
|
|
db_mod._reset_for_tests()
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
def _insert_slab(conn, s):
|
|
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,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id,"
|
|
"moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description,"
|
|
"content_source,status,server_rev,client_rev,created_at,updated_at) VALUES "
|
|
"(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
(s["id"], s["sku"], s["length_in"], s["thickness_in"], s["sqft"], s["bdft"], s["width_min_in"],
|
|
s["width_max_in"], s["width_avg_in"], s["species_id"], s["wood_category_ids"], s["edge_type_term_id"],
|
|
s["figure_term_ids"], s["grade_term_id"], s["thickness_term_id"], s["moisture_term_id"],
|
|
s["fig_tag_ids"], s["feat_tag_ids"], s["price"], s["price_source"], s["title"], s["short_description"],
|
|
s["description"], s["content_source"], s["status"], 1, 1, "t", "t"),
|
|
)
|
|
|
|
|
|
def _install_mock(monkeypatch, sku_exists=0, product_id=999, media_ids=(42,)):
|
|
from backend.woo import WooClient
|
|
|
|
state = {"media_n": 0}
|
|
|
|
def fake_request(url, method, data, headers):
|
|
if "/products?sku=" in url:
|
|
body = json.dumps([{"id": 1}] * sku_exists).encode()
|
|
return 200, {}, body
|
|
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
|
mid = media_ids[min(state["media_n"], len(media_ids) - 1)]
|
|
state["media_n"] += 1
|
|
return 201, {}, json.dumps({"id": mid}).encode()
|
|
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
|
return 201, {}, json.dumps({"id": product_id}).encode()
|
|
return 200, {}, b"[]"
|
|
|
|
orig_init = WooClient.__init__
|
|
|
|
def patched_init(self, *a, **k):
|
|
orig_init(self, *a, **k)
|
|
self._request = fake_request
|
|
|
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
|
|
|
|
|
def test_publish_success(env, monkeypatch):
|
|
client, conn = env
|
|
s = _full_slab()
|
|
_insert_slab(conn, s)
|
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
|
|
conn.commit()
|
|
_install_mock(monkeypatch, sku_exists=0, product_id=999, media_ids=(42,))
|
|
r = client.post("/api/v1/slabs/s1/publish")
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["ok"] is True and body["woo_product_id"] == 999 and body["draft"] is False
|
|
row = conn.execute("SELECT status, woo_product_id FROM slabs WHERE id='s1'").fetchone()
|
|
assert row["status"] == "published" and row["woo_product_id"] == 999
|
|
# photos purged
|
|
assert conn.execute("SELECT processed_blob FROM slab_photos WHERE id='p1'").fetchone()["processed_blob"] is None
|
|
|
|
|
|
def test_publish_sends_derived_thickness_band(env, monkeypatch):
|
|
"""#90 / QUALITY-95: the band is off the Review screen but must still reach Woo.
|
|
|
|
The owner has no thickness-band control, so nothing client-side carries it: the
|
|
server derives it from the measurement on create/update, and publish must send the
|
|
live synced term name on the product payload. This pins that chain end to end.
|
|
"""
|
|
client, conn = env
|
|
create = client.post("/api/v1/slabs", json={
|
|
"id": "s1", "sku": "SLAB-BAND", "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,
|
|
})
|
|
assert create.status_code == 201, create.text
|
|
# 1.75 in bands into the live '1 1/2" - 2"' term — never sent by the client.
|
|
assert create.json()["thickness_term_id"] == 351
|
|
|
|
put = client.put("/api/v1/slabs/s1", json={
|
|
"client_rev": create.json()["server_rev"], "species_id": 1, "wood_category_ids": [2],
|
|
"edge_type_term_id": 1, "figure_term_ids": [1], "grade_term_id": 1, "moisture_term_id": 326,
|
|
"price": 184.0, "price_source": "recommendation", "title": "T", "short_description": "S",
|
|
"description": "D", "content_source": "manual",
|
|
})
|
|
assert put.status_code == 200, put.text
|
|
conn.execute("UPDATE slabs SET status='ready' WHERE id='s1'")
|
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
|
|
conn.commit()
|
|
|
|
products = []
|
|
|
|
def fake_request(url, method, data, headers):
|
|
if "/products?sku=" in url:
|
|
return 200, {}, b"[]"
|
|
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
|
return 201, {}, json.dumps({"id": 42}).encode()
|
|
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
|
products.append(json.loads(data))
|
|
return 201, {}, json.dumps({"id": 999}).encode()
|
|
return 200, {}, b"[]"
|
|
|
|
from backend.woo import WooClient
|
|
|
|
orig_init = WooClient.__init__
|
|
|
|
def patched_init(self, *a, **k):
|
|
orig_init(self, *a, **k)
|
|
self._request = fake_request
|
|
|
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
|
r = client.post("/api/v1/slabs/s1/publish")
|
|
assert r.status_code == 200, r.text
|
|
assert len(products) == 1, "expected exactly one product create"
|
|
attrs = {a["id"]: a for a in products[0].get("attributes", [])}
|
|
assert 9 in attrs, f"Thickness attribute missing from the Woo payload: {products[0].get('attributes')}"
|
|
assert attrs[9]["options"] == ['1 1/2" - 2"']
|
|
|
|
|
|
def test_publish_woo_image_order_by_role(env, monkeypatch):
|
|
# PUBLISH-45: Woo order is clean cal, dimension-overlay, then batch — by
|
|
# role, not by stored seq (the Review strip stores the owner's keep order).
|
|
client, conn = env
|
|
s = _full_slab()
|
|
_insert_slab(conn, s)
|
|
# Stored seq deliberately out of lineup order: batch(1), calibration(2),
|
|
# overlay(3). The publish query must reorder to cal, overlay, batch.
|
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pb','s1',1,0,'batch',X'89504E47','t')")
|
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('pc','s1',2,1,'calibration',X'89504E47','t')")
|
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,role,processed_blob,created_at) VALUES ('po','s1',3,0,'overlay',X'89504E47','t')")
|
|
conn.commit()
|
|
media_calls = []
|
|
|
|
def fake_request(url, method, data, headers):
|
|
if "/products?sku=" in url:
|
|
return 200, {}, b"[]"
|
|
if url.endswith("/wp-json/wp/v2/media") and method == "POST":
|
|
media_calls.append(data)
|
|
return 201, {}, json.dumps({"id": 42}).encode()
|
|
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
|
return 201, {}, json.dumps({"id": 999}).encode()
|
|
return 200, {}, b"[]"
|
|
|
|
from backend.woo import WooClient
|
|
|
|
orig_init = WooClient.__init__
|
|
|
|
def patched_init(self, *a, **k):
|
|
orig_init(self, *a, **k)
|
|
self._request = fake_request
|
|
|
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
|
r = client.post("/api/v1/slabs/s1/publish")
|
|
assert r.status_code == 200, r.text
|
|
assert len(media_calls) == 3
|
|
# The photo id is embedded in the multipart filename (slab.py publish
|
|
# passes f"{sku}-{photo_id}.png"). Order of media uploads = cal, overlay,
|
|
# batch (PUBLISH-45), not stored seq.
|
|
def photo_id(body: bytes) -> str:
|
|
import re
|
|
m = re.search(rb'filename="[^"]*-(pc|pb|po)\.png"', body)
|
|
return m.group(1).decode() if m else ""
|
|
|
|
assert [photo_id(c) for c in media_calls] == ["pc", "po", "pb"]
|
|
|
|
|
|
def test_uat_draft(env, monkeypatch):
|
|
client, conn = env
|
|
s = _full_slab(sku="SLAB-UAT-1")
|
|
_insert_slab(conn, s)
|
|
conn.commit()
|
|
_install_mock(monkeypatch)
|
|
r = client.post("/api/v1/slabs/s1/publish")
|
|
assert r.status_code == 200
|
|
assert r.json()["draft"] is True
|
|
|
|
|
|
def test_not_ready_422(env, monkeypatch):
|
|
client, conn = env
|
|
s = _full_slab(price=None, price_source=None)
|
|
_insert_slab(conn, s)
|
|
conn.commit()
|
|
_install_mock(monkeypatch)
|
|
r = client.post("/api/v1/slabs/s1/publish")
|
|
assert r.status_code == 422
|
|
assert "price" in r.json()["error"]["detail"]["missing"]
|
|
|
|
|
|
def test_duplicate_sku_409(env, monkeypatch):
|
|
client, conn = env
|
|
s = _full_slab(sku="SLAB-DUP")
|
|
_insert_slab(conn, s)
|
|
conn.commit()
|
|
_install_mock(monkeypatch, sku_exists=1)
|
|
r = client.post("/api/v1/slabs/s1/publish")
|
|
assert r.status_code == 409
|
|
assert r.json()["error"]["code"] == "duplicate_sku"
|
|
|
|
|
|
def test_partial_media_failure_502(env, monkeypatch):
|
|
client, conn = env
|
|
s = _full_slab()
|
|
_insert_slab(conn, s)
|
|
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,is_calibration,processed_blob,created_at) VALUES ('p1','s1',1,1,X'89504E47','t')")
|
|
conn.commit()
|
|
from backend.woo import WooClient, WooError
|
|
|
|
def fake_request(url, method, data, headers):
|
|
if "/products?sku=" in url:
|
|
return 200, {}, b"[]"
|
|
if url.endswith("/wp-json/wp/v2/media"):
|
|
return 201, {}, json.dumps({"id": 42}).encode()
|
|
if url.endswith("/wp-json/wc/v3/products"):
|
|
raise WooError("product create returned 500")
|
|
return 200, {}, b"[]"
|
|
|
|
orig_init = WooClient.__init__
|
|
|
|
def patched_init(self, *a, **k):
|
|
orig_init(self, *a, **k)
|
|
self._request = fake_request
|
|
|
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
|
r = client.post("/api/v1/slabs/s1/publish")
|
|
assert r.status_code == 502
|
|
assert r.json()["error"]["detail"]["partial_media"] is True
|
|
# sync_log written, status failed
|
|
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
|
|
assert conn.execute("SELECT COUNT(*) c FROM sync_log WHERE status='failed'").fetchone()["c"] >= 1
|
|
|
|
|
|
def test_publish_sends_user_agent_on_sku_and_create(env, monkeypatch):
|
|
"""#79: every publish Woo hop carries User-Agent (same transport as client)."""
|
|
client, conn = env
|
|
s = _full_slab()
|
|
_insert_slab(conn, s)
|
|
conn.commit()
|
|
seen = []
|
|
|
|
def fake_request(url, method, data, headers):
|
|
seen.append({"url": url, "method": method, "headers": dict(headers or {})})
|
|
if "/products?sku=" in url:
|
|
return 200, {}, b"[]"
|
|
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
|
|
return 201, {}, json.dumps({"id": 42}).encode()
|
|
return 200, {}, b"[]"
|
|
|
|
from backend.woo import WooClient, _USER_AGENT
|
|
|
|
orig_init = WooClient.__init__
|
|
|
|
def patched_init(self, *a, **k):
|
|
orig_init(self, *a, **k)
|
|
self._request = fake_request
|
|
|
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
|
r = client.post("/api/v1/slabs/s1/publish")
|
|
assert r.status_code == 200, r.text
|
|
sku_hops = [h for h in seen if "/products?sku=" in h["url"]]
|
|
create_hops = [h for h in seen if h["url"].endswith("/wp-json/wc/v3/products") and h["method"] == "POST"]
|
|
assert sku_hops and create_hops
|
|
for hop in sku_hops + create_hops:
|
|
assert hop["headers"].get("User-Agent") == _USER_AGENT
|
|
|
|
|
|
def test_publish_403_is_unrecoverable(env, monkeypatch):
|
|
"""#79: WAF 403 on SKU lookup is unrecoverable — do not invite retry."""
|
|
client, conn = env
|
|
s = _full_slab()
|
|
_insert_slab(conn, s)
|
|
conn.commit()
|
|
from backend.woo import WooClient, WooForbiddenError
|
|
|
|
def fake_request(url, method, data, headers):
|
|
raise WooForbiddenError("request forbidden (403)")
|
|
|
|
orig_init = WooClient.__init__
|
|
|
|
def patched_init(self, *a, **k):
|
|
orig_init(self, *a, **k)
|
|
self._request = fake_request
|
|
|
|
monkeypatch.setattr(WooClient, "__init__", patched_init)
|
|
r = client.post("/api/v1/slabs/s1/publish")
|
|
assert r.status_code == 502
|
|
body = r.json()
|
|
assert body["error"]["code"] == "woo_forbidden"
|
|
assert body["error"]["detail"]["recoverable"] is False
|
|
assert "retry" not in body["error"]["message"].lower() or "will not help" in body["error"]["message"].lower()
|