Files
redefined-designs/frontend/tests/e2e/support/api.ts
T
bermudalamb 9b3a03d1bf
Linting / lint (pull_request) Successful in 2m2s
SonarQube Analysis / sonarqube (pull_request) Successful in 17m17s
test(e2e): convert the storefront and filter specs onto page objects (#137)
storefront, storefront-errors, theme, error-boundary and filters.

filters.spec.ts carried the last hand-rolled copies of createCategory, createTag and createItem, and the hardcoded `http://localhost:5173` that meant changing the port in the config would have moved every test except this one. Seeding now goes through support/api, and the host it needs lives in one constant. It has to be a constant rather than the config's baseURL: beforeAll runs with worker-scoped fixtures only and cannot read a test-scoped option, which is why the URL was inlined there in the first place.

New FilterDrawer object. The two rules it encodes are the ones the tests exist to pin down and neither is guessable from a locator: categories are a tree because the filter matches a node and everything filed beneath it, and tags combine with AND rather than OR, so selecting two means "must have both".

The active-filter chips go on StorefrontPage rather than the drawer, because that is where they render — and the scoping matters, since the drawer carries a "Clear all" of its own that an unscoped locator also matches.

StorefrontPage gains the three things the catalogue says instead of listing items. They are named together deliberately: the distinction between "No items yet" and "Couldn't load items" is the point, and several tests assert one is showing while the other is not, because telling a customer the shop is empty when the server is broken hides the outage.

The theme switch and the attribute it writes are both on Header now. The switch is in the header and `data-theme` lands on <body>, so a spec previously had to know about `body` to observe the control it had just clicked.

Verified: 12/12 across the four small specs, 8/8 on filters, tsc clean, lint unchanged at the 30-warning src baseline.

Refs #137
2026-08-23 17:50:59 -05:00

111 lines
4.0 KiB
TypeScript

import { APIRequestContext, expect } from '@playwright/test';
/**
* 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;
}