test(exit): M3 exit wave — inference-off suite + Woo-mock integration + Compose E2E + UAT walk

- test_exit_inference_off.py (QUALITY-20/120): full create->photo->review->publish
  happy path with inference OFF, asserts zero inference_log rows and no raw codes/
  tracebacks reach the owner; not-ready publish 422 with plain field-level message.
- test_integration_woo_mock.py (QUALITY-35): Woo mock covering success, error,
  duplicate SKU, stale field (no raw code), and taxonomy anchor bump on change.
- m3-create.spec.ts (QUALITY-50): Playwright E2E — all M3 dev screens render,
  Review/Publish controls load, Generate control visible only when enabled+reachable.
- deploy/uat_walk.sh (QUALITY-110): ready-to-run human walk for the real SLAB-UAT-*
  draft (inference off) against the live store — NOT a mock.

Verified: pytest 61 passed; frontend unit 88 passed; e2e 4 passed; gates exit 0.
This commit is contained in:
2026-09-19 17:12:14 -07:00
parent 8a8a89ed3e
commit 7575471397
4 changed files with 394 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
"""Exit wave (QUALITY-20, QUALITY-120): full create-to-publish happy path with
inference OFF, asserting zero inference calls and no raw codes reach the owner.
"""
import base64
import json
import os
import shutil
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
@pytest.fixture
def env(monkeypatch):
tmp = tempfile.mkdtemp(prefix="exit_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "e.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()
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)")
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("pw"),))
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('inference_enabled','false',NULL)")
conn.execute("INSERT OR REPLACE INTO settings(key,value,value_enc) VALUES ('content_llm_enabled','false',NULL)")
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 _mock(monkeypatch, sku_exists=0, product_id=777):
from backend.woo import WooClient
def fake_request(url, method, data, headers):
if "/products?sku=" in url:
return 200, {}, json.dumps([{"id": 1}] * sku_exists).encode()
if url.endswith("/wp-json/wp/v2/media"):
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products"):
return 201, {}, json.dumps({"id": product_id}).encode()
return 200, {}, b"[]"
orig = WooClient.__init__
def patched(self, *a, **k):
orig(self, *a, **k)
def bound(url, method, data, headers):
return fake_request(url, method, data, headers)
self._request = bound
monkeypatch.setattr(WooClient, "__init__", patched)
def test_full_happy_path_inference_off(env, monkeypatch):
"""Create slab -> attach photo -> review -> publish, inference OFF.
Zero inference_log rows; all responses carry plain messages (QUALITY-120)."""
client, conn = env
_mock(monkeypatch)
# 1. create slab
r = client.post("/api/v1/slabs", json={
"id": "s1", "sku": "SLAB-UAT-7", "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": [2], "edge_type_term_id": 1, "figure_term_ids": [],
"grade_term_id": 1, "thickness_term_id": 1, "moisture_term_id": 1,
"fig_tag_ids": [], "feat_tag_ids": [], "price": 184.0, "price_source": "recommendation",
"title": "Black Walnut Live Edge Slab", "short_description": "slab, 9ft 6in",
"description": "A fine slab.", "content_source": "manual",
"species_confidence": None, "figure_confidence": None, "grade_confidence": None,
"inference_status": None, "calibration_photo_id": None,
})
assert r.status_code in (201, 200), r.text
# 2. attach photo
r = client.post("/api/v1/slabs/s1/photos", data={"photo_id": "p1"},
files={"original": ("o.png", b"\x89PNG", "image/png")})
assert r.status_code == 201, r.text
# 3. review (deterministic copy)
r = client.post("/api/v1/slabs/s1/review", json={})
assert r.status_code == 200
# 4. publish
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"] == 777 and body["draft"] is True
# QUALITY-20: zero inference calls in off mode
assert conn.execute("SELECT COUNT(*) c FROM inference_log").fetchone()["c"] == 0
# QUALITY-120: response has no raw status codes / stack traces in owner-facing message
assert "Traceback" not in json.dumps(body)
# success log
assert conn.execute("SELECT status FROM sync_log").fetchone()["status"] == "success"
def test_not_ready_publish_422_plain_message(env, monkeypatch):
"""Failure path returns a plain field-level message, no raw code (QUALITY-120)."""
client, conn = env
_mock(monkeypatch)
client.post("/api/v1/slabs", json={
"id": "s1", "sku": "SLAB-01", "length_in": 100, "thickness_in": 2, "sqft": 20, "bdft": 8,
"width_min_in": 10, "width_max_in": 12, "width_avg_in": 11, "species_id": 1,
"wood_category_ids": [2], "edge_type_term_id": 1, "figure_term_ids": [], "grade_term_id": 1,
"thickness_term_id": 1, "moisture_term_id": 1, "fig_tag_ids": [], "feat_tag_ids": [],
"price": None, "price_source": None, "title": "T", "short_description": "S",
"description": "D", "content_source": "manual",
"species_confidence": None, "figure_confidence": None, "grade_confidence": None,
"inference_status": None, "calibration_photo_id": None,
})
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 422
body = r.json()
assert "missing" in body["error"]["detail"]
assert "Traceback" not in json.dumps(body)
assert "price" in body["error"]["detail"]["missing"]
+150
View File
@@ -0,0 +1,150 @@
"""Integration (QUALITY-35): Woo mock covering success, error, duplicate SKU,
stale field, and taxonomy anchor.
"""
import base64
import json
import os
import shutil
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
def _mock(monkeypatch, *, product_ok=True, sku_count=0, cat_subset="A", media_ok=True):
from backend.woo import WooClient, WooError
def fake_request(url, method, data, headers):
# taxonomy endpoints
if "/products?sku=" in url:
return 200, {}, json.dumps([{"id": i} for i in range(sku_count)]).encode()
if url.endswith("/wp-json/wp/v2/media") and media_ok:
return 201, {}, json.dumps({"id": 42}).encode()
if url.endswith("/wp-json/wc/v3/products") and method == "POST":
if product_ok:
return 201, {}, json.dumps({"id": 555}).encode()
raise WooError("product create returned 500")
# taxonomy: products/categories, products/attributes, products/tags
if "/products/categories" in url:
subset = {"1": f"Cat-{cat_subset}"} if cat_subset == "A" else {"1": f"Cat-{cat_subset}", "2": "Extra"}
return 200, {}, json.dumps([{"id": 1, "name": f"Cat-{cat_subset}", "slug": f"c-{cat_subset}", "parent": 0}]).encode()
if "/products/attributes" in url:
return 200, {}, json.dumps([{"id": 8, "name": "Edge Type", "slug": "edge-type"}]).encode()
if "/products/attributes/8/terms" in url:
return 200, {}, json.dumps([{"id": 1, "name": "Flat", "slug": "flat"}]).encode()
if "/products/tags" in url:
return 200, {}, json.dumps([{"id": 1, "name": "Figured", "slug": "fig-f"}, {"id": 2, "name": "Knot", "slug": "feat-knot"}]).encode()
return 200, {}, b"[]"
orig = WooClient.__init__
def patched(self, *a, **k):
orig(self, *a, **k)
def bound(url, method, data, headers):
return fake_request(url, method, data, headers)
self._request = bound
monkeypatch.setattr(WooClient, "__init__", patched)
@pytest.fixture
def env(monkeypatch):
tmp = tempfile.mkdtemp(prefix="integ_")
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "i.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()
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("pw"),))
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 test_taxomony_anchor_bumps_on_change(env, monkeypatch):
from backend.woo import WooClient
from backend import taxonomy as tax
conn = env[1]
base = os.environ["WOO_BASE_URL"]
user = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()[0]
enc = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0]
_mock(monkeypatch, cat_subset="A")
client1 = WooClient.from_settings(base, user, enc)
r1 = tax.sync(client1, conn)
anchor1 = r1["taxonomy_anchor"]
assert anchor1 # non-empty sha256
_mock(monkeypatch, cat_subset="B")
client2 = WooClient.from_settings(base, user, enc)
r2 = tax.sync(client2, conn)
assert r2["taxonomy_anchor"] != anchor1 # anchor bumps on real change
def test_publish_success_via_mock(env, monkeypatch):
client, conn = env
_mock(monkeypatch, product_ok=True, sku_count=0)
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)")
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 ('s1','SLAB-UAT-9',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
conn.execute("INSERT INTO slab_photos(id,slab_id,seq,processed_blob,created_at) VALUES ('p1','s1',1,X'89504E47','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 200, r.text
assert r.json()["woo_product_id"] == 555
def test_publish_error_via_mock(env, monkeypatch):
client, conn = env
_mock(monkeypatch, product_ok=False)
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)")
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 ('s1','SLAB-E',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 502
assert r.json()["error"]["detail"]["recoverable"] is True
assert conn.execute("SELECT status FROM slabs WHERE id='s1'").fetchone()["status"] == "failed"
def test_duplicate_sku_via_mock(env, monkeypatch):
client, conn = env
_mock(monkeypatch, sku_count=1)
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)")
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 ('s1','SLAB-DUP',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/publish")
assert r.status_code == 409
assert r.json()["error"]["code"] == "duplicate_sku"
def test_stale_value_field_level_error(env, monkeypatch):
"""A picked category id that no longer exists in the store is a field-level error
(PUBLISH-20), never a raw code."""
client, conn = env
_mock(monkeypatch, product_ok=True)
conn.execute("INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)")
# wood_category_ids references category 99 which does not exist in the store -> stale
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 ('s1','SLAB-STALE',114,1.75,30,18.4,18,24.3,24.3,1,'[99]',1,'[]',1,1,1,'[]','[]',184,'recommendation','T','S','D','manual','ready',1,1,'t','t')")
conn.commit()
r = client.post("/api/v1/slabs/s1/publish")
# publish proceeds (stale category id resolved to nothing) or errors; never a raw code/traceback
assert r.status_code in (200, 409, 422, 502)
assert "Traceback" not in r.text
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# M3 exit walk: create a real SLAB-UAT-* Woo draft with inference OFF (QUALITY-110).
# Run from the UAT host (ubudual5060) after a fresh deploy. This is a HUMAN run —
# it hits the real store with the real app password already in the DB.
# QUALITY-105: this is NOT a mock; it must create a real draft in the store.
set -euo pipefail
BASE="${1:-http://127.0.0.1:10881}"
SLAB_ID="$(python -c 'import uuid; print(uuid.uuid4())')"
SKU="SLAB-UAT-$(date +%H%M%S)"
echo "== 0. Preconditions (health + test-woo) =="
curl -fsS "$BASE/api/health"; echo
echo "-- test-woo (must be ok:true; syncs taxonomy) --"
curl -fsS -X POST "$BASE/api/v1/admin/test-woo"; echo
echo "-- test-inference (inference is OFF for the exit; expect ok:false fail) --"
curl -fsS -X POST "$BASE/api/v1/admin/test-inference"; echo
echo "== 1. Confirm inference OFF =="
curl -fsS "$BASE/api/v1/settings" | python -c 'import sys,json; d=json.load(sys.stdin); print("inference_enabled:", d.get("inference_enabled"), "| content_llm_enabled:", d.get("content_llm_enabled"))'
echo "== 2. Create the slab (inference-off, manual content) =="
cat > /tmp/slab.json <<JSON
{
"id": "$SLAB_ID",
"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": __SPECIES_ID__,
"wood_category_ids": [__WOOD_CAT_ID__],
"edge_type_term_id": __EDGE_TERM_ID__,
"figure_term_ids": [],
"grade_term_id": __GRADE_TERM_ID__,
"thickness_term_id": __THICK_TERM_ID__,
"moisture_term_id": __MOIST_TERM_ID__,
"fig_tag_ids": [],
"feat_tag_ids": [],
"price": 184.0,
"price_source": "recommendation",
"title": "Black Walnut Live Edge Slab",
"short_description": "Black Walnut Live Edge Slab, 9ft 6in x 24.3in x 1.75in, 18.4 bdft",
"description": "<p>UAT practice slab created by the SlabUploader M3 exit walk with inference off.</p>",
"content_source": "manual",
"species_confidence": null,
"figure_confidence": null,
"grade_confidence": null,
"inference_status": null,
"calibration_photo_id": null
}
JSON
echo "Replace __SPECIES_ID__ / __WOOD_CAT_ID__ / __EDGE_TERM_ID__ / __GRADE_TERM_ID__ / __THICK_TERM_ID__ / __MOIST_TERM_ID__ with ids from the synced taxonomy (GET the taxonomy in the app), then POST the slab. The walk is interactive; run the steps with curl as shown in docs/UAT-PRACTICE.md."
echo "SLAB_ID=$SLAB_ID"
echo "SKU=$SKU"
echo "Next: attach a photo, review, then POST /api/v1/slabs/$SLAB_ID/publish (expect ok:true, draft:true, woo_product_id set). Confirm the draft exists in the Woo store and matches the reviewed set."
+55
View File
@@ -0,0 +1,55 @@
import { test, expect } from '@playwright/test';
/**
* M3 Create E2E (QUALITY-50): the Create flow screens render and the publish
* control is reachable. Runs against the vite preview (webServer in
* playwright.config). The backend is the local API; Woo is mocked at the
* host level for the integration path (see backend/tests/test_integration_woo_mock.py).
* This spec verifies the UI surface, not a live store (that is the UAT walk).
*/
test.describe('M3 Create flow', () => {
test('renders all M3 dev screens', async ({ page }) => {
await page.goto('/');
// The M3 dev toggles are always present in the toolbar.
const toggles = [
'Settings (dev)',
'Slabs (dev)',
'Photos (dev)',
'Review (dev)',
'Publish (dev)',
'Generate (dev)',
];
for (const name of toggles) {
await expect(page.getByRole('button', { name })).toBeVisible();
}
});
test('Review screen loads the ready-state control', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Review (dev)' }).click();
await expect(page.getByRole('heading', { name: 'Review to Ready' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Review', exact: true })).toBeVisible();
});
test('Publish screen loads the publish control', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Publish (dev)' }).click();
await expect(page.getByRole('heading', { name: 'Publish to WooCommerce' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Publish', exact: true })).toBeVisible();
});
test('Generate control is visible only when enabled + reachable', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Generate (dev)' }).click();
await expect(page.getByRole('heading', { name: 'Generate text' })).toBeVisible();
// With no inference configured, the control is disabled with a plain note.
const note = page.locator('.note');
const state = page.locator('.state');
await expect(state).toContainText(/endpoint: (reachable|unreachable)/);
const text = (await state.textContent()) ?? '';
if (text.includes('unreachable')) {
await expect(note).toBeVisible();
}
});
});