import { expect, type Page, test } from '@playwright/test'; /** * Audio library e2e (Workstream 13). Proves the RBAC + persistence contract: * director/office_manager/teacher upload (manage), all four campus roles * read/play, support_staff is read-only, external roles are locked out. * * Requires the backend running with the database migrated + seeded. */ const USER_PASSWORD = 'flatlogicUser123!'; const BACKEND_API_URL = process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; const AUDIO = `${BACKEND_API_URL}/audio_files`; const TEACHER = 'teacher@flatlogic.com'; const SUPPORT_STAFF = 'support_staff@flatlogic.com'; const DIRECTOR = 'director@flatlogic.com'; const GUARDIAN = 'guardian@flatlogic.com'; const DENIED = [400, 401, 403]; interface AudioRow { readonly id: string; readonly title: string; readonly kind: string; readonly url: string | null; readonly recipe: unknown; } const SAMPLE_RECIPE = { voices: [ { waveform: 'sine', notes: [{ freq: 523.25, startAt: 0, duration: 0.6, gain: 0.3 }] }, ], }; async function logout(page: Page): Promise { await page.request.post(`${BACKEND_API_URL}/auth/signout`); await page.context().clearCookies(); } async function login(page: Page, email: string): Promise { await logout(page); await page.goto('/login'); await page.getByPlaceholder('you@school.edu').fill(email); await page.getByPlaceholder('Enter your password').fill(USER_PASSWORD); await page.getByRole('button', { name: 'Sign In', exact: true }).click(); await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 10_000, }); } async function findAudio(page: Page, title: string): Promise { const res = await page.request.get(AUDIO); expect(res.status()).toBe(200); const body = (await res.json()) as { rows?: AudioRow[] }; return (body.rows ?? []).find((row) => row.title === title); } test.describe('Audio library', () => { test('a teacher uploads an audio file that persists and peers can read', async ({ page, }) => { await login(page, TEACHER); const title = `Calm Bell ${Date.now()}`; const res = await page.request.post(AUDIO, { data: { data: { title, url: '/files/calm-bell.mp3' } }, }); expect(res.ok()).toBe(true); expect(await findAudio(page, title), 'uploader sees it').toBeTruthy(); // A same-campus director can read/play it. await login(page, DIRECTOR); expect(await findAudio(page, title), 'director on the campus sees it').toBeTruthy(); }); test('a teacher generates a recipe sound that persists with kind=recipe', async ({ page, }) => { await login(page, TEACHER); const title = `Generated ${Date.now()}`; const res = await page.request.post(AUDIO, { data: { data: { kind: 'recipe', title, recipe: SAMPLE_RECIPE } }, }); expect(res.ok()).toBe(true); const row = await findAudio(page, title); expect(row, 'creator sees the recipe row').toBeTruthy(); expect(row?.kind).toBe('recipe'); expect(row?.url).toBeNull(); expect(row?.recipe).toBeTruthy(); }); test('the service rejects content that does not match the kind', async ({ page }) => { await login(page, TEACHER); // recipe kind without a recipe payload. const missingRecipe = await page.request.post(AUDIO, { data: { data: { kind: 'recipe', title: 'No recipe' } }, }); expect(DENIED).toContain(missingRecipe.status()); // file kind without a url. const missingUrl = await page.request.post(AUDIO, { data: { data: { kind: 'file', title: 'No url' } }, }); expect(DENIED).toContain(missingUrl.status()); }); test('support_staff can read but cannot upload', async ({ page }) => { await login(page, SUPPORT_STAFF); expect((await page.request.get(AUDIO)).status()).toBe(200); const create = await page.request.post(AUDIO, { data: { data: { title: 'Support should not upload', url: '/files/x.mp3' } }, }); expect(DENIED).toContain(create.status()); }); test('external roles cannot access the audio library', async ({ page }) => { await login(page, GUARDIAN); expect(DENIED).toContain((await page.request.get(AUDIO)).status()); const create = await page.request.post(AUDIO, { data: { data: { title: 'Guardian should not upload', url: '/files/x.mp3' } }, }); expect(DENIED).toContain(create.status()); }); });