191 lines
7.2 KiB
TypeScript
191 lines
7.2 KiB
TypeScript
import { expect, type APIRequestContext, type Page, test } from '@playwright/test';
|
|
|
|
const BACKEND_API_URL = process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api';
|
|
|
|
const TEST_USER = {
|
|
email: 'admin@flatlogic.com',
|
|
password: 'flatlogicAdmin123!',
|
|
};
|
|
|
|
async function authenticateViaPage(page: Page): Promise<void> {
|
|
await page.goto('/');
|
|
await page.getByPlaceholder('you@school.edu').fill(TEST_USER.email);
|
|
await page.getByPlaceholder('Enter your password').fill(TEST_USER.password);
|
|
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
|
|
await page.waitForURL('/', { timeout: 10000 });
|
|
}
|
|
|
|
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 authenticateViaPage(page);
|
|
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 authenticateViaPage(page);
|
|
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 authenticateViaPage(page);
|
|
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 authenticateViaPage(page);
|
|
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();
|
|
});
|
|
});
|