Files
SlabUploader/frontend/tests/e2e/m3-create.spec.ts
T
Ty 693719e85f fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review
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
2026-09-20 23:36:31 -07:00

358 lines
14 KiB
TypeScript

import { test, expect, type Page, type Route } from '@playwright/test';
import { seedMeasureDraft } from './helpers/draft';
/**
* M3 Review→Publish E2E (issue #77, QUALITY-50/105): drives the one-column
* create→review→publish flow through the UI against a mocked API
* (`page.route` on the whole /api/v1 namespace; no live backend under e2e).
*
* The capture→measure path needs real camera/canvas/Vision workers, so the
* deterministic driver is the Resume path: seed an in-progress measure draft
* (helpers/draft.ts) → Resume → Continue → mocked createSlab/uploadPhoto →
* Review → Publish. All ids are synthetic fixtures (QUALITY-105 / METADATA-5).
*/
// --- Fixture taxonomy: fixture ids only, parented like the live Woo tree ---
const SLAB_ID = '5f3c9a2e-0000-4000-8000-0000000000aa';
const SPECIES_GROUper_ID = 10; // "Species" grouper (parent_id null)
const SPECIES_ID = 11; // Cedar (parent = species grouper)
const WCAT_ID = 20; // "Wood category" grouper (parent null)
const LIVEEDGE_ID = 21; // Live edge (parent = wcat)
const BOWL_ID = 22; // Bowl blanks (parent = wcat)
const FIG_TAG_ID = 101; // fig-burl product tag
const FEAT_TAG_ID = 201; // feat-mineral-streaks product tag
const TAXONOMY = {
categories: [
{ id: SPECIES_GROUper_ID, name: 'Species', slug: 'species', parent_id: null },
{ id: SPECIES_ID, name: 'Cedar', slug: 'cedar', parent_id: SPECIES_GROUper_ID },
{ id: 12, name: 'Maple', slug: 'maple', parent_id: SPECIES_GROUper_ID },
{ id: WCAT_ID, name: 'Wood category', slug: 'wcat', parent_id: null },
{ id: LIVEEDGE_ID, name: 'Live edge', slug: 'liveedge', parent_id: WCAT_ID },
{ id: BOWL_ID, name: 'Bowl blanks', slug: 'bowlblanks', parent_id: WCAT_ID },
{ id: 99, name: 'Chairs', slug: 'chairs', parent_id: null },
],
attributes: [
{
id: 30, name: 'Edge Type', slug: 'edge_type',
terms: [
{ id: 31, name: 'Live edge', slug: 'live-edge' },
{ id: 32, name: 'Square edge', slug: 'square-edge' },
],
},
{
id: 33, name: 'Figure', slug: 'figure',
terms: [
{ id: 34, name: 'Burl', slug: 'burl' },
{ id: 35, name: 'Birdseye', slug: 'birdseye' },
],
},
{
id: 36, name: 'Grade', slug: 'grade',
terms: [{ id: 37, name: 'A grade', slug: 'a-grade' }],
},
{
id: 38, name: 'Thickness', slug: 'thickness',
terms: [{ id: 39, name: '2-4 in', slug: '2-4' }],
},
{
id: 40, name: 'Moisture', slug: 'moisture',
terms: [{ id: 41, name: 'Kiln dried', slug: 'kiln-dried' }],
},
],
tags: [
{ id: 100, name: 'fig', slug: 'fig' },
{ id: FIG_TAG_ID, name: 'fig-burl', slug: 'fig-burl' },
{ id: 102, name: 'fig-birdseye', slug: 'fig-birdseye' },
{ id: FEAT_TAG_ID, name: 'feat-mineral-streaks', slug: 'feat-mineral-streaks' },
{ id: 202, name: 'feat-knots', slug: 'feat-knots' },
],
taxonomy_anchor: 'fixture',
taxonomy_last_sync_at: '2026-01-01T00:00:00',
};
function settingsView(over: Record<string, unknown> = {}) {
return {
woo_base_url: 'https://example.invalid',
woo_wp_username: 'wpuser',
woo_app_password: { configured: true },
inference_base_url: { configured: false },
inference_api_key: { configured: false },
inference_model: null,
inference_enabled: false,
content_llm_enabled: false,
brand_voice: null,
geo_context: null,
woo_create_status: 'draft',
taxonomy_last_sync_at: '2026-01-01T00:00:00',
taxonomy_anchor: 'fixture',
...over,
};
}
function baseSlab(): Record<string, unknown> {
return {
id: SLAB_ID,
sku: '',
status: 'draft',
price: null,
price_source: 'recommendation',
length_in: 48,
thickness_in: 2,
sqft: 6.25,
bdft: 12.5,
width_min_in: 10,
width_max_in: 12,
width_avg_in: 11,
species_id: null,
wood_category_ids: [],
edge_type_term_id: null,
figure_term_ids: [],
grade_term_id: null,
thickness_term_id: null,
moisture_term_id: null,
fig_tag_ids: [],
feat_tag_ids: [],
title: null,
short_description: null,
description: null,
content_source: 'manual',
metadata_locked: false,
species_confidence: null,
figure_confidence: null,
grade_confidence: null,
inference_status: null,
calibration_photo_id: null,
woo_product_id: null,
server_rev: 0,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
}
async function json(route: Route, body: unknown, status = 200) {
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
}
interface ApiHooks {
settings?: Record<string, unknown>;
onPublish?: (route: Route) => Promise<void>;
onCall1?: (route: Route) => Promise<void>;
onCall2?: (route: Route) => Promise<void>;
/** Counts inference endpoints hit (default: record to an array via closure). */
calls?: { call1: number; call2: number };
}
/** Mock the full API for the flow. Slab PUTs are folded into a live row; the
* review endpoint recomputes `ready` from the folded row and echoes the primary. */
async function mockApi(page: Page, hooks: ApiHooks = {}) {
const calls = hooks.calls ?? { call1: 0, call2: 0 };
const slab = baseSlab();
let primary = null as number | null;
const merge = (payload: Record<string, unknown>) => {
// Respect optimistic concurrency: reject stale client_rev (like the real backend).
if (typeof payload.client_rev === 'number' && payload.client_rev !== slab.server_rev) {
return false; // stale — do NOT merge
}
for (const k of [
'sku', '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',
'title', 'short_description', 'description', 'content_source', 'price', 'price_source',
'metadata_locked',
]) {
if (k in payload) (slab as Record<string, unknown>)[k] = payload[k];
}
if (typeof payload.client_rev === 'number') slab.server_rev = payload.client_rev + 1;
};
// #89: deterministic copy is built only on an explicit lock. Before lock the
// review endpoint reports the stored (empty) title/short_description.
const buildCopy = () => {
const speciesName = TAXONOMY.categories.find((c) => c.id === slab.species_id)?.name ?? 'Slab';
const fig = Array.isArray(slab.figure_term_ids) && slab.figure_term_ids.length
? 'Figured'
: '';
return `${fig} ${speciesName} Live Edge Slab`;
};
const reviewResult = (payload: Record<string, unknown>) => {
if (typeof payload.primary_wood_category_id === 'number') primary = payload.primary_wood_category_id;
if (typeof payload.price === 'number') slab.price = payload.price;
if (typeof payload.sku === 'string' && payload.sku) slab.sku = payload.sku;
// Explicit lock (#89): persist the deterministic copy + lock flag.
if (payload.action === 'lock') {
const title = (typeof payload.title === 'string' && payload.title)
? (payload.title as string)
: buildCopy();
(slab as Record<string, unknown>).title = title;
(slab as Record<string, unknown>).short_description = `${title}, 4ft 0in x 11in x 2in, 12.5 bdft`;
(slab as Record<string, unknown>).metadata_locked = true;
}
const locked = (slab.metadata_locked as boolean) === true;
const missing: string[] = [];
if (!slab.sku) missing.push('sku');
if (!slab.species_id) missing.push('species');
if (!Array.isArray(slab.wood_category_ids) || slab.wood_category_ids.length === 0) missing.push('wood categories');
if (!slab.edge_type_term_id) missing.push('edge type');
if (!slab.title) missing.push('title');
if (!slab.short_description) missing.push('short description');
if (slab.price == null) missing.push('price');
return {
ready: missing.length === 0,
missing,
title: (slab.title as string) ?? null,
short_description: (slab.short_description as string) ?? null,
price: (slab.price as number) ?? null,
price_suggestion: 120,
primary_wood_category_id: primary,
locked,
};
};
await page.route('**/api/v1/**', async (route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname;
const method = req.method();
const payload = (() => {
try { return req.postDataJSON() as Record<string, unknown>; } catch { return {}; }
})();
if (path === '/api/v1/settings' && method === 'GET') {
await json(route, settingsView(hooks.settings));
return;
}
if (path === '/api/v1/admin/taxonomy' && method === 'GET') {
await json(route, TAXONOMY);
return;
}
if (path === '/api/v1/slabs' && method === 'POST') {
// The client generates the slab id (crypto.randomUUID, APP-20); adopt it so
// the subsequent photo/review/publish paths (keyed by this id) resolve.
slab.id = String(payload.id ?? SLAB_ID);
merge(payload); // created with capture math; client fields empty
await json(route, slab, 201);
return;
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'PUT') {
if (merge(payload) === false) {
await json(route, { error: { code: 'revision_conflict', message: 'client_rev mismatch', detail: { current_server_rev: slab.server_rev } } }, 409);
return;
}
await json(route, slab);
return;
}
if (path === `/api/v1/slabs/${slab.id}` && method === 'GET') {
await json(route, slab);
return;
}
if (path === `/api/v1/slabs/${slab.id}/photos` && method === 'POST') {
// Photo uploads are multipart; `payload` is {} — the client's photo_id is
// in the form body, not JSON, so echo a stable synthetic id.
await json(route, {
id: 'photo-1',
slab_id: slab.id,
seq: 1,
is_calibration: 1,
original_retained: false,
processed_retained: true,
}, 201);
return;
}
if (path === `/api/v1/slabs/${slab.id}/review` && method === 'POST') {
await json(route, reviewResult(payload));
return;
}
if (path === `/api/v1/slabs/${slab.id}/publish` && method === 'POST') {
if (hooks.onPublish) { await hooks.onPublish(route); return; }
await json(route, { ok: true, status: 'draft', woo_product_id: 4242, draft: true });
return;
}
if (path === `/api/v1/slabs/${slab.id}/inference/call1` && method === 'POST') {
calls.call1 += 1;
if (hooks.onCall1) { await hooks.onCall1(route); return; }
await json(route, { ok: false, suggestions: {} });
return;
}
if (path === `/api/v1/slabs/${slab.id}/inference/call2` && method === 'POST') {
calls.call2 += 1;
if (hooks.onCall2) { await hooks.onCall2(route); return; }
await json(route, { ok: false, description: '' });
return;
}
await json(route, { error: { code: 'not_found', message: path } }, 404);
});
return { calls, slab };
}
// Seed a measure draft (L=48in, T=2in, 6.25 sqft, 12.5 bdft, 11in avg width) and
// drive Resume → Continue to land in Review.
async function reachReview(page: Page) {
// Phase 1: boot with ?noload so the app mounts (real origin for IndexedDB)
// WITHOUT probing for a draft. Then seed the measure draft into IDB.
await page.goto('/?noload');
await seedMeasureDraft(page, {
sku: '', lengthIn: 48, thicknessIn: 2, sqft: 6.25, bdft: 12.5, widthIn: 11,
});
// Phase 2: reload WITHOUT ?noload so onMount probes IDB and shows Resume.
await page.goto('/');
// Resume prompt → Continue (restores measure). The mask re-derivation runs in
// a worker; wait for the Photos-ready Continue control.
await expect(page.getByRole('heading', { name: 'Resume?' })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByTestId('continue-to-review')).toBeVisible({ timeout: 20000 });
// Continue → mocked createSlab + uploadPhoto → Review screen.
await page.getByTestId('continue-to-review').click();
await expect(page.getByTestId('review-screen')).toBeVisible({ timeout: 20000 });
}
/** Fill every ready-gating field to make the slab publishable. */
async function completeForReady(page: Page) {
await page.getByTestId('species').selectOption(String(SPECIES_ID));
// Wood categories: open, select Live edge, Done.
await page.getByTestId('wood-cat-btn').click();
await page.getByTestId(`wood-cat-opt-${LIVEEDGE_ID}`).click();
await page.getByTestId('wood-cat-done').click();
await page.getByTestId('edge-type').selectOption('31');
await page.getByTestId('price').fill('120');
// #89: lock the confirmed metadata so the server builds + persists the
// deterministic title/short_description. Do this BEFORE the SKU blur so a
// subsequent plain review can't clobber the locked-note banner.
await expect(page.getByTestId('lock-metadata')).toBeEnabled();
await page.getByTestId('lock-metadata').click();
// Wait for the lock to complete (title field populated = lock persisted copy).
await expect(page.getByTestId('title')).not.toHaveValue('');
await page.getByTestId('sku').fill('SLAB-0001');
// SKU commits on blur — press Tab to move focus off it (deterministic, single
// commit; a body click can re-focus the SKU and re-fire blur).
await page.getByTestId('sku').press('Tab');
await expect(page.getByTestId('ready-status')).toContainText('Ready to publish', { timeout: 15000 });
}
test.describe('M3 Review→Publish flow (#77)', () => {
test('golden path: capture→review→lock→ready→publish→done (#77, #89)', async ({ page }) => {
const { slab } = await mockApi(page);
await reachReview(page);
// Not ready until fields are set; copy is empty until an explicit lock.
await expect(page.getByTestId('ready-status')).toContainText(/Not ready|Ready/);
await expect(page.getByTestId('title')).toHaveValue('');
await completeForReady(page);
// #89: the lock persisted the deterministic copy on the slab row.
expect(slab.metadata_locked).toBe(true);
expect(slab.title).toBeTruthy();
expect(slab.short_description).toBeTruthy();
// Continue to Publish is enabled and hands off.
await expect(page.getByTestId('continue-publish')).toBeEnabled();
await page.getByTestId('continue-publish').click();
await expect(page.getByTestId('publish-screen')).toBeVisible();
// Publish → success → Done panel (draft in UAT store).
await page.getByTestId('publish-btn').click();
await expect(page.getByText('Published')).toBeVisible({ timeout: 15000 });
await expect(page.getByText(/Woo product #4242/)).toBeVisible();
await expect(page.getByTestId('new-slab')).toBeVisible();
});
});