import { test, expect, Page } from '@playwright/test' /** * E2E tests for /schulkalender. Mocks the /api/school/calendar/* routes * so the wizard, save flow and month grid render deterministically without * the live backend or seed data. */ interface MockOpts { config?: { user_id: string; bundesland: string } | null holidays?: unknown[] events?: unknown[] } async function mockCalendarApi(page: Page, opts: MockOpts = {}) { let config = opts.config ?? null const events = (opts.events ?? []) as Array> await page.route('**/api/school/calendar/config', async (route) => { if (route.request().method() === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(config), }) } if (route.request().method() === 'PUT') { const body = JSON.parse(route.request().postData() || '{}') config = { user_id: 'dev', bundesland: body.bundesland } return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(config), }) } return route.fulfill({ status: 405 }) }) await page.route(/\/api\/school\/calendar\/holidays(\?.*)?$/, async (route) => { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.holidays ?? []), }) }) // Phase 9b: school events + rollover. await page.route(/\/api\/school\/calendar\/events(\?.*)?$/, async (route) => { const method = route.request().method() if (method === 'GET') { return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(events) }) } if (method === 'POST') { const body = JSON.parse(route.request().postData() || '{}') const created = { id: `new-${events.length}`, created_by_user_id: 'dev', affected_class_ids: [], visible_to_parents: true, notify_parents: false, notify_students: false, notification_lead_days: [7, 1], is_school_free: false, ...body, } events.push(created) return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(created) }) } return route.fulfill({ status: 405 }) }) await page.route(/\/api\/school\/calendar\/events\/[^/]+$/, async (route) => { if (route.request().method() === 'DELETE') { return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' }) } return route.fulfill({ status: 405 }) }) await page.route(/\/api\/school\/calendar\/school-year-rollover$/, async (route) => { if (route.request().method() !== 'POST') return route.fulfill({ status: 405 }) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ classes_promoted: 8, classes_graduated: 2, new_year_start: '2026-08-01', new_year_end: '2027-07-31', }), }) }) } test.describe('Schulkalender — Bundesland Wizard', () => { test('wizard renders when no config exists', async ({ page }) => { await mockCalendarApi(page, { config: null }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await expect(page.getByTestId('bundesland-wizard')).toBeVisible() await expect(page.getByText('Willkommen im Schulkalender')).toBeVisible() }) test('saving a Bundesland switches to MonthView', async ({ page }) => { await mockCalendarApi(page, { config: null }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await page.getByTestId('bundesland-select').selectOption('DE-NI') await page.getByTestId('bundesland-save').click() await expect(page.getByTestId('month-view')).toBeVisible() await expect(page.getByText('Niedersachsen')).toBeVisible() }) }) test.describe('Schulkalender — Month View', () => { test('shows MonthView when config is set', async ({ page }) => { await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, holidays: [], }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await expect(page.getByTestId('month-view')).toBeVisible() // Weekday header line. for (const w of ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']) { await expect(page.getByText(w, { exact: true }).first()).toBeVisible() } }) test('colours holidays in the grid', async ({ page }) => { // Fix today by mocking config with a deterministic month/year via prev/next. await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, holidays: [ { id: 'h1', region: 'DE-NI', event_type: 'public_holiday', name_de: 'Test-Feiertag', start_date: '2099-06-15', end_date: '2099-06-15' }, { id: 'h2', region: 'DE-NI', event_type: 'school_holiday', name_de: 'Test-Ferien', start_date: '2099-06-20', end_date: '2099-06-21' }, ], }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') // Assert the legend rows — using exact text to avoid colliding with // tooltips like "Tag der deutschen Einheit" that also contain 'tag'. await expect(page.getByText('Feiertag', { exact: true })).toBeVisible() await expect(page.getByText('Schulferien', { exact: true })).toBeVisible() }) test('Heute button resets to current month', async ({ page }) => { await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, holidays: [], }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await page.getByTestId('month-prev').click() await page.getByTestId('month-prev').click() await page.getByTestId('month-today').click() // After clicking Heute, the current month name must appear in the heading. const months = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] const currentMonth = months[new Date().getMonth()] await expect(page.getByRole('heading', { name: new RegExp(currentMonth) })).toBeVisible() }) }) test.describe('Schulkalender — Sidebar entry', () => { test('sidebar contains Schulkalender link', async ({ page }) => { await mockCalendarApi(page) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') const sidebar = page.locator('aside').first() await expect(sidebar.getByText(/Schulkalender|School Calendar/).first()).toBeVisible() }) }) // ========================================================================== // Phase 9b — Schul-Events + Schuljahres-Rollover // ========================================================================== test.describe('Schulkalender — School Event CRUD', () => { test('+ Termin button opens the event modal', async ({ page }) => { await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, events: [] }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await page.getByTestId('add-event').click() await expect(page.getByTestId('event-modal')).toBeVisible() await expect(page.getByText('Neuer Termin')).toBeVisible() }) test('submitting the form creates an event and closes the modal', async ({ page }) => { await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, events: [] }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await page.getByTestId('add-event').click() await page.getByTestId('event-title').fill('SCHILF: Digitale Tafeln') await page.getByTestId('event-type').selectOption('fortbildung') await page.getByTestId('event-save').click() await expect(page.getByTestId('event-modal')).toHaveCount(0) }) test('clicking a day opens the DayDetail with its events', async ({ page }) => { const todayIso = new Date().toISOString().slice(0, 10) await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' }, events: [{ id: 'e1', created_by_user_id: 'dev', title: 'Pruefe Test-Event', event_type: 'projekttag', is_school_free: false, start_date: todayIso, end_date: todayIso, affected_class_ids: [], visible_to_parents: true, notify_parents: false, notify_students: false, notification_lead_days: [7, 1], }], }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await page.getByTestId(`day-${todayIso}`).click() await expect(page.getByTestId('day-detail')).toBeVisible() await expect(page.getByText('Pruefe Test-Event')).toBeVisible() }) }) test.describe('Schulkalender — Schuljahres-Rollover', () => { test('Schuljahr-wechseln button opens the wizard', async ({ page }) => { await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await page.getByTestId('rollover-trigger').click() await expect(page.getByTestId('rollover-wizard')).toBeVisible() }) test('confirm-typing protects against accidental submit', async ({ page }) => { await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await page.getByTestId('rollover-trigger').click() const submit = page.getByTestId('rollover-submit') await expect(submit).toBeDisabled() await page.getByTestId('rollover-confirm').fill('falsch') await expect(submit).toBeDisabled() await page.getByTestId('rollover-confirm').fill('SCHULJAHR WECHSELN') await expect(submit).toBeEnabled() }) test('successful rollover shows summary numbers', async ({ page }) => { await mockCalendarApi(page, { config: { user_id: 'dev', bundesland: 'DE-NI' } }) await page.goto('/schulkalender') await page.waitForLoadState('networkidle') await page.getByTestId('rollover-trigger').click() await page.getByTestId('rollover-confirm').fill('SCHULJAHR WECHSELN') await page.getByTestId('rollover-submit').click() await expect(page.getByTestId('rollover-result')).toBeVisible() await expect(page.getByText('8 Klassen um eine Stufe aufgerueckt')).toBeVisible() await expect(page.getByText('2 Abschlussklassen entfernt')).toBeVisible() }) })