18c262b5b4
Add Settings species pricing UI (children of Species grouper by slug, never hardcoded ids). Map Woo REST parent→parent_id on taxonomy sync; upsert categories so slab FKs survive re-sync. Tests: parent map, FK-safe sync, TV-8 0.96×12.50→12.00, Playwright mock e2e. UAT verified on 5060.
211 lines
7.4 KiB
TypeScript
211 lines
7.4 KiB
TypeScript
import { test, expect, type Page, type Route } from '@playwright/test';
|
|
|
|
/**
|
|
* SETTINGS-35 UI contract against vite preview (no /api proxy).
|
|
* Taxonomy/pricing are Playwright mocks with fixture ids only — never live UAT
|
|
* store ids (QUALITY-105 / METADATA-5).
|
|
*/
|
|
|
|
const SETTINGS = {
|
|
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',
|
|
};
|
|
|
|
/** Synthetic parented tree. Fixture ids 10/11/12/20/21/99 — not production. */
|
|
const PARENTED_TAXONOMY = {
|
|
categories: [
|
|
{ id: 10, name: 'Species', slug: 'species', parent_id: null },
|
|
{ id: 11, name: 'Cedar', slug: 'cedar', parent_id: 10 },
|
|
{ id: 12, name: 'Maple', slug: 'maple', parent_id: 10 },
|
|
{ id: 20, name: 'Wood category', slug: 'wcat', parent_id: null },
|
|
{ id: 21, name: 'Live edge wood', slug: 'liveedge', parent_id: 20 },
|
|
{ id: 99, name: 'Chairs', slug: 'chairs', parent_id: null },
|
|
],
|
|
attributes: [],
|
|
tags: [],
|
|
taxonomy_anchor: 'fixture',
|
|
taxonomy_last_sync_at: '2026-01-01T00:00:00',
|
|
};
|
|
|
|
async function json(route: Route, body: unknown, status = 200) {
|
|
await route.fulfill({
|
|
status,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
type ApiOpts = {
|
|
taxonomy?: unknown;
|
|
pricingGet?: () => unknown;
|
|
onPricingPost?: (route: Route, payload: Record<string, unknown>) => Promise<void>;
|
|
onPricingPut?: (route: Route, id: string, payload: Record<string, unknown>) => Promise<void>;
|
|
};
|
|
|
|
async function mockApi(page: Page, opts: ApiOpts) {
|
|
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, SETTINGS);
|
|
return;
|
|
}
|
|
if (path === '/api/v1/admin/taxonomy' && method === 'GET') {
|
|
await json(route, opts.taxonomy ?? { categories: [] });
|
|
return;
|
|
}
|
|
const putMatch = path.match(/^\/api\/v1\/admin\/pricing\/(\d+)$/);
|
|
if (putMatch && method === 'PUT') {
|
|
if (opts.onPricingPut) {
|
|
await opts.onPricingPut(route, putMatch[1], payload);
|
|
return;
|
|
}
|
|
await json(route, { error: { code: 'not_found', message: 'rule not found' } }, 404);
|
|
return;
|
|
}
|
|
if (path === '/api/v1/admin/pricing' && method === 'GET') {
|
|
const list = opts.pricingGet ? opts.pricingGet() : [];
|
|
await json(route, list);
|
|
return;
|
|
}
|
|
if (path === '/api/v1/admin/pricing' && method === 'POST') {
|
|
if (opts.onPricingPost) {
|
|
await opts.onPricingPost(route, payload);
|
|
return;
|
|
}
|
|
await json(
|
|
route,
|
|
{
|
|
id: 601,
|
|
species_id: payload.species_id,
|
|
price_per_bdft: payload.price_per_bdft,
|
|
},
|
|
201
|
|
);
|
|
return;
|
|
}
|
|
await json(route, { error: { code: 'not_found', message: path } }, 404);
|
|
});
|
|
}
|
|
|
|
async function openSettings(page: Page) {
|
|
await page.goto('/');
|
|
await page.getByRole('button', { name: 'Settings (dev)' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
|
|
await expect(page.getByTestId('species-pricing')).toBeVisible();
|
|
}
|
|
|
|
test.describe('Settings species pricing (SETTINGS-35)', () => {
|
|
test('empty taxonomy: prompt only, no seeded species rows', async ({ page }) => {
|
|
await mockApi(page, { taxonomy: { categories: [] } });
|
|
await openSettings(page);
|
|
const empty = page.getByTestId('species-pricing-empty');
|
|
await expect(empty).toBeVisible();
|
|
await expect(empty).toContainText(/Test WooCommerce/i);
|
|
await expect(page.locator('.rate-row')).toHaveCount(0);
|
|
await expect(page.getByTestId('save-rates')).toHaveCount(0);
|
|
await expect(page.getByText('Cedar', { exact: true })).toHaveCount(0);
|
|
await expect(page.getByText('Maple', { exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
test('lists only Species-grouper children (not wood cats / parents / furniture)', async ({
|
|
page,
|
|
}) => {
|
|
await mockApi(page, {
|
|
taxonomy: PARENTED_TAXONOMY,
|
|
pricingGet: () => [{ id: 501, species_id: 11, price_per_bdft: 8.25 }],
|
|
});
|
|
await openSettings(page);
|
|
await expect(page.locator('.rate-row')).toHaveCount(2);
|
|
await expect(page.locator('[data-species-slug="cedar"]')).toBeVisible();
|
|
await expect(page.locator('[data-species-slug="maple"]')).toBeVisible();
|
|
await expect(page.getByLabel('Price per bdft for Cedar')).toHaveValue('8.25');
|
|
await expect(page.getByLabel('Price per bdft for Maple')).toHaveValue('');
|
|
await expect(page.locator('[data-species-slug="liveedge"]')).toHaveCount(0);
|
|
await expect(page.locator('[data-species-slug="chairs"]')).toHaveCount(0);
|
|
await expect(page.locator('[data-species-slug="species"]')).toHaveCount(0);
|
|
await expect(page.locator('[data-species-slug="wcat"]')).toHaveCount(0);
|
|
await expect(page.getByText('Live edge wood')).toHaveCount(0);
|
|
await expect(page.getByText('Chairs', { exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
test('Save rates POSTs a new rule', async ({ page }) => {
|
|
const posts: Record<string, unknown>[] = [];
|
|
await mockApi(page, {
|
|
taxonomy: PARENTED_TAXONOMY,
|
|
onPricingPost: async (route, payload) => {
|
|
posts.push(payload);
|
|
await json(
|
|
route,
|
|
{ id: 601, species_id: payload.species_id, price_per_bdft: payload.price_per_bdft },
|
|
201
|
|
);
|
|
},
|
|
});
|
|
await openSettings(page);
|
|
await page.getByLabel('Price per bdft for Cedar').fill('12.5');
|
|
await page.getByTestId('save-rates').click();
|
|
await expect(page.getByText('Saved 1 rate')).toBeVisible();
|
|
expect(posts).toEqual([{ species_id: 11, price_per_bdft: 12.5 }]);
|
|
});
|
|
|
|
test('Save rates PUT after POST 409', async ({ page }) => {
|
|
const posts: Record<string, unknown>[] = [];
|
|
const puts: { id: string; payload: Record<string, unknown> }[] = [];
|
|
let sawConflict = false;
|
|
await mockApi(page, {
|
|
taxonomy: PARENTED_TAXONOMY,
|
|
pricingGet: () =>
|
|
sawConflict ? [{ id: 501, species_id: 12, price_per_bdft: 8 }] : [],
|
|
onPricingPost: async (route, payload) => {
|
|
posts.push(payload);
|
|
sawConflict = true;
|
|
await json(
|
|
route,
|
|
{
|
|
error: {
|
|
code: 'duplicate',
|
|
message: 'rule exists for species',
|
|
detail: { species_id: payload.species_id },
|
|
},
|
|
},
|
|
409
|
|
);
|
|
},
|
|
onPricingPut: async (route, id, payload) => {
|
|
puts.push({ id, payload });
|
|
await json(route, {
|
|
id: Number(id),
|
|
species_id: 12,
|
|
price_per_bdft: payload.price_per_bdft,
|
|
});
|
|
},
|
|
});
|
|
await openSettings(page);
|
|
await page.getByLabel('Price per bdft for Maple').fill('9.75');
|
|
await page.getByTestId('save-rates').click();
|
|
await expect(page.getByText('Saved 1 rate')).toBeVisible();
|
|
expect(posts).toEqual([{ species_id: 12, price_per_bdft: 9.75 }]);
|
|
expect(puts).toEqual([{ id: '501', payload: { price_per_bdft: 9.75 } }]);
|
|
});
|
|
});
|