Files
breakpilot-lehrer/studio-v2/e2e/eltern.spec.ts
T
Benjamin Admin d9858084dd
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 26s
Phase 9c: Parent accounts, magic-link login + parent timetable view
Backend (school-service):
  - parent_account, parent_child, parent_magic_link, parent_session
    tables. Tokens are sha256-hashed in DB; raw goes back exactly
    once to the inviting teacher.
  - InviteParent upserts the parent account, links a child to a tt_
    class, mints a 7-day magic link. Returns the link path so the
    teacher can paste it into Matrix/Email.
  - RedeemMagicLink validates + marks used + mints a 30-day session,
    sets HttpOnly bp_parent_session cookie.
  - ParentSessionMiddleware reads the cookie and resolves the parent.
    Lives in its own router group /api/v1/parent — totally separate
    from the teacher JWT path.
  - ParentMe returns the account + list of children (with class name).
  - ParentTimetable returns the latest completed tt_solution's lessons
    for the requested child's class, with full authorization check
    (parent must own a child in that class).

Frontend (studio-v2):
  - lib/calendar/subject-i18n.ts maps 22 German subject names to 8
    parent locales (de/en/tr/ar/uk/ru/pl/fr). Falls back to German
    for custom subjects.
  - ParentManager component on the Schulkalender page lets the teacher
    invite parents via email + child name + class + language. Newly
    minted magic-link is shown with a copy-to-clipboard button.
  - app/api/parent/[...path]/route.ts proxies parent-side endpoints
    via the cookie so HttpOnly survives the Next.js round-trip.
  - /eltern/login?token=… redeems and redirects to /eltern.
  - /eltern shows a Wochengrid with German days + translated subject
    names in the parent's preferred language. Headings and weekday
    labels also localised (de/en/tr/ar/uk/ru/pl/fr).

Tests:
  - 3 new Go unit tests (random token, hash stability, invite-request
    validator). 83 subtests gesamt.
  - studio-v2: e2e/eltern.spec.ts mit 7 tests across ParentManager,
    /eltern/login, /eltern overview, subject-i18n end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:50:35 +02:00

135 lines
6.8 KiB
TypeScript

