40227-vm/frontend/tests/e2e/content-catalog.seeded.e2e.ts
Dmitri d4a5378adf Refactor: migrate frontend to Vite/React, add product backend modules
Frontend:
- Replace Next.js with Vite + React + TypeScript
- Add new component architecture (app-shell, sidebar, dashboard modules)
- Implement product modules: FRAME, safety protocols, walkthrough checkin,
  campus/staff attendance, personality quiz, sign language, classroom timer
- Add shadcn/ui component library with Tailwind CSS
- Remove legacy generated components, stores, and pages

Backend:
- Add product migrations: frame_entries, user_progress, safety_quiz_results,
  walkthrough_checkins, communication_events, personality_quiz_results,
  campus_attendance_config/summaries, staff_attendance_records, content_catalog
- Add corresponding models, services, and routes
- Implement cookie-based auth with refresh token rotation
- Add content catalog seeder with product content
- Migrate to ESLint flat config
- Switch from yarn to npm

Infrastructure:
- Update .gitignore for new tooling
- Add project documentation (CLAUDE.md, docs/)
- Remove deprecated config files and yarn.lock

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-09 15:18:23 +02:00

174 lines
6.6 KiB
TypeScript

import { expect, type APIRequestContext, test } from '@playwright/test';
const BACKEND_API_URL = process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api';
interface ClassroomStrategyPayload {
readonly title: string;
}
interface SignLanguagePayload {
readonly word: string;
}
interface ZonePayload {
readonly name: string;
}
interface TimerSoundPayload {
readonly name: string;
}
interface SeededContentTypeExpectation {
readonly contentType: string;
readonly payloadShape: 'array' | 'object';
}
const MINIMUM_SEEDED_CONTENT_TYPES: readonly SeededContentTypeExpectation[] = [
{ contentType: 'classroom-timer-backgrounds', payloadShape: 'array' },
{ contentType: 'classroom-timer-sounds', payloadShape: 'array' },
{ contentType: 'classroom-timer-presets', payloadShape: 'array' },
{ contentType: 'classroom-timer-tips', payloadShape: 'array' },
{ contentType: 'classroom-strategies', payloadShape: 'array' },
{ contentType: 'sign-language-items', payloadShape: 'array' },
{ contentType: 'sign-language-page-content', payloadShape: 'object' },
{ contentType: 'regulation-zones', payloadShape: 'array' },
{ contentType: 'zones-of-regulation-page-content', payloadShape: 'object' },
{ contentType: 'dashboard-encouraging-quotes', payloadShape: 'array' },
{ contentType: 'dashboard-compliance-items', payloadShape: 'array' },
{ contentType: 'dashboard-sign-of-week', payloadShape: 'object' },
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function getStringField(value: unknown, fieldName: string): string {
if (!isRecord(value)) {
throw new Error(`Payload item must be an object with ${fieldName}`);
}
const fieldValue = value[fieldName];
if (typeof fieldValue !== 'string') {
throw new Error(`Payload item ${fieldName} must be a string`);
}
return fieldValue;
}
function parseNamedItems(payload: unknown, fieldName: string): readonly { readonly value: string }[] {
if (!Array.isArray(payload)) {
throw new Error('Content catalog payload must be an array');
}
return payload.map((item) => ({
value: getStringField(item, fieldName),
}));
}
async function getSeededPayload(
request: APIRequestContext,
contentType: string,
): Promise<unknown> {
const response = await request.get(
`${BACKEND_API_URL}/public/content-catalog/${encodeURIComponent(contentType)}`,
);
expect(
response.ok(),
`Seeded backend content catalog request failed for ${contentType}. Run backend migrations, seeders, and backend server before this suite.`,
).toBe(true);
const catalog: unknown = await response.json();
if (!isRecord(catalog)) {
throw new Error(`Content catalog response for ${contentType} must be an object`);
}
return catalog.payload;
}
function getFirstPayloadItem<TItem>(payload: readonly TItem[], contentType: string): TItem {
expect(payload.length, `${contentType} seed payload must contain at least one item`).toBeGreaterThan(0);
return payload[0];
}
function expectSeededPayloadShape(payload: unknown, expectation: SeededContentTypeExpectation) {
if (expectation.payloadShape === 'array') {
if (!Array.isArray(payload)) {
throw new Error(`${expectation.contentType} payload must be an array`);
}
expect(payload.length, `${expectation.contentType} payload must contain at least one item`).toBeGreaterThan(0);
return;
}
if (!isRecord(payload)) {
throw new Error(`${expectation.contentType} payload must be an object`);
}
}
test.describe('seeded content catalog integration', () => {
test('minimum backend content seed set is available through public API', async ({ request }) => {
for (const expectation of MINIMUM_SEEDED_CONTENT_TYPES) {
const payload = await getSeededPayload(request, expectation.contentType);
expectSeededPayloadShape(payload, expectation);
}
});
test('renders seeded classroom timer catalog content', async ({ page, request }) => {
const payload = await getSeededPayload(request, 'classroom-timer-sounds');
const sounds: readonly TimerSoundPayload[] = parseNamedItems(payload, 'name').map((item) => ({
name: item.value,
}));
const firstSound = getFirstPayloadItem<TimerSoundPayload>(sounds, 'classroom-timer-sounds');
await page.goto('/classroom-timer');
await expect(page.getByText('Loading classroom timer content...')).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Classroom Timer' })).toBeVisible();
await expect(page.getByText(firstSound.name, { exact: true })).toBeVisible();
});
test('renders seeded classroom support strategies', async ({ page, request }) => {
const payload = await getSeededPayload(request, 'classroom-strategies');
const strategies: readonly ClassroomStrategyPayload[] = parseNamedItems(payload, 'title').map((item) => ({
title: item.value,
}));
const firstStrategy = getFirstPayloadItem<ClassroomStrategyPayload>(strategies, 'classroom-strategies');
await page.goto('/classroom-support');
await expect(page.getByText('Loading classroom strategies...')).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Classroom Support' })).toBeVisible();
await expect(page.getByText(firstStrategy.title, { exact: true })).toBeVisible();
});
test('renders seeded sign language content', async ({ page, request }) => {
const payload = await getSeededPayload(request, 'sign-language-items');
const signs: readonly SignLanguagePayload[] = parseNamedItems(payload, 'word').map((item) => ({
word: item.value,
}));
const firstSign = getFirstPayloadItem<SignLanguagePayload>(signs, 'sign-language-items');
await page.goto('/sign-language');
await expect(page.getByText('Loading sign language content and saved progress.')).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Sign Language' })).toBeVisible();
await expect(page.getByText(firstSign.word, { exact: true })).toBeVisible();
});
test('renders seeded zones of regulation content', async ({ page, request }) => {
const payload = await getSeededPayload(request, 'regulation-zones');
const zones: readonly ZonePayload[] = parseNamedItems(payload, 'name').map((item) => ({
name: item.value,
}));
const firstZone = getFirstPayloadItem<ZonePayload>(zones, 'regulation-zones');
await page.goto('/zones-of-regulation');
await expect(page.getByText('Loading regulation zone content.')).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Zones of Regulation' })).toBeVisible();
await expect(page.getByText(firstZone.name, { exact: true })).toBeVisible();
});
});