Files
breakpilot-lehrer/studio-v2/e2e/schulkalender.spec.ts
T
Benjamin Admin 33409352ee
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 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m38s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 26s
Phase 9b: Schul-Events CRUD + Schuljahres-Rollover
Backend (school-service):
  - calendar_events.go — Create/List/Delete on cal_school_event with
    UUID[] handling for affected_class_ids. Default lead-days [7,1]
    if caller omits the array.
  - calendar_rollover.go — single-transaction promotion: graduating
    classes (grade >= 13) get deleted first so the +1 update doesn't
    bump them to invalid grade 14. defaultSchoolYearDates() picks the
    next Aug-Jul pair when the caller doesn't specify.
  - Handlers + routes: GET/POST /calendar/events,
    DELETE /calendar/events/:id, POST /calendar/school-year-rollover.

Frontend (studio-v2):
  - EventModal: form with Title / Typ / Datum/Zeit / unterrichtsfrei /
    Beschreibung / Sichtbarkeit + Notification-Checkboxen. Per-Type
    Farb-Mapping in types.ts.
  - DayDetail: Modal das beim Klick auf einen Kalender-Tag aufgeht und
    Feiertage + Schulferien + Schul-Events fuer diesen Tag listet,
    inkl. Loeschen-Button pro Event.
  - RolloverWizard: zwei-Schritt-Dialog mit Datums-Auswahl + Tipp-
    Bestaetigung ("SCHULJAHR WECHSELN") gegen versehentliche Auslo-
    sung, danach Ergebnis-Card mit promoted/graduated-Counts.
  - MonthView gewinnt onDayClick + onAddEvent + onRollover Props,
    rendert farb-codierte Punkte fuer School-Events am Tagesrand.
  - Page laed Events parallel mit Holidays und reicht alle Handler
    nach unten.

Tests:
  - Go: 3 neue Tests fuer defaultSchoolYearDates + parseClassIDs.
    Validator-Test fuer CreateSchoolEventRequest existiert bereits.
    80 Subtests gesamt, alle gruen.
  - Playwright: mockCalendarApi gewinnt Routes fuer events GET/POST/
    DELETE und school-year-rollover. 6 neue Tests (EventModal open,
    submit, DayDetail open, Rollover-Trigger, Confirm-Schutz,
    Ergebnis-Anzeige).

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

252 lines
10 KiB
TypeScript

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<Record<string, unknown>>
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()
})
})