import { test, expect, Page } from '@playwright/test'
/**
* E2E for the Phase 9c parent-side: ParentManager on /schulkalender (teacher
* UI) and the /eltern login + timetable view. Backend calls are intercepted
* so the suite doesn't need a real teacher → parent invitation cycle.
*/
async function mockTeacherCalendar(page: Page, opts: { classes?: unknown[]; parents?: unknown[]; invite?: unknown } = {}) {
// Existing schulkalender mocks the page already needs.
await page.route('**/api/school/calendar/config', async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user_id: 'dev', bundesland: 'DE-NI' }) })
})
await page.route(/\/api\/school\/calendar\/holidays(\?.*)?$/, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.route(/\/api\/school\/calendar\/events(\?.*)?$/, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
// ParentManager loads classes via the stundenplan API.
await page.route('**/api/school/timetable/classes', async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.classes ?? []) }))
await page.route('**/api/school/calendar/parents', async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.parents ?? []) }))
await page.route('**/api/school/calendar/parents/invite', async (route) => {
if (route.request().method() !== 'POST') return route.fulfill({ status: 405 })
return route.fulfill({
status: 201, contentType: 'application/json',
body: JSON.stringify(opts.invite ?? {
parent: { id: 'p1', email: 'mama@example.de', preferred_language: 'tr' },
child: { id: 'c1', parent_id: 'p1', tt_class_id: 'class-1', first_name: 'Max', last_name: 'Mueller' },
magic_token: 'abc123',
magic_url: '/eltern/login?token=abc123',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
}),
})
})
}
test.describe('Schulkalender — ParentManager', () => {
test('renders empty state when no parents invited', async ({ page }) => {
await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
const manager = page.getByTestId('parent-manager')
await expect(manager).toBeVisible()
await expect(manager.getByText('Keine eingeladenen Eltern.')).toBeVisible()
})
test('+ Eltern einladen opens the form when classes exist', async ({ page }) => {
await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('parent-invite-toggle').click()
await expect(page.getByTestId('parent-email')).toBeVisible()
})
test('submitting invite shows the magic link to copy', async ({ page }) => {
await mockTeacherCalendar(page, { classes: [{ id: 'class-1', name: '5a', grade_level: 5, student_count: 24, created_by_user_id: 'u', created_at: '' }] })
await page.goto('/schulkalender')
await page.waitForLoadState('networkidle')
await page.getByTestId('parent-invite-toggle').click()
await page.getByTestId('parent-email').fill('mama@example.de')
await page.getByTestId('parent-child-first').fill('Max')
await page.getByTestId('parent-child-last').fill('Mueller')
await page.getByTestId('parent-class').selectOption('class-1')
await page.getByTestId('parent-invite-submit').click()
await expect(page.getByTestId('parent-invite-link')).toBeVisible()
await expect(page.getByText('Einladungs-Link fuer mama@example.de')).toBeVisible()
})
})
async function mockParentApi(page: Page, opts: { redeemOk?: boolean; me?: unknown; lessons?: unknown[] } = {}) {
const redeemOk = opts.redeemOk ?? true
await page.route('**/api/parent/auth/redeem', async (route) => {
if (!redeemOk) return route.fulfill({ status: 401, contentType: 'application/json', body: '{"error":"invalid"}' })
return route.fulfill({
status: 200, contentType: 'application/json',
headers: { 'set-cookie': 'bp_parent_session=test; Path=/; HttpOnly' },
body: JSON.stringify({ id: 'p1', email: 'mama@example.de', preferred_language: 'tr' }),
})
})
await page.route('**/api/parent/me', async (route) => {
return route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify(opts.me ?? {
parent: { id: 'p1', email: 'mama@example.de', preferred_language: 'tr' },
children: [{ id: 'c1', parent_id: 'p1', tt_class_id: 'class-1', first_name: 'Max', last_name: 'Mueller', class_name: '5a' }],
}),
})
})
await page.route(/\/api\/parent\/me\/timetable(\?.*)?$/, async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.lessons ?? []) }))
await page.route('**/api/parent/auth/logout', async (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' }))
}
test.describe('Eltern — Login + Wochengrid', () => {
test('login page shows error when no token in URL', async ({ page }) => {
await mockParentApi(page)
await page.goto('/eltern/login')
await expect(page.getByTestId('eltern-login')).toBeVisible()
await expect(page.getByText('Kein Token in der URL')).toBeVisible()
})
test('valid token redirects to the parent overview', async ({ page }) => {
await mockParentApi(page, {})
await page.goto('/eltern/login?token=abc123')
await page.waitForURL('**/eltern', { timeout: 3000 })
await expect(page.getByTestId('eltern-page')).toBeVisible()
})
test('shows greeting and child class on /eltern', async ({ page }) => {
await mockParentApi(page)
await page.goto('/eltern/login?token=abc123')
await page.waitForURL('**/eltern')
// Turkish greeting because preferred_language=tr.
await expect(page.getByText('Hoş geldiniz, mama@example.de')).toBeVisible()
await expect(page.getByText('Max Mueller · 5a')).toBeVisible()
})
test('translates subject names into the parent language', async ({ page }) => {
await mockParentApi(page, {
lessons: [
{ DayOfWeek: 1, PeriodIndex: 1, StartTime: '08:00', EndTime: '08:45', ClassName: '5a', SubjectName: 'Mathematik', SubjectCode: 'M', TeacherName: 'Schmidt, Anna', RoomName: 'A101', Pinned: false },
],
})
await page.goto('/eltern/login?token=abc123')
await page.waitForURL('**/eltern')
// Turkish target = Matematik.
await expect(page.getByTestId('eltern-cell-1-1').getByText('Matematik')).toBeVisible()
})
})