Files
SlabUploader/frontend/tests/e2e/settings-species-pricing.spec.ts
Ty 18c262b5b4 SETTINGS-35: species $/bdft in Settings + taxonomy parent fix (#76)
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.
2026-09-20 12:37:52 -07:00

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 } }]);
});
});