8311b33fb3
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 1m10s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 4m4s
CI / test-python-agent-core (push) Successful in 44s
CI / test-nodejs-website (push) Successful in 51s
Backend (school-service):
- notification_log table with UNIQUE(event_id, lead_days, audience,
channel) for idempotent re-runs. Status enum sent/failed/skipped.
- internal/notifications/templates.go: per-event-type × audience ×
lead-day-bucket × language templates in 8 languages (de/en/tr/ar/
uk/ru/pl/fr). Fallback chain (lang→de, eventType→andere) so we
never miss a render.
- service.go scans cal_school_event for events whose
(start_date - runDate) appears in notification_lead_days. For each
due (audience, channel) tuple it dispatches via POST to the
Matrix/Email upstreams owned by the colleague's services.
Empty URL → status='skipped', logged for visibility.
- dispatcher.go handles the POST, parent-recipient lookup (joins
parent_account + parent_child + cal_school_event.affected_class_ids),
and writeLog with the unique constraint dropping duplicate runs.
- main.go runs a 1-hour ticker; when time.Hour()==6 it invokes the
scanner for today. Idempotent so transient restarts don't double-
send.
- POST /calendar/notifications/run-now for manual trigger + backfill
(?date=YYYY-MM-DD).
- GET /calendar/events/:id/notifications returns notification_log
rows scoped to the owning teacher.
- MATRIX_SERVICE_URL + EMAIL_SERVICE_URL env vars added (default
empty = stub mode).
Frontend (studio-v2):
- NotificationStatus component fetches /events/:id/notifications and
renders coloured badges per (lead, audience, channel, status).
- DayDetail mounts NotificationStatus inside each event card when
notify_parents or notify_students is set.
Tests:
- 6 new Go unit tests for bucketFor + Render (de/tr/fallback paths)
+ substitute(class_suffix). 89 subtests gesamt.
- 2 new Playwright tests: badge render with mocked log, hidden when
notifications are off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
318 lines
13 KiB
TypeScript
318 lines
13 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[]
|
|
notificationLog?: 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',
|
|
}),
|
|
})
|
|
})
|
|
|
|
// Phase 9d: per-event notification_log + manual trigger. NotificationStatus
|
|
// component fetches the log when an event has notify_parents/students.
|
|
await page.route(/\/api\/school\/calendar\/events\/[^/]+\/notifications$/, async (route) => {
|
|
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(opts.notificationLog ?? []) })
|
|
})
|
|
await page.route(/\/api\/school\/calendar\/notifications\/run-now.*/, async (route) => {
|
|
return route.fulfill({ status: 200, contentType: 'application/json',
|
|
body: '{"date":"2026-05-22","sent":0,"failed":0,"skipped":0,"already_logged":0}' })
|
|
})
|
|
}
|
|
|
|
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()
|
|
})
|
|
})
|
|
|
|
// ==========================================================================
|
|
// Phase 9d — Notification-Status im DayDetail
|
|
// ==========================================================================
|
|
|
|
test.describe('Schulkalender — Notification-Status', () => {
|
|
test('shows sent badge for delivered reminders', 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: true, notify_students: false,
|
|
notification_lead_days: [7, 1],
|
|
}],
|
|
notificationLog: [
|
|
{ lead_days: 7, audience: 'parents', channel: 'email', status: 'sent', run_date: '2026-05-15', created_at: '2026-05-15T06:00:00Z' },
|
|
{ lead_days: 1, audience: 'parents', channel: 'email', status: 'skipped', run_date: '2026-05-21', created_at: '2026-05-21T06:00:00Z' },
|
|
],
|
|
})
|
|
await page.goto('/schulkalender')
|
|
await page.waitForLoadState('networkidle')
|
|
await page.getByTestId(`day-${todayIso}`).click()
|
|
await expect(page.getByTestId('day-detail')).toBeVisible()
|
|
|
|
const status = page.getByTestId('notif-status-e1')
|
|
await expect(status).toBeVisible()
|
|
await expect(status.getByText(/7 Tage.*Eltern.*email/)).toBeVisible()
|
|
await expect(status.getByText(/1 Tag.*Eltern.*email/)).toBeVisible()
|
|
})
|
|
|
|
test('hides notification status when notifications are off', async ({ page }) => {
|
|
const todayIso = new Date().toISOString().slice(0, 10)
|
|
await mockCalendarApi(page, {
|
|
config: { user_id: 'dev', bundesland: 'DE-NI' },
|
|
events: [{
|
|
id: 'e2', created_by_user_id: 'dev',
|
|
title: 'Stilles Event', event_type: 'andere',
|
|
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: [],
|
|
}],
|
|
})
|
|
await page.goto('/schulkalender')
|
|
await page.waitForLoadState('networkidle')
|
|
await page.getByTestId(`day-${todayIso}`).click()
|
|
await expect(page.getByTestId('notif-status-e2')).toHaveCount(0)
|
|
})
|
|
})
|