Nine sites across five specs do `collection.find(...)` and dereference the result immediately. When the row is missing the test dies with "Cannot read properties of undefined" naming a line of test plumbing, which says nothing about what was expected — and that is exactly how favorites-filter:169 failed without producing a usable signal. The message names what was wanted and how many rows were searched. That distinction carries real diagnostic weight: "0 rows" means the fixture never landed, "37 rows" means it landed and the predicate is wrong, and those are different bugs to chase. It lives in its own module importing nothing, rather than in support/api.ts. That file imports @playwright/test, and vitest.config.ts runs tests/unit with environment: 'node' — putting six lines of pure logic there would drag a browser harness into the unit suite to test them. api.ts re-exports it so specs still reach it through fixtures. Throws rather than returning null, because every caller wants the row: an error at the point of the miss beats a null threaded through three more lines before something unrelated fails. Frontend: 30 unit tests pass, lint unchanged at 2 pre-existing warnings, build clean. Ref #241
118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
import { APIRequestContext, expect } from '@playwright/test';
|
|
|
|
// Re-exported so specs get it from './fixtures' with everything else, rather
|
|
// than reaching into support/ directly — fixtures.ts does `export * from
|
|
// './support/api'`. It lives in its own module because that one imports
|
|
// nothing, which is what lets the Vitest unit suite cover it without loading
|
|
// @playwright/test. See findOrFail.ts.
|
|
export { findOrFail } from './findOrFail';
|
|
|
|
/**
|
|
* Where the app under test is served.
|
|
*
|
|
* Matches playwright.config.ts's baseURL. It exists as a constant because
|
|
* `beforeAll` runs with worker-scoped fixtures only and cannot read the
|
|
* test-scoped `baseURL` option, so a seeding hook has to name the host itself.
|
|
* One spec used to write it inline, which meant changing the port in the config
|
|
* moved every test except that one.
|
|
*/
|
|
export const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173';
|
|
|
|
/**
|
|
* Seeding through the admin API.
|
|
*
|
|
* Every spec that needs stock used to write its own version of this, and the
|
|
* copies had drifted: some checked `res.ok()`, some checked `res.status()`,
|
|
* some checked nothing, and one built its own request context against a
|
|
* hardcoded `http://localhost:5173` rather than the configured baseURL.
|
|
*
|
|
* Seeding through the API rather than the UI on purpose. Creating an item by
|
|
* driving the admin form makes every storefront test depend on the admin form
|
|
* working, so a broken form fails a hundred tests that are not about it.
|
|
*/
|
|
|
|
/** An admin API context for `beforeAll`, where the `adminApi` fixture is out of reach. */
|
|
export async function createAdminContext(
|
|
playwright: { request: { newContext: (o: { baseURL: string }) => Promise<APIRequestContext> } }
|
|
): Promise<APIRequestContext> {
|
|
return playwright.request.newContext({ baseURL: BASE_URL });
|
|
}
|
|
|
|
export interface SeededItem {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
/** Unique enough to survive a parallel run and a database nobody truncated. */
|
|
export function uniqueSuffix(): string {
|
|
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
|
}
|
|
|
|
export function uniqueEmail(prefix = 'pw'): string {
|
|
return `${prefix}-${uniqueSuffix()}@example.com`;
|
|
}
|
|
|
|
interface CreateItemOptions {
|
|
name: string;
|
|
price?: string;
|
|
description?: string;
|
|
categoryId?: number | null;
|
|
tags?: string[];
|
|
/**
|
|
* New items are created pending, and the storefront lists only published
|
|
* ones. Most fixtures stand in for ordinary stock rather than staged drafts,
|
|
* so publishing is the default — a spec that wants a draft asks for one.
|
|
*/
|
|
publish?: boolean;
|
|
}
|
|
|
|
export async function createItem(
|
|
api: APIRequestContext,
|
|
options: CreateItemOptions
|
|
): Promise<SeededItem> {
|
|
const res = await api.post('/api/admin/items', {
|
|
// multipart rather than JSON: the route accepts image uploads, so it reads
|
|
// its fields from a multipart body even when no image is attached.
|
|
multipart: {
|
|
name: options.name,
|
|
description: options.description ?? '',
|
|
price: options.price ?? '50',
|
|
category_id: options.categoryId == null ? '' : String(options.categoryId),
|
|
tags: JSON.stringify(options.tags ?? [])
|
|
}
|
|
});
|
|
expect(res.ok(), `creating item ${options.name}`).toBeTruthy();
|
|
const item = { id: (await res.json()).id as number, name: options.name };
|
|
|
|
if (options.publish ?? true) {
|
|
await publishItem(api, item.id);
|
|
}
|
|
return item;
|
|
}
|
|
|
|
export async function publishItem(api: APIRequestContext, id: number): Promise<void> {
|
|
const res = await api.post(`/api/admin/items/${id}/mark-available`);
|
|
expect(res.ok(), `publishing item ${id}`).toBeTruthy();
|
|
}
|
|
|
|
export async function sellItem(api: APIRequestContext, id: number): Promise<void> {
|
|
const res = await api.post(`/api/admin/items/${id}/mark-sold`);
|
|
expect(res.ok(), `selling item ${id}`).toBeTruthy();
|
|
}
|
|
|
|
export async function createCategory(
|
|
api: APIRequestContext,
|
|
name: string,
|
|
parentId: number | null = null
|
|
): Promise<number> {
|
|
const res = await api.post('/api/admin/categories', { data: { name, parent_id: parentId } });
|
|
expect(res.status(), `creating category ${name}`).toBe(201);
|
|
return (await res.json()).id as number;
|
|
}
|
|
|
|
export async function createTag(api: APIRequestContext, name: string): Promise<number> {
|
|
const res = await api.post('/api/admin/tags', { data: { name } });
|
|
expect(res.ok(), `creating tag ${name}`).toBeTruthy();
|
|
return (await res.json()).id as number;
|
|
}
|