diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 078ae040..f798f279 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -315,7 +315,7 @@ jobs:
needs: changes
if: ${{ needs.changes.outputs.web_client == 'true' }}
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 20
defaults:
run:
working-directory: web-client
@@ -355,6 +355,20 @@ jobs:
path: web-client/coverage/
if-no-files-found: ignore
+ - name: Install Playwright browsers
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Run E2E tests (Playwright)
+ run: pnpm e2e
+
+ - name: Upload Playwright report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: web-client-playwright-report
+ path: web-client/playwright-report/
+ if-no-files-found: ignore
+
- name: Build (Vite)
run: pnpm build
diff --git a/web-client/.env.development.example b/web-client/.env.development.example
index 6e06e283..a2c4a0d9 100644
--- a/web-client/.env.development.example
+++ b/web-client/.env.development.example
@@ -1,14 +1,10 @@
# Copy this file to `.env.development` for local dev. Vite loads `.env.development`
# automatically for `pnpm dev`. The real `.env.development` is gitignored so each
-# developer can choose mocks vs. live backend without committing the choice.
+# developer can keep local overrides without committing them.
+#
+# The app talks only to the real server. Run the stack with
+# `docker compose up -d --build` from infra/ (not just keycloak). For offline UI work,
+# run the Playwright E2E suite, which serves the API in-memory (see e2e/README.md).
-# Defaults to the real backend (requires the full stack running --
-# `docker compose up -d --build` from infra/, not just keycloak).
-# Set to true to serve fixtures everywhere instead, for UI work with no backend running.
-VITE_USE_MOCKS=false
-
-# Only used when VITE_USE_MOCKS=true. Demo as a specific persona so member-scoped
-# pages (dashboard feedback, payments, development report) resolve against that
-# fixture identity.
-# Options: member | coach | director | admin
-VITE_MOCK_PERSONA=member
+# Keycloak base URL (defaults to http://localhost:8081/auth if unset).
+# VITE_KEYCLOAK_URL=http://localhost:8081/auth
diff --git a/web-client/.gitignore b/web-client/.gitignore
index 12ff2f6f..d1900d15 100644
--- a/web-client/.gitignore
+++ b/web-client/.gitignore
@@ -14,6 +14,11 @@ coverage
*.local
progress/
+# Playwright outputs
+playwright-report/
+test-results/
+.playwright/
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
diff --git a/web-client/e2e/README.md b/web-client/e2e/README.md
new file mode 100644
index 00000000..794042ac
--- /dev/null
+++ b/web-client/e2e/README.md
@@ -0,0 +1,53 @@
+# Web-client E2E suite (Playwright)
+
+Drives the real, server-only app with its `/api/v1` calls answered at the network
+layer by an in-memory server — no real services and no Keycloak server needed.
+
+## Run it
+
+```bash
+pnpm -C web-client e2e # headless run (boots its own Vite dev server on :5199)
+pnpm -C web-client e2e:ui # Playwright UI mode
+```
+
+The app makes real axios calls to `/api/v1/*`; `e2e/support/api.ts` intercepts them per
+test context and answers from `e2e/support/server/*` (one module per resource). Browser:
+chromium (`pnpm exec playwright install chromium`).
+
+## How the app gets past Keycloak
+
+`AuthenticatedApp` always runs `keycloak.init({ onLoad: 'check-sso' })`. The suite uses a
+test-side-only approach with zero production changes: `e2e/support/auth.ts` intercepts the
+three requests keycloak-js makes (3p-cookie probe iframe, silent-check-sso authorization
+request, code-for-token exchange) with `context.route(...)` and answers them like a real
+Keycloak would, minting an unsigned JWT whose `nonce` echoes the login request (keycloak-js
+does not verify signatures client-side, but does verify the nonce). The app reaches
+`status: 'ready'` in ~1s.
+
+**Identity:** `getCurrentUser()` reads the Keycloak token only. The stubbed token is minted
+for the seeded **admin**, and the server answers every request as that same admin, so the
+suite runs as one identity (admin) — no persona switching. Because the admin may use every
+route, the route-guard deny branch is covered by unit tests (`RouteRoleGuard.test.tsx`),
+along with the per-role nav/scoping sweep (`AppShell.test.tsx`, `scope.test.ts`).
+
+## State isolation
+
+The in-memory server keeps its mutable state (deep clones of the fixtures) in the
+Playwright/Node process, shared across a worker's tests. `stubApi()` calls each resource's
+`reset()` before wiring the routes, so every test starts from a pristine copy — one test's
+create/edit/delete never leaks into the next. Tests run fully parallel (each Playwright
+worker is its own process, and tests within a worker run serially, so the shared state is
+safe). Do not chain assertions across tests expecting mutated state to survive.
+
+Assertions derive from the fixtures themselves (`e2e/support/data.ts` re-exports the same
+fixtures the server serves), not hard-coded strings.
+
+## What lives where
+
+- **E2E (this suite):** boot past auth, sidebar nav for the admin role, user-menu identity,
+ theme persistence, 404, per-page fixture-scoped rendering, and the full happy paths of
+ members CRUD (+409 duplicate email), feedback compose/edit/delete, events
+ create/edit/delete, payments create (+2 form-error paths)/delete, letters
+ preview/send/PDF-mode.
+- **Unit/component tests:** the per-role sweep (nav visibility, route guard, scoping),
+ dialog validation/error branches, and view-model logic.
diff --git a/web-client/e2e/app-shell.spec.ts b/web-client/e2e/app-shell.spec.ts
new file mode 100644
index 00000000..98f83073
--- /dev/null
+++ b/web-client/e2e/app-shell.spec.ts
@@ -0,0 +1,57 @@
+import { ADMIN, NAV_ITEMS } from './support/data'
+import { expect, gotoApp, test } from './support/fixtures'
+
+test('sidebar shows exactly the nav items the admin role may use', async ({ page }) => {
+ await gotoApp(page)
+
+ const allowed = NAV_ITEMS.filter((item) => item.roles.includes(ADMIN.role))
+ for (const item of allowed) {
+ await expect(page.getByRole('link', { name: item.label, exact: true })).toBeVisible()
+ }
+
+ // Nav renders from navPolicy.ts only — no extra destinations beyond the allowed set.
+ await expect(page.locator('a[data-sidebar="menu-button"]')).toHaveCount(allowed.length)
+})
+
+test('user menu shows the signed-in identity and links to the profile', async ({ page }) => {
+ await gotoApp(page)
+
+ const userMenuTrigger = page.getByRole('button', { name: new RegExp(ADMIN.name) })
+ await expect(userMenuTrigger).toBeVisible()
+ await expect(userMenuTrigger).toContainText(ADMIN.email)
+
+ await userMenuTrigger.click()
+ await expect(page.getByRole('menuitem', { name: 'Profile' })).toBeVisible()
+ await expect(page.getByRole('menuitem', { name: 'Log out' })).toBeVisible()
+
+ await page.getByRole('menuitem', { name: 'Profile' }).click()
+ await expect(page).toHaveURL(/\/profile$/)
+})
+
+test('theme switch flips the dark class and persists to localStorage', async ({ page }) => {
+ await gotoApp(page)
+
+ await page.getByRole('button', { name: new RegExp(ADMIN.name) }).click()
+ await page.getByRole('menuitemradio', { name: 'Dark' }).click()
+
+ await expect(page.locator('html')).toHaveClass(/dark/)
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem('ui-theme')))
+ .toBe('dark')
+
+ // Survives a full reload (ThemeProvider re-reads localStorage).
+ await gotoApp(page)
+ await expect(page.locator('html')).toHaveClass(/dark/)
+
+ await page.getByRole('button', { name: new RegExp(ADMIN.name) }).click()
+ await page.getByRole('menuitemradio', { name: 'Light' }).click()
+ await expect(page.locator('html')).not.toHaveClass(/dark/)
+})
+
+test('unknown paths render the NotFound page inside the shell', async ({ page }) => {
+ await gotoApp(page, '/this-route-does-not-exist')
+
+ await expect(page.getByRole('heading', { name: '404 - Page not found' })).toBeVisible()
+ await page.getByRole('button', { name: 'Go home' }).click()
+ await expect(page.getByText("Here's what's happening across your club.")).toBeVisible()
+})
diff --git a/web-client/e2e/boot.spec.ts b/web-client/e2e/boot.spec.ts
new file mode 100644
index 00000000..25589d08
--- /dev/null
+++ b/web-client/e2e/boot.spec.ts
@@ -0,0 +1,10 @@
+import { expect, gotoApp, test } from './support/fixtures'
+
+test('boots past the auth spinner into the app shell', async ({ page }) => {
+ await gotoApp(page)
+
+ await expect(page.getByRole('heading', { name: 'Roost' })).toBeVisible()
+ // Neither the auth error card nor the spinner may remain.
+ await expect(page.getByText('Sign-in error')).toHaveCount(0)
+ await expect(page.getByText('Cannot reach authentication server')).toHaveCount(0)
+})
diff --git a/web-client/e2e/events.spec.ts b/web-client/e2e/events.spec.ts
new file mode 100644
index 00000000..ea0c8ef0
--- /dev/null
+++ b/web-client/e2e/events.spec.ts
@@ -0,0 +1,68 @@
+import { eventSummaryFixtures } from './support/data'
+import { expect, gotoApp, test } from './support/fixtures'
+
+test('creates an event through the four-step dialog', async ({ page }) => {
+ await gotoApp(page, '/sport-events')
+
+ await page.getByRole('button', { name: 'New event' }).click()
+ const dialog = page.getByRole('dialog', { name: 'New Event' })
+
+ await dialog.getByLabel('Name').fill('E2E Friendly Match')
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ // Schedule step is pre-filled with a valid start/end pair.
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ // Sports & teams and attendees are optional for the admin.
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Create Event' }).click()
+
+ // Creation opens the detail sheet for the new event; the notice behind the
+ // modal sheet is aria-hidden until the sheet closes.
+ const sheet = page.getByRole('dialog', { name: 'E2E Friendly Match' })
+ await expect(sheet).toBeVisible()
+ await sheet.getByRole('button', { name: 'Close' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Event created.')
+ await expect(page.getByRole('button', { name: 'View E2E Friendly Match' })).toBeVisible()
+})
+
+test('blocks an empty name on the details step', async ({ page }) => {
+ await gotoApp(page, '/sport-events')
+
+ await page.getByRole('button', { name: 'New event' }).click()
+ const dialog = page.getByRole('dialog', { name: 'New Event' })
+
+ // The name input is `required`, so a blank submit is stopped by the browser
+ // and the dialog stays on the first step.
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await expect(dialog.getByLabel('Name')).toBeVisible()
+ await expect(dialog.getByRole('button', { name: 'Next' })).toBeVisible()
+})
+
+test('edits an event name from the list', async ({ page }) => {
+ await gotoApp(page, '/sport-events')
+
+ const target = eventSummaryFixtures[0]
+ await page.getByRole('button', { name: `Edit ${target.name}` }).first().click()
+
+ const dialog = page.getByRole('dialog', { name: 'Edit Event' })
+ await dialog.getByLabel('Name').fill('Renamed E2E Session')
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Save Event' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Event updated.')
+ await expect(page.getByRole('button', { name: 'View Renamed E2E Session' })).toBeVisible()
+})
+
+test('deletes an event after confirmation', async ({ page }) => {
+ await gotoApp(page, '/sport-events')
+
+ const target = eventSummaryFixtures[0]
+ await page.getByRole('button', { name: `Delete ${target.name}` }).first().click()
+
+ const confirm = page.getByRole('alertdialog')
+ await confirm.getByRole('button', { name: 'Delete', exact: true }).click()
+
+ await expect(page.getByRole('status')).toContainText('Event deleted.')
+})
diff --git a/web-client/e2e/feedback.spec.ts b/web-client/e2e/feedback.spec.ts
new file mode 100644
index 00000000..1bee189c
--- /dev/null
+++ b/web-client/e2e/feedback.spec.ts
@@ -0,0 +1,64 @@
+import { feedbackSummaryFixtures } from './support/data'
+import { expect, gotoApp, test } from './support/fixtures'
+
+test('composes feedback via the sport → team → event picker', async ({ page }) => {
+ await gotoApp(page, '/feedback')
+
+ await page.getByRole('button', { name: 'New feedback' }).click()
+ const picker = page.getByRole('dialog', { name: 'New feedback' })
+
+ // Drill down the coverage tree: first sport → first team → first event.
+ for (let level = 0; level < 3; level += 1) {
+ await picker.locator('ul > li button').first().click()
+ }
+
+ // Remember who we are giving feedback to, then open the compose dialog.
+ const traineeRow = picker.locator('ul > li').first()
+ const traineeName = (await traineeRow.locator('p').first().innerText()).trim()
+ await traineeRow.getByRole('button', { name: `Give feedback for ${traineeName}` }).click()
+
+ const compose = page.getByRole('dialog', { name: 'Give Feedback' })
+ await expect(compose).toContainText(traineeName)
+ await compose.getByLabel('Feedback').fill('Great energy in the session — keep it up.')
+ await compose.getByLabel('Rating').fill('9')
+ await compose.getByRole('button', { name: 'Save Feedback' }).click()
+
+ await expect(page.getByRole('status')).toContainText(`Feedback added for ${traineeName}.`)
+ await expect(
+ page.getByRole('button', { name: `View feedback for ${traineeName}` }).first(),
+ ).toBeVisible()
+})
+
+test('edits existing feedback from the detail sheet', async ({ page }) => {
+ await gotoApp(page, '/feedback')
+
+ const target = feedbackSummaryFixtures[0]
+ await page.getByRole('button', { name: `View feedback for ${target.member.name}` }).first().click()
+
+ const sheet = page.getByRole('dialog', { name: target.event.name })
+ await sheet.getByRole('button', { name: 'Edit Feedback' }).click()
+
+ const editDialog = page.getByRole('dialog', { name: 'Edit feedback' })
+ await editDialog.getByLabel('Rating').fill('10')
+ await editDialog.getByRole('button', { name: 'Save changes' }).click()
+
+ await expect(sheet.getByText('10/10')).toBeVisible()
+})
+
+test('deletes feedback after confirmation', async ({ page }) => {
+ await gotoApp(page, '/feedback')
+
+ const target = feedbackSummaryFixtures[0]
+ const deleteButtons = page.getByRole('button', {
+ name: `Delete feedback for ${target.member.name}`,
+ })
+ // Wait for the row to render before counting, so the count isn't read early.
+ await expect(deleteButtons.first()).toBeVisible()
+ const countBefore = await deleteButtons.count()
+
+ await deleteButtons.first().click()
+ const confirm = page.getByRole('alertdialog', { name: 'Delete feedback' })
+ await confirm.getByRole('button', { name: 'Delete', exact: true }).click()
+
+ await expect(deleteButtons).toHaveCount(countBefore - 1)
+})
diff --git a/web-client/e2e/helper.spec.ts b/web-client/e2e/helper.spec.ts
new file mode 100644
index 00000000..2a16e538
--- /dev/null
+++ b/web-client/e2e/helper.spec.ts
@@ -0,0 +1,38 @@
+import { ADMIN } from './support/data'
+import { expect, gotoApp, test } from './support/fixtures'
+
+test('generates a report', async ({ page }) => {
+ await gotoApp(page, '/helper')
+
+ await page.getByRole('button', { name: 'Generate report' }).click()
+
+ await expect(page.getByText('Report generation started')).toBeVisible()
+ await expect(page.getByRole('button', { name: `Read report for ${ADMIN.name}` })).toBeVisible()
+})
+
+test('views a report', async ({ page }) => {
+ await gotoApp(page, '/helper')
+
+ await page.getByRole('button', { name: 'Generate report' }).click()
+ await page.getByRole('button', { name: `Read report for ${ADMIN.name}` }).click()
+
+ const sheet = page.getByRole('dialog', { name: ADMIN.name })
+ await expect(sheet).toBeVisible()
+ await expect(sheet.getByText('Report generation in progress.')).toBeVisible()
+})
+
+test('deletes a report', async ({ page }) => {
+ await gotoApp(page, '/helper')
+
+ await page.getByRole('button', { name: 'Generate report' }).click()
+ const deleteButtons = page.getByRole('button', { name: `Delete report for ${ADMIN.name}` })
+ await expect(deleteButtons.first()).toBeVisible()
+ const countBefore = await deleteButtons.count()
+
+ await deleteButtons.first().click()
+ const confirm = page.getByRole('alertdialog', { name: 'Delete report' })
+ await expect(confirm).toContainText(`Delete ${ADMIN.name}?`)
+ await confirm.getByRole('button', { name: 'Delete', exact: true }).click()
+
+ await expect(deleteButtons).toHaveCount(countBefore - 1)
+})
diff --git a/web-client/e2e/letters.spec.ts b/web-client/e2e/letters.spec.ts
new file mode 100644
index 00000000..1c9fdea4
--- /dev/null
+++ b/web-client/e2e/letters.spec.ts
@@ -0,0 +1,34 @@
+import { expect, gotoApp, test } from './support/fixtures'
+
+test('renders a live preview with sample recipient data', async ({ page }) => {
+ await gotoApp(page, '/letters')
+
+ // Admin audience goes to the whole club.
+ await expect(page.getByText('This will go to all members.')).toBeVisible()
+
+ await page.getByLabel('Template').fill('
Hello {{first_name}}, welcome to the club.
')
+
+ const preview = page.frameLocator('iframe[title="Letter template sample preview"]')
+ // Tokens are substituted with the documented sample values ({{first_name}} -> Alex).
+ await expect(preview.getByText('Hello Alex, welcome to the club.')).toBeVisible()
+})
+
+test('sends a mail letter to the admin audience', async ({ page }) => {
+ await gotoApp(page, '/letters')
+
+ await page.getByLabel('Subject').fill('E2E newsletter')
+ await page.getByLabel('Template').fill('Hello {{first_name}}
See you at training.
')
+ await page.getByRole('button', { name: 'Send Mail' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Mail sent to all members.')
+})
+
+test('switches to PDF mode and hides the subject field', async ({ page }) => {
+ await gotoApp(page, '/letters')
+
+ await expect(page.getByLabel('Subject')).toBeVisible()
+ await page.getByRole('button', { name: 'PDF' }).click()
+
+ await expect(page.getByLabel('Subject')).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Download PDF' })).toBeVisible()
+})
diff --git a/web-client/e2e/members.spec.ts b/web-client/e2e/members.spec.ts
new file mode 100644
index 00000000..2cb31c8a
--- /dev/null
+++ b/web-client/e2e/members.spec.ts
@@ -0,0 +1,76 @@
+import { memberSummaryFixtures } from './support/data'
+import { expect, gotoApp, test } from './support/fixtures'
+
+// Admin members CRUD. Each test gets a pristine server copy (stubApi resets it),
+// so the specs are order-independent.
+
+test('lists every member for the admin', async ({ page }) => {
+ await gotoApp(page, '/members')
+
+ await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible()
+ await expect(page.getByRole('cell', { name: 'Lena Roth', exact: true })).toBeVisible()
+ // Admin scope = all rows (one table row per member summary + 1 header row).
+ await expect(page.getByRole('row')).toHaveCount(memberSummaryFixtures.length + 1)
+})
+
+test('creates a member through the stepper dialog', async ({ page }) => {
+ await gotoApp(page, '/members')
+
+ await page.getByRole('button', { name: 'New member' }).click()
+ const dialog = page.getByRole('dialog', { name: 'New Member' })
+ await dialog.getByLabel('First name').fill('E2E')
+ await dialog.getByLabel('Last name').fill('Testperson')
+ await dialog.getByLabel('Email').fill('e2e.testperson@club.de')
+ await dialog.getByLabel('Initial password').fill('changeme-e2e')
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Create Member' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Member created.')
+ await expect(page.getByRole('cell', { name: 'E2E Testperson', exact: true })).toBeVisible()
+})
+
+test('surfaces the 409 duplicate-email error inside the dialog', async ({ page }) => {
+ await gotoApp(page, '/members')
+
+ await page.getByRole('button', { name: 'New member' }).click()
+ const dialog = page.getByRole('dialog', { name: 'New Member' })
+ await dialog.getByLabel('First name').fill('Duplicate')
+ await dialog.getByLabel('Last name').fill('Email')
+ // lena.roth@club.de belongs to an existing fixture member -> 409.
+ await dialog.getByLabel('Email').fill('lena.roth@club.de')
+ await dialog.getByLabel('Initial password').fill('changeme-e2e')
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Create Member' }).click()
+
+ await expect(dialog).toContainText('Email already in use')
+ await expect(dialog).toBeVisible()
+})
+
+test('edits a member and reflects the change in the table', async ({ page }) => {
+ await gotoApp(page, '/members')
+
+ await page.getByRole('button', { name: 'Edit Lena Roth' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Edit Member' })
+ await dialog.getByLabel('First name').fill('Magda')
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Save Member' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Member updated.')
+ await expect(page.getByRole('cell', { name: 'Magda Roth', exact: true })).toBeVisible()
+ await expect(page.getByRole('cell', { name: 'Lena Roth', exact: true })).toHaveCount(0)
+})
+
+test('deletes a member after confirmation', async ({ page }) => {
+ await gotoApp(page, '/members')
+
+ await page.getByRole('button', { name: 'Delete Lena Roth' }).click()
+ const confirm = page.getByRole('alertdialog', { name: 'Delete member' })
+ await expect(confirm).toContainText('Delete Lena Roth?')
+ await confirm.getByRole('button', { name: 'Delete', exact: true }).click()
+
+ await expect(page.getByRole('status')).toContainText('Member deleted.')
+ await expect(page.getByRole('cell', { name: 'Lena Roth', exact: true })).toHaveCount(0)
+})
diff --git a/web-client/e2e/organization.spec.ts b/web-client/e2e/organization.spec.ts
new file mode 100644
index 00000000..1adde9fe
--- /dev/null
+++ b/web-client/e2e/organization.spec.ts
@@ -0,0 +1,85 @@
+import { sportFixtures, teamFixtures } from './support/data'
+import { expect, gotoApp, test } from './support/fixtures'
+
+test('creates a sport', async ({ page }) => {
+ await gotoApp(page, '/organization')
+
+ await page.getByRole('button', { name: 'New sport' }).click()
+ const dialog = page.getByRole('dialog', { name: 'New Sport' })
+ await dialog.getByLabel('Name').fill('E2E Pickleball')
+ await dialog.getByLabel('Description').fill('E2E indoor sessions.')
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Create Sport' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Sport created.')
+ await expect(page.getByText('E2E Pickleball', { exact: true })).toBeVisible()
+})
+
+test('edits a sport', async ({ page }) => {
+ await gotoApp(page, '/organization')
+
+ const target = sportFixtures[0]
+ await page.getByRole('button', { name: `Edit ${target.name}`, exact: true }).click()
+
+ const dialog = page.getByRole('dialog', { name: 'Edit Sport' })
+ await dialog.getByLabel('Name').fill('E2E Football')
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Save Sport' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Sport updated.')
+ await expect(page.getByText('E2E Football', { exact: true })).toBeVisible()
+ await expect(page.getByText(target.name, { exact: true })).toHaveCount(0)
+})
+
+test('deletes a sport', async ({ page }) => {
+ await gotoApp(page, '/organization')
+
+ const target = sportFixtures[0]
+ await page.getByRole('button', { name: `Delete ${target.name}`, exact: true }).click()
+ const confirm = page.getByRole('alertdialog', { name: 'Delete sport' })
+ await expect(confirm).toContainText(`Delete ${target.name}?`)
+ await confirm.getByRole('button', { name: 'Delete sport' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Sport deleted.')
+ await expect(page.getByText(target.name, { exact: true })).toHaveCount(0)
+})
+
+test('creates a team', async ({ page }) => {
+ await gotoApp(page, '/organization')
+
+ const sport = sportFixtures[0]
+ await page.getByRole('button', { name: 'New team' }).click()
+ const dialog = page.getByRole('dialog', { name: 'New Team' })
+ await dialog.getByLabel('Name').fill('E2E Morning Crew')
+ await dialog.getByRole('combobox', { name: 'Sport' }).click()
+ await page.getByRole('option', { name: sport.name }).click()
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Create Team' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Team created.')
+ await expect(page.getByText('E2E Morning Crew', { exact: true })).toBeVisible()
+})
+
+test('edits and deletes a team', async ({ page }) => {
+ await gotoApp(page, '/organization')
+
+ const target = teamFixtures[0]
+ await page.getByRole('button', { name: `Edit ${target.name}`, exact: true }).click()
+
+ const dialog = page.getByRole('dialog', { name: 'Edit Team' })
+ await dialog.getByLabel('Name').fill('E2E Football Juniors')
+ await dialog.getByRole('button', { name: 'Next' }).click()
+ await dialog.getByRole('button', { name: 'Save Team' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Team updated.')
+ await expect(page.getByText('E2E Football Juniors', { exact: true })).toBeVisible()
+ await expect(page.getByText(target.name, { exact: true })).toHaveCount(0)
+
+ await page.getByRole('button', { name: 'Delete E2E Football Juniors' }).click()
+ const confirm = page.getByRole('alertdialog', { name: 'Delete team' })
+ await expect(confirm).toContainText('Delete E2E Football Juniors?')
+ await confirm.getByRole('button', { name: 'Delete', exact: true }).click()
+
+ await expect(page.getByRole('status')).toContainText('Team deleted.')
+ await expect(page.getByText('E2E Football Juniors', { exact: true })).toHaveCount(0)
+})
diff --git a/web-client/e2e/pages.spec.ts b/web-client/e2e/pages.spec.ts
new file mode 100644
index 00000000..a199bed8
--- /dev/null
+++ b/web-client/e2e/pages.spec.ts
@@ -0,0 +1,82 @@
+import {
+ ADMIN,
+ dashboardFixtures,
+ eventSummaryFixtures,
+ feedbackSummaryFixtures,
+ memberFixtures,
+ sportFixtures,
+ teamFixtures,
+} from './support/data'
+import { expect, gotoApp, test } from './support/fixtures'
+
+// Every reachable page renders content derived from the fixtures + scope rules
+// for the running admin identity (admin scope = everything).
+
+test('dashboard greets the admin and shows the admin count envelope', async ({ page }) => {
+ await gotoApp(page)
+
+ const adminDashboard = dashboardFixtures.admin
+ if (adminDashboard.role !== 'admin') throw new Error('expected the admin envelope')
+
+ await expect(page.getByRole('heading', { name: ADMIN.name })).toBeVisible()
+ // StatCard renders label + value as sibling paragraphs inside the card div.
+ const membersCard = page.getByText('Total Members', { exact: true }).locator('..')
+ await expect(membersCard).toContainText(String(adminDashboard.total_members))
+ const sportsCard = page.getByText('Total Sports', { exact: true }).locator('..')
+ await expect(sportsCard).toContainText(String(adminDashboard.total_sports))
+ await expect(page.getByText('Organization Summary')).toBeVisible()
+})
+
+test('events page lists every fixture event for the admin', async ({ page }) => {
+ await gotoApp(page, '/sport-events')
+
+ await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible()
+ // Admin scope = all rows; spot-check the first and last fixture events.
+ const first = eventSummaryFixtures[0]
+ const last = eventSummaryFixtures[eventSummaryFixtures.length - 1]
+ await expect(page.getByRole('button', { name: `View ${first.name}` }).first()).toBeVisible()
+ await expect(page.getByRole('button', { name: `View ${last.name}` }).first()).toBeVisible()
+})
+
+test('feedback page lists fixture rows with ratings for the admin', async ({ page }) => {
+ await gotoApp(page, '/feedback')
+
+ await expect(page.getByRole('heading', { name: 'Feedback' })).toBeVisible()
+ const first = feedbackSummaryFixtures[0]
+ await expect(
+ page.getByRole('button', { name: `View feedback for ${first.member.name}` }).first(),
+ ).toBeVisible()
+ await expect(page.getByText(`${first.rating}/10`).first()).toBeVisible()
+})
+
+test('teams page shows fixture sports and teams', async ({ page }) => {
+ await gotoApp(page, '/organization')
+
+ await expect(page.getByText(sportFixtures[0].name).first()).toBeVisible()
+ await expect(page.getByText(teamFixtures[0].name).first()).toBeVisible()
+})
+
+test('payments page shows the managed lens with fixture balances', async ({ page }) => {
+ await gotoApp(page, '/payments')
+
+ await expect(page.getByText('Member balances and transaction history.')).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Balances' })).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Transactions' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'New Transaction' })).toBeVisible()
+})
+
+test('development page renders the report generator', async ({ page }) => {
+ await gotoApp(page, '/helper')
+
+ await expect(page.getByRole('heading', { name: 'Development' })).toBeVisible()
+})
+
+test('profile page loads the admin persona member record', async ({ page }) => {
+ await gotoApp(page, '/profile')
+
+ const adminMember = memberFixtures.find((member) => member.id === ADMIN.id)
+ if (!adminMember) throw new Error('admin persona must exist in memberFixtures')
+
+ await expect(page.getByLabel('Email')).toHaveValue(adminMember.email)
+ await expect(page.getByLabel('First name')).toHaveValue(adminMember.first_name)
+})
diff --git a/web-client/e2e/payments.spec.ts b/web-client/e2e/payments.spec.ts
new file mode 100644
index 00000000..7681be82
--- /dev/null
+++ b/web-client/e2e/payments.spec.ts
@@ -0,0 +1,69 @@
+import { expect, gotoApp, test } from './support/fixtures'
+
+test('shows a field error when no member is selected', async ({ page }) => {
+ await gotoApp(page, '/payments')
+
+ await page.getByRole('button', { name: 'New Transaction' }).click()
+ const dialog = page.getByRole('dialog', { name: 'New Transaction' })
+
+ await dialog.getByLabel('Amount').fill('25')
+ await dialog.getByLabel('Title').fill('E2E fee')
+ await dialog.getByRole('button', { name: 'Record Transaction' }).click()
+
+ await expect(dialog).toContainText('Select a member.')
+ await expect(dialog.getByRole('combobox').first()).toHaveAttribute('aria-invalid', 'true')
+})
+
+test('records a transaction via the searchable member combobox', async ({ page }) => {
+ await gotoApp(page, '/payments')
+
+ await page.getByRole('button', { name: 'New Transaction' }).click()
+ const dialog = page.getByRole('dialog', { name: 'New Transaction' })
+
+ await dialog.getByRole('combobox', { name: 'Member' }).click()
+ await page.getByRole('combobox', { name: 'Search members' }).fill('Marie Wolf')
+ await page.getByRole('option', { name: 'Marie Wolf' }).click()
+
+ await dialog.getByRole('button', { name: 'Payment' }).click()
+ await dialog.getByLabel('Amount').fill('25.50')
+ await dialog.getByLabel('Title').fill('E2E membership payment')
+ await dialog.getByRole('button', { name: 'Record Transaction' }).click()
+
+ await expect(page.getByRole('status')).toContainText('Transaction recorded for Marie Wolf.')
+ await expect(
+ page.getByRole('cell', { name: 'E2E membership payment', exact: true }),
+ ).toBeVisible()
+})
+
+test('rejects a malformed amount with a form error', async ({ page }) => {
+ await gotoApp(page, '/payments')
+
+ await page.getByRole('button', { name: 'New Transaction' }).click()
+ const dialog = page.getByRole('dialog', { name: 'New Transaction' })
+
+ await dialog.getByRole('combobox', { name: 'Member' }).click()
+ await page.getByRole('combobox', { name: 'Search members' }).fill('Marie Wolf')
+ await page.getByRole('option', { name: 'Marie Wolf' }).click()
+ await dialog.getByLabel('Amount').fill('not-a-number')
+ await dialog.getByLabel('Title').fill('E2E fee')
+ await dialog.getByRole('button', { name: 'Record Transaction' }).click()
+
+ await expect(dialog).toContainText('Enter an amount in euros with up to two decimals.')
+})
+
+test('deletes a transaction from the managed table', async ({ page }) => {
+ await gotoApp(page, '/payments')
+
+ // Admin-created fixture transactions expose a delete action. Wait for the first row's
+ // delete button so the count below isn't read before the table has loaded.
+ const deleteButtons = page.getByRole('button', { name: /^Delete / })
+ await expect(deleteButtons.first()).toBeVisible()
+ const countBefore = await deleteButtons.count()
+ expect(countBefore).toBeGreaterThan(0)
+
+ await deleteButtons.first().click()
+ const confirm = page.getByRole('alertdialog', { name: 'Delete transaction' })
+ await confirm.getByRole('button', { name: 'Delete', exact: true }).click()
+
+ await expect(page.getByRole('status')).toContainText('Transaction deleted.')
+})
diff --git a/web-client/e2e/profile.spec.ts b/web-client/e2e/profile.spec.ts
new file mode 100644
index 00000000..a397c240
--- /dev/null
+++ b/web-client/e2e/profile.spec.ts
@@ -0,0 +1,43 @@
+import { ADMIN, memberFixtures } from './support/data'
+import { expect, gotoApp, test } from './support/fixtures'
+
+test('edits and saves the own record', async ({ page }) => {
+ await gotoApp(page, '/profile')
+
+ const adminMember = memberFixtures.find((member) => member.id === ADMIN.id)
+ if (!adminMember) throw new Error('admin persona must exist in memberFixtures')
+
+ const phone = page.getByLabel('Phone number')
+ const save = page.getByRole('button', { name: 'Save profile' })
+ await expect(phone).toHaveValue(adminMember.phone_number)
+ await expect(save).toBeDisabled()
+
+ await phone.fill('+49 170 000000')
+ await expect(save).toBeEnabled()
+ await save.click()
+
+ await expect(page.getByRole('status')).toContainText('Profile updated.')
+ await expect(phone).toHaveValue('+49 170 000000')
+ await expect(save).toBeDisabled()
+})
+
+test('cancels and reverts unsaved changes', async ({ page }) => {
+ await gotoApp(page, '/profile')
+
+ const adminMember = memberFixtures.find((member) => member.id === ADMIN.id)
+ if (!adminMember) throw new Error('admin persona must exist in memberFixtures')
+
+ const address = page.getByLabel('Address')
+ const save = page.getByRole('button', { name: 'Save profile' })
+ const cancel = page.getByRole('button', { name: 'Cancel' })
+ await expect(address).toHaveValue(adminMember.address)
+ await expect(save).toBeDisabled()
+
+ await address.fill('E2E profile street 12')
+ await expect(save).toBeEnabled()
+ await expect(cancel).toBeEnabled()
+ await cancel.click()
+
+ await expect(address).toHaveValue(adminMember.address)
+ await expect(save).toBeDisabled()
+})
diff --git a/web-client/e2e/support/api.ts b/web-client/e2e/support/api.ts
new file mode 100644
index 00000000..9e4aa40f
--- /dev/null
+++ b/web-client/e2e/support/api.ts
@@ -0,0 +1,176 @@
+import type { BrowserContext, Route } from '@playwright/test'
+
+import { E2E_USER } from './auth'
+import type { AuthUser } from '../../src/types'
+import * as members from './server/members'
+import * as events from './server/events'
+import * as feedback from './server/feedback'
+import * as payments from './server/payments'
+import * as organization from './server/organization'
+import * as helper from './server/helper'
+import * as letters from './server/letters'
+import * as dashboard from './server/dashboard'
+
+// Intercepts the app's axios calls at the network layer (context.route on **/api/v1/**)
+// and answers them from an in-memory server, so no real services run. The server keeps
+// module-level state (deep clones of the fixtures); stubApi() resets it per test.
+
+const USER: AuthUser = {
+ id: E2E_USER.sub,
+ name: E2E_USER.name,
+ email: E2E_USER.email,
+ role: 'admin',
+}
+
+type ApiError = Error & { response?: { status?: number; data?: unknown } }
+
+const server = { members, events, feedback, payments, organization, helper, letters, dashboard }
+
+function resetServer(): void {
+ members.reset()
+ events.reset()
+ feedback.reset()
+ payments.reset()
+ organization.reset()
+ helper.reset()
+}
+
+interface Parsed {
+ method: string
+ // path segments after /api/v1, e.g. ['members', ''] or ['finance', 'transactions']
+ segments: string[]
+ body: unknown
+}
+
+function parse(route: Route): Parsed {
+ const request = route.request()
+ const url = new URL(request.url())
+ const after = url.pathname.replace(/^.*\/api\/v1\//, '')
+ const segments = after.split('/').filter(Boolean).map(decodeURIComponent)
+ let body: unknown
+ try {
+ body = request.postData() ? JSON.parse(request.postData() as string) : undefined
+ } catch {
+ body = request.postData()
+ }
+ return { method: request.method(), segments, body }
+}
+
+async function run(route: Route, produce: () => unknown | Promise): Promise {
+ try {
+ const result = await produce()
+ if (result === undefined) {
+ await route.fulfill({ status: 204, body: '' })
+ return
+ }
+ await route.fulfill({
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(result),
+ })
+ } catch (error) {
+ const err = error as ApiError
+ const status = err.response?.status ?? 500
+ const data = err.response?.data ?? { message: err.message }
+ await route.fulfill({
+ status,
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(data),
+ })
+ }
+}
+
+// Routes one intercepted request to the in-memory server. A thrown httpError() carries its status.
+function dispatch({ method, segments, body }: Parsed): unknown | Promise {
+ const [service, a, b] = segments
+
+ switch (service) {
+ case 'members': {
+ // dashboard lives under members: GET /members/dashboard
+ if (a === 'dashboard') return server.dashboard.getDashboard(USER)
+ if (method === 'GET' && a === 'hello') return 'members'
+ if (method === 'GET' && !a) return server.members.listMembers(USER)
+ if (method === 'GET') return server.members.getMember(a, USER)
+ if (method === 'POST') return server.members.createMember(body as never, USER)
+ if (method === 'PATCH') return server.members.updateMember(a, body as never, USER)
+ if (method === 'DELETE') return server.members.deleteMember(a, USER)
+ break
+ }
+ case 'events': {
+ if (method === 'GET' && a === 'hello') return 'events'
+ if (method === 'GET' && !a) return server.events.listEvents(USER)
+ if (method === 'GET') return server.events.getEvent(a, USER)
+ if (method === 'POST') return server.events.createEvent(body as never, USER)
+ if (method === 'PATCH') return server.events.updateEvent(a, body as never, USER)
+ if (method === 'DELETE') return server.events.deleteEvent(a, USER)
+ break
+ }
+ case 'feedback': {
+ if (method === 'GET' && a === 'hello') return 'feedback'
+ if (method === 'GET' && !a) return server.feedback.listFeedback(USER)
+ if (method === 'GET') return server.feedback.getFeedback(a, USER)
+ if (method === 'POST') return server.feedback.createFeedback(body as never, USER)
+ if (method === 'PATCH') return server.feedback.updateFeedback(a, body as never, USER)
+ if (method === 'DELETE') return server.feedback.deleteFeedback(a, USER)
+ break
+ }
+ case 'finance': {
+ if (method === 'GET' && a === 'hello') return 'finance'
+ if (a === 'balances' && method === 'GET' && !b) return server.payments.listBalances(USER)
+ if (a === 'balances' && method === 'GET') return server.payments.getMemberBalance(b, USER)
+ if (a === 'transactions' && method === 'GET' && !b) return server.payments.listTransactions(USER)
+ if (a === 'transactions' && method === 'GET') return server.payments.getTransaction(b, USER)
+ if (a === 'transactions' && method === 'POST') return server.payments.createTransaction(body as never, USER)
+ if (a === 'transactions' && method === 'PATCH') return server.payments.updateTransaction(b, body as never, USER)
+ if (a === 'transactions' && method === 'DELETE') return server.payments.deleteTransaction(b, USER)
+ break
+ }
+ case 'organization': {
+ if (method === 'GET' && a === 'hello') return 'organization'
+ if (a === 'sports' && method === 'GET' && !b) return server.organization.listSports()
+ if (a === 'sports' && method === 'GET') return server.organization.getSport(b)
+ if (a === 'sports' && method === 'POST') return server.organization.createSport(body as never, USER)
+ if (a === 'sports' && method === 'PATCH') return server.organization.updateSport(b, body as never, USER)
+ if (a === 'sports' && method === 'DELETE') return server.organization.deleteSport(b, USER)
+ if (a === 'teams' && method === 'GET' && !b) return server.organization.listTeams()
+ if (a === 'teams' && method === 'GET') return server.organization.getTeam(b)
+ if (a === 'teams' && method === 'POST') return server.organization.createTeam(body as never, USER)
+ if (a === 'teams' && method === 'PATCH') return server.organization.updateTeam(b, body as never, USER)
+ if (a === 'teams' && method === 'DELETE') return server.organization.deleteTeam(b, USER)
+ break
+ }
+ case 'helper': {
+ if (method === 'GET' && a === 'hello') return 'helper'
+ // /helper/reports/member/{id}, /helper/reports/team/{id}, /helper/reports/{id}
+ if (a === 'reports') {
+ const [, , kind, id] = segments
+ if (kind === 'member' && method === 'POST') return void server.helper.generateMemberReport(id, USER)
+ if (kind === 'team' && method === 'POST') return void server.helper.generateTeamReport(id, USER)
+ if (kind === 'member' && method === 'GET') return server.helper.listMemberReports(id, USER)
+ if (kind === 'team' && method === 'GET') return server.helper.listTeamReports(id, USER)
+ if (method === 'GET') return server.helper.getReport(kind)
+ if (method === 'DELETE') return void server.helper.deleteReport(kind)
+ }
+ break
+ }
+ case 'letters': {
+ if (method === 'GET' && a === 'hello') return server.letters.lettersHello()
+ if (a === 'mail' && method === 'POST') return void server.letters.sendMail()
+ if (a === 'pdf' && method === 'POST') return server.letters.generatePdf(body as never)
+ break
+ }
+ }
+
+ // Unmapped route: fail the test loudly rather than hang.
+ throw Object.assign(new Error(`Unhandled API route: ${method} /${segments.join('/')}`), {
+ response: { status: 501, data: { message: 'Not implemented in the E2E server' } },
+ })
+}
+
+export async function stubApi(context: BrowserContext): Promise {
+ resetServer()
+ await context.route(
+ (url) => url.pathname.includes('/api/v1/'),
+ (route) => run(route, () => dispatch(parse(route))),
+ )
+}
diff --git a/web-client/e2e/support/auth.ts b/web-client/e2e/support/auth.ts
new file mode 100644
index 00000000..b0fdbeba
--- /dev/null
+++ b/web-client/e2e/support/auth.ts
@@ -0,0 +1,131 @@
+import type { BrowserContext, Route } from '@playwright/test'
+
+// Must match VITE_KEYCLOAK_URL in playwright.config.ts.
+const KEYCLOAK_URL = 'http://localhost:8081/auth'
+const REALM_URL = `${KEYCLOAK_URL}/realms/devops`
+
+// Token identity mirrors the `admin` entry in src/testing/personas.ts so the stubbed
+// Keycloak session and the in-memory server describe the same person.
+export const E2E_USER = {
+ sub: '99999999-0004-0000-78dd-0000000278dc',
+ name: 'Admin Devoops',
+ email: 'admin.devoops@club.de',
+ memberRoles: ['Admin'],
+}
+
+function base64Url(value: string): string {
+ return Buffer.from(value)
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '')
+}
+
+// keycloak-js only base64-decodes token payloads client-side (it never verifies
+// signatures), so an unsigned JWT with the right claims is enough. The nonce must
+// echo the one keycloak-js generated for the login URL or setToken discards the
+// session as a replay.
+function mintToken(nonce: string): string {
+ const now = Math.floor(Date.now() / 1000)
+ const header = { alg: 'none', typ: 'JWT' }
+ const payload = {
+ exp: now + 3600,
+ iat: now,
+ jti: 'e2e-token',
+ iss: REALM_URL,
+ aud: 'devops-client',
+ sub: E2E_USER.sub,
+ typ: 'Bearer',
+ azp: 'devops-client',
+ nonce,
+ session_state: 'e2e-session',
+ sid: 'e2e-session',
+ scope: 'openid',
+ realm_access: { roles: ['admin', 'member'] },
+ preferred_username: 'admin.devoops',
+ name: E2E_USER.name,
+ email: E2E_USER.email,
+ member_roles: E2E_USER.memberRoles,
+ }
+ return `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}.e2e-signature`
+}
+
+// Intercepts the three requests keycloak-js makes during
+// init({ onLoad: 'check-sso', silentCheckSsoRedirectUri, pkceMethod: 'S256' }):
+// 1. the hidden-iframe authorization request -> 302 back to
+// /silent-check-sso.html with the standard OIDC fragment (state + code),
+// 2. the authorization-code token exchange -> minted JWT set,
+// 3. anything else on the Keycloak origin -> 404 (nothing should reach it).
+export async function stubKeycloak(context: BrowserContext, appOrigin: string): Promise {
+ let nonce = ''
+
+ // Registered first = matched last: catch-all for the Keycloak origin.
+ await context.route(
+ (url) => url.href.startsWith(KEYCLOAK_URL),
+ (route) => route.fulfill({ status: 404, body: '' }),
+ )
+
+ // keycloak-js probes third-party-cookie support in a hidden iframe before the
+ // silent check-sso flow; it waits for a "supported"/"unsupported" postMessage
+ // from a document on the Keycloak origin.
+ await context.route(
+ (url) => url.href.startsWith(REALM_URL) && url.pathname.includes('/3p-cookies/'),
+ (route) =>
+ route.fulfill({
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ body: '',
+ }),
+ )
+
+ await context.route(
+ (url) => url.href.startsWith(REALM_URL) && url.pathname.endsWith('/protocol/openid-connect/auth'),
+ (route) => {
+ const url = new URL(route.request().url())
+ nonce = url.searchParams.get('nonce') ?? ''
+ const state = url.searchParams.get('state') ?? ''
+ const redirectUri = url.searchParams.get('redirect_uri') ?? ''
+ const fragment = new URLSearchParams({
+ state,
+ session_state: 'e2e-session',
+ iss: REALM_URL,
+ code: 'e2e-authorization-code',
+ })
+ return route.fulfill({
+ status: 302,
+ headers: { location: `${redirectUri}#${fragment.toString()}` },
+ })
+ },
+ )
+
+ await context.route(
+ (url) => url.href.startsWith(REALM_URL) && url.pathname.endsWith('/protocol/openid-connect/token'),
+ (route: Route) => {
+ const cors = {
+ 'access-control-allow-origin': appOrigin,
+ 'access-control-allow-credentials': 'true',
+ 'access-control-allow-methods': 'POST, OPTIONS',
+ 'access-control-allow-headers': 'content-type, authorization',
+ }
+ if (route.request().method() === 'OPTIONS') {
+ return route.fulfill({ status: 204, headers: cors })
+ }
+ const token = mintToken(nonce)
+ return route.fulfill({
+ status: 200,
+ headers: { 'content-type': 'application/json', ...cors },
+ body: JSON.stringify({
+ access_token: token,
+ id_token: token,
+ refresh_token: token,
+ token_type: 'Bearer',
+ expires_in: 3600,
+ refresh_expires_in: 7200,
+ 'not-before-policy': 0,
+ session_state: 'e2e-session',
+ scope: 'openid',
+ }),
+ })
+ },
+ )
+}
diff --git a/web-client/e2e/support/data.ts b/web-client/e2e/support/data.ts
new file mode 100644
index 00000000..3ccb52f7
--- /dev/null
+++ b/web-client/e2e/support/data.ts
@@ -0,0 +1,15 @@
+// Node-side re-exports of the shared test data the stub server serves, so E2E
+// assertions derive from the same fixtures rather than hard-coded strings.
+export { TEST_PERSONAS } from '../../src/testing/personas'
+export { ALL_ROLES, NAV_ITEMS } from '../../src/app/navPolicy'
+export { memberFixtures, memberSummaryFixtures } from '../../src/testing/fixtures/members'
+export { eventSummaryFixtures } from '../../src/testing/fixtures/events'
+export { feedbackSummaryFixtures } from '../../src/testing/fixtures/feedback'
+export { balanceFixtures, transactionFixtures } from '../../src/testing/fixtures/finance'
+export { sportFixtures, teamFixtures } from '../../src/testing/fixtures/organization'
+export { dashboardFixtures } from '../../src/testing/fixtures/dashboard'
+
+import { TEST_PERSONAS } from '../../src/testing/personas'
+
+// The suite runs as the admin identity (see auth.ts).
+export const ADMIN = TEST_PERSONAS.admin
diff --git a/web-client/e2e/support/fixtures.ts b/web-client/e2e/support/fixtures.ts
new file mode 100644
index 00000000..40bd9d01
--- /dev/null
+++ b/web-client/e2e/support/fixtures.ts
@@ -0,0 +1,26 @@
+import { test as base, expect, type Page } from '@playwright/test'
+import { stubKeycloak } from './auth'
+import { stubApi } from './api'
+
+// Every test gets a context whose Keycloak endpoints and /api/v1 calls are stubbed before
+// the app loads: keycloak.init() resolves as an authenticated admin session, and the app's
+// axios calls are answered by the in-memory server (see api.ts).
+export const test = base.extend({
+ // Playwright fixture: the callback's second parameter provides the value to
+ // the test (named `provide` so the react-hooks lint rule doesn't read `use`
+ // as a React hook).
+ context: async ({ context, baseURL }, provide) => {
+ await stubKeycloak(context, new URL(baseURL ?? 'http://localhost:5199').origin)
+ await stubApi(context)
+ await provide(context)
+ },
+})
+
+export { expect }
+
+// Navigates and waits until the app shell has rendered past the auth spinner. Each test
+// gets a pristine server copy (see stubApi), so its mutations never leak into the next.
+export async function gotoApp(page: Page, path = '/'): Promise {
+ await page.goto(path)
+ await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible({ timeout: 15_000 })
+}
diff --git a/web-client/e2e/support/server/dashboard.ts b/web-client/e2e/support/server/dashboard.ts
new file mode 100644
index 00000000..cbff6ebe
--- /dev/null
+++ b/web-client/e2e/support/server/dashboard.ts
@@ -0,0 +1,7 @@
+import { dashboardForUser } from '@/testing/fixtures/dashboard'
+import type { AuthUser, Dashboard } from '@/types'
+
+// In-memory dashboard resource.
+export function getDashboard(user: AuthUser): Dashboard {
+ return dashboardForUser(user)
+}
diff --git a/web-client/e2e/support/server/events.ts b/web-client/e2e/support/server/events.ts
new file mode 100644
index 00000000..46f402ff
--- /dev/null
+++ b/web-client/e2e/support/server/events.ts
@@ -0,0 +1,208 @@
+import {
+ eventDetailsById,
+ eventSummaryFixtures,
+ memberNamesById,
+ sportFixtures,
+ teamFixtures,
+} from '@/testing/fixtures'
+import { httpError } from '@/testing/httpError'
+import { scopeEvents } from '@/testing/scope'
+import type { AuthUser, EventListItem, Reference } from '@/types'
+import type { SportEvent, EventCreate, EventPartialUpdate } from '@/features/sport-events/types'
+
+// In-memory events resource; reset() restores the module-level state per test.
+
+let eventDetailsState: Record = {}
+let eventSummaryState: EventListItem[] = []
+
+export function reset(): void {
+ eventDetailsState = { ...eventDetailsById }
+ eventSummaryState = eventSummaryFixtures.map((event) => ({
+ ...event,
+ attendees: event.attendees?.map((attendee) => ({ ...attendee })),
+ sports_linked: event.sports_linked?.map((sport) => ({ ...sport })),
+ teams_linked: event.teams_linked?.map((team) => ({ ...team })),
+ }))
+}
+
+reset()
+
+function eventError(message: string): Error {
+ return new Error(message)
+}
+
+function newEventId(): string {
+ return globalThis.crypto?.randomUUID?.() ?? `eeeeeeee-eeee-4eee-8eee-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
+}
+
+function uniqueIds(ids: string[] | undefined): string[] {
+ return Array.from(new Set(ids ?? []))
+}
+
+function memberRef(id: string): Reference {
+ const name = memberNamesById[id]
+ if (!name) throw eventError('Member not found')
+ return { id, name }
+}
+
+function sportRef(id: string): Reference {
+ const sport = sportFixtures.find((item) => item.id === id)
+ if (!sport) throw eventError('Sport not found')
+ return { id: sport.id, name: sport.name }
+}
+
+function teamRef(id: string): Reference {
+ const team = teamFixtures.find((item) => item.id === id)
+ if (!team) throw eventError('Team not found')
+ return { id: team.id, name: team.name }
+}
+
+function eventSummary(event: SportEvent): EventListItem {
+ return {
+ id: event.id,
+ name: event.name,
+ start_time: event.start_time,
+ end_time: event.end_time,
+ attendees: event.attendees?.map((attendee) => ({ ...attendee })),
+ sports_linked: event.sports_linked?.map((sport) => ({ ...sport })),
+ teams_linked: event.teams_linked?.map((team) => ({ ...team })),
+ }
+}
+
+function validateEventTimes(startTime: string, endTime: string): void {
+ if (Number.isNaN(new Date(startTime).getTime()) || Number.isNaN(new Date(endTime).getTime())) {
+ throw eventError('Start and end time are required')
+ }
+
+ if (new Date(endTime) <= new Date(startTime)) {
+ throw eventError('End time must be after start time')
+ }
+}
+
+function canManageEvent(user: AuthUser, event: SportEvent): boolean {
+ return user.role === 'admin' || event.creator?.id === user.id
+}
+
+// Mirrors EventService.canCreateEvent: admins bypass; otherwise the requester must be a
+// trainer of a linked team, or a director of a linked sport (linked directly via
+// sports_linked, or derived from a linked team's sport). No links => 403, like the server.
+function canCreateMockEvent(user: AuthUser, data: EventCreate): boolean {
+ if (user.role === 'admin') return true
+
+ const teamIds = uniqueIds(data.teams_linked)
+ const linkedTeams = teamFixtures.filter((team) => teamIds.includes(team.id))
+ if (linkedTeams.some((team) => team.trainers.some((trainer) => trainer.id === user.id))) {
+ return true
+ }
+
+ const sportIds = new Set(uniqueIds(data.sports_linked))
+ for (const team of linkedTeams) sportIds.add(team.sport.id)
+ return sportFixtures.some(
+ (sport) => sportIds.has(sport.id) && sport.directors.some((director) => director.id === user.id),
+ )
+}
+
+function upsertMockEvent(event: SportEvent): void {
+ eventDetailsState[event.id] = event
+
+ const summary = eventSummary(event)
+ const index = eventSummaryState.findIndex((row) => row.id === event.id)
+
+ if (index === -1) {
+ eventSummaryState = [summary, ...eventSummaryState]
+ return
+ }
+
+ eventSummaryState = eventSummaryState.map((row) =>
+ row.id === event.id ? summary : row,
+ )
+}
+
+export function listEvents(user: AuthUser): EventListItem[] {
+ return scopeEvents(eventSummaryState, user)
+}
+
+export function getEvent(id: string, user: AuthUser): SportEvent {
+ const found = id ? eventDetailsState[id] : undefined
+ const scoped = found ? scopeEvents([found], user) : []
+ if (!scoped[0]) throw httpError(404, 'Event not found')
+ return scoped[0]
+}
+
+export function createEvent(data: EventCreate, user: AuthUser): SportEvent {
+ const name = data.name.trim()
+
+ if (!name) throw eventError('Name is required')
+ validateEventTimes(data.start_time, data.end_time)
+ if (!canCreateMockEvent(user, data)) {
+ throw httpError(403, 'Access denied')
+ }
+
+ const event: SportEvent = {
+ id: newEventId(),
+ name,
+ description: data.description?.trim() ?? '',
+ start_time: data.start_time,
+ end_time: data.end_time,
+ attendees: uniqueIds(data.attendees).map(memberRef),
+ sports_linked: uniqueIds(data.sports_linked).map(sportRef),
+ teams_linked: uniqueIds(data.teams_linked).map(teamRef),
+ creator: { id: user.id, name: user.name },
+ }
+
+ upsertMockEvent(event)
+ return event
+}
+
+export function updateEvent(
+ id: string,
+ data: EventPartialUpdate,
+ user: AuthUser,
+): SportEvent {
+ const current = eventDetailsState[id]
+
+ if (!current) throw eventError('Event not found')
+ if (!canManageEvent(user, current)) {
+ throw eventError('You are not allowed to update this event')
+ }
+
+ const name = data.name === undefined ? current.name : data.name.trim()
+ if (!name) throw eventError('Name is required')
+
+ const startTime = data.start_time ?? current.start_time
+ const endTime = data.end_time ?? current.end_time
+ validateEventTimes(startTime, endTime)
+
+ const event: SportEvent = {
+ ...current,
+ name,
+ description: data.description === undefined ? current.description : data.description.trim(),
+ start_time: startTime,
+ end_time: endTime,
+ attendees:
+ data.attendees === undefined ? current.attendees : uniqueIds(data.attendees).map(memberRef),
+ sports_linked:
+ data.sports_linked === undefined
+ ? current.sports_linked
+ : uniqueIds(data.sports_linked).map(sportRef),
+ teams_linked:
+ data.teams_linked === undefined
+ ? current.teams_linked
+ : uniqueIds(data.teams_linked).map(teamRef),
+ }
+
+ upsertMockEvent(event)
+ return event
+}
+
+export function deleteEvent(id: string, user: AuthUser): void {
+ const event = eventDetailsState[id]
+
+ if (!event) throw eventError('Event not found')
+ if (!canManageEvent(user, event)) {
+ throw eventError('You are not allowed to delete this event')
+ }
+
+ delete eventDetailsState[id]
+ eventSummaryState = eventSummaryState.filter((row) => row.id !== id)
+}
diff --git a/web-client/e2e/support/server/feedback.ts b/web-client/e2e/support/server/feedback.ts
new file mode 100644
index 00000000..e3edc0bf
--- /dev/null
+++ b/web-client/e2e/support/server/feedback.ts
@@ -0,0 +1,150 @@
+import {
+ eventDetailsById,
+ eventSummaryFixtures,
+ feedbackDetailsById,
+ feedbackSummaryFixtures,
+ memberNamesById,
+} from '@/testing/fixtures'
+import { httpError } from '@/testing/httpError'
+import { scopeFeedback, trainerManagesMember } from '@/testing/scope'
+import type { AuthUser, Reference } from '@/types'
+import type {
+ Feedback,
+ FeedbackCreate,
+ FeedbackPartialUpdate,
+ FeedbackSummary,
+} from '@/features/feedback/types'
+
+// In-memory feedback resource; reset() restores the module-level state per test.
+
+let feedbackDetailsState: Record = {}
+let feedbackSummaryState: FeedbackSummary[] = []
+
+export function reset(): void {
+ feedbackDetailsState = { ...feedbackDetailsById }
+ feedbackSummaryState = [...feedbackSummaryFixtures]
+}
+
+reset()
+
+function feedbackSummary(feedback: Feedback): FeedbackSummary {
+ const { id, event, member, creator, created_at, rating } = feedback
+ return { id, event, member, creator, created_at, rating }
+}
+
+function upsertMockFeedback(feedback: Feedback): void {
+ feedbackDetailsState[feedback.id] = feedback
+
+ const summary = feedbackSummary(feedback)
+ const index = feedbackSummaryState.findIndex((row) => row.id === feedback.id)
+
+ if (index === -1) {
+ feedbackSummaryState = [summary, ...feedbackSummaryState]
+ return
+ }
+
+ feedbackSummaryState = feedbackSummaryState.map((row) =>
+ row.id === feedback.id ? summary : row,
+ )
+}
+
+function isFeedbackRating(rating: number): boolean {
+ return Number.isInteger(rating) && rating >= 0 && rating <= 10
+}
+
+function memberRef(memberId: string): Reference | null {
+ const name = memberNamesById[memberId]
+ return name ? { id: memberId, name } : null
+}
+
+function eventRef(eventId: string): Reference | null {
+ const event = eventDetailsById[eventId] ?? eventSummaryFixtures.find((row) => row.id === eventId)
+ return event ? { id: event.id, name: event.name } : null
+}
+
+function canCreateFeedback(user: AuthUser, memberId: string): boolean {
+ return user.role === 'admin' || (user.role === 'trainer' && trainerManagesMember(user.id, memberId))
+}
+
+function newFeedbackId(): string {
+ return globalThis.crypto?.randomUUID?.() ?? `ffffffff-ffff-4fff-8fff-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
+}
+
+export function listFeedback(user: AuthUser): FeedbackSummary[] {
+ return scopeFeedback(feedbackSummaryState, user)
+}
+
+export function getFeedback(id: string, user: AuthUser): Feedback {
+ const found = feedbackDetailsState[id]
+ const scoped = found ? scopeFeedback([found], user) : []
+ if (!scoped[0]) throw httpError(404, 'Feedback not found')
+ return scoped[0]
+}
+
+export function createFeedback(data: FeedbackCreate, user: AuthUser): Feedback {
+ const event = eventRef(data.event)
+ const member = memberRef(data.member)
+
+ if (!event) throw httpError(404, 'Event not found')
+ if (!member) throw httpError(404, 'Member not found')
+ if (!data.feedback.trim()) throw httpError(400, 'Feedback is required')
+ if (!isFeedbackRating(data.rating)) {
+ throw httpError(400, 'Rating must be an integer between 0 and 10')
+ }
+ if (!canCreateFeedback(user, data.member)) {
+ throw httpError(403, 'You are not allowed to create feedback for this member')
+ }
+
+ const created: Feedback = {
+ id: newFeedbackId(),
+ event,
+ member,
+ creator: { id: user.id, name: user.name },
+ created_at: new Date().toISOString(),
+ feedback: data.feedback,
+ rating: data.rating,
+ }
+
+ upsertMockFeedback(created)
+ return created
+}
+
+export function updateFeedback(
+ id: string,
+ data: FeedbackPartialUpdate,
+ user: AuthUser,
+): Feedback {
+ const existing = feedbackDetailsState[id]
+
+ if (!existing) throw httpError(404, 'Feedback not found')
+ if (user.role !== 'admin' && existing.creator?.id !== user.id) {
+ throw httpError(403, 'You are not allowed to update this feedback')
+ }
+ if (data.feedback !== undefined && !data.feedback.trim()) {
+ throw httpError(400, 'Feedback is required')
+ }
+ if (data.rating !== undefined && !isFeedbackRating(data.rating)) {
+ throw httpError(400, 'Rating must be an integer between 0 and 10')
+ }
+
+ const updated: Feedback = {
+ ...existing,
+ ...(data.feedback !== undefined ? { feedback: data.feedback } : {}),
+ ...(data.rating !== undefined ? { rating: data.rating } : {}),
+ }
+
+ upsertMockFeedback(updated)
+ return updated
+}
+
+export function deleteFeedback(id: string, user: AuthUser): void {
+ const existing = feedbackDetailsState[id] ?? feedbackSummaryState.find((row) => row.id === id)
+
+ if (!existing) throw httpError(404, 'Feedback not found')
+ if (user.role !== 'admin' && existing.creator?.id !== user.id) {
+ throw httpError(403, 'You are not allowed to delete this feedback')
+ }
+
+ delete feedbackDetailsState[id]
+ feedbackSummaryState = feedbackSummaryState.filter((row) => row.id !== id)
+}
diff --git a/web-client/e2e/support/server/helper.ts b/web-client/e2e/support/server/helper.ts
new file mode 100644
index 00000000..bb8bc443
--- /dev/null
+++ b/web-client/e2e/support/server/helper.ts
@@ -0,0 +1,113 @@
+import {
+ memberNamesById,
+ memberReportSummariesById,
+ reportById as reportByIdFixture,
+ teamFixtures,
+ teamReportSummariesById,
+} from '@/testing/fixtures'
+import { httpError } from '@/testing/httpError'
+import { scopeReport, scopeTeamReport } from '@/testing/scope'
+import type { AuthUser, MemberReportSummary, Report, TeamReportSummary } from '@/types'
+
+// In-memory helper (reports) resource; reset() restores the module-level state per test.
+
+let reportState: Record = {}
+let memberReportSummariesState: Record = {}
+let teamReportSummariesState: Record = {}
+
+export function reset(): void {
+ reportState = { ...reportByIdFixture }
+ memberReportSummariesState = Object.fromEntries(
+ Object.entries(memberReportSummariesById).map(([memberId, rows]) => [memberId, [...rows]]),
+ )
+ teamReportSummariesState = Object.fromEntries(
+ Object.entries(teamReportSummariesById).map(([teamId, rows]) => [teamId, [...rows]]),
+ )
+}
+
+reset()
+
+function newReportId(): string {
+ return globalThis.crypto?.randomUUID?.() ?? `ffffffff-ffff-4fff-8fff-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
+}
+
+export function generateMemberReport(memberId: string, user: AuthUser): void {
+ if (!scopeReport(memberId, user)) throw httpError(403, 'Report not allowed')
+
+ const name = memberNamesById[memberId]
+ if (!name) return
+
+ const member = { id: memberId, name }
+ const report: Report = {
+ id: newReportId(),
+ kind: 'member',
+ member,
+ created_at: new Date().toISOString(),
+ text: `# Development report — ${name}\n\nReport generation in progress.`,
+ }
+
+ reportState[report.id] = report
+ memberReportSummariesState[memberId] = [
+ { id: report.id, member, created_at: report.created_at },
+ ...(memberReportSummariesState[memberId] ?? []),
+ ]
+}
+
+export function generateTeamReport(teamId: string, user: AuthUser): void {
+ if (!scopeTeamReport(teamId, user)) throw httpError(403, 'Report not allowed')
+
+ const team = teamFixtures.find((item) => item.id === teamId)
+ if (!team) return
+
+ const teamRef = { id: team.id, name: team.name }
+ const report: Report = {
+ id: newReportId(),
+ kind: 'team',
+ team: teamRef,
+ created_at: new Date().toISOString(),
+ text: `# Team report — ${team.name}\n\nReport generation in progress.`,
+ }
+
+ reportState[report.id] = report
+ teamReportSummariesState[teamId] = [
+ { id: report.id, team: teamRef, created_at: report.created_at },
+ ...(teamReportSummariesState[teamId] ?? []),
+ ]
+}
+
+export function listMemberReports(memberId: string, user: AuthUser): MemberReportSummary[] {
+ if (!scopeReport(memberId, user)) return []
+ return memberReportSummariesState[memberId] ?? []
+}
+
+export function listTeamReports(teamId: string, user: AuthUser): TeamReportSummary[] {
+ if (!scopeTeamReport(teamId, user)) return []
+ return teamReportSummariesState[teamId] ?? []
+}
+
+export function getReport(reportId: string): Report {
+ const found = reportId ? reportState[reportId] : undefined
+ if (!found) throw httpError(404, 'Report not found')
+ return found
+}
+
+export function deleteReport(reportId: string): void {
+ const report = reportState[reportId]
+ if (!report) return
+
+ delete reportState[reportId]
+
+ if (report.kind === 'member' && report.member) {
+ const memberId = report.member.id
+ memberReportSummariesState[memberId] = (memberReportSummariesState[memberId] ?? []).filter(
+ (row) => row.id !== reportId,
+ )
+ }
+
+ if (report.kind === 'team' && report.team) {
+ const teamId = report.team.id
+ teamReportSummariesState[teamId] = (teamReportSummariesState[teamId] ?? []).filter(
+ (row) => row.id !== reportId,
+ )
+ }
+}
diff --git a/web-client/e2e/support/server/letters.ts b/web-client/e2e/support/server/letters.ts
new file mode 100644
index 00000000..d40913bf
--- /dev/null
+++ b/web-client/e2e/support/server/letters.ts
@@ -0,0 +1,15 @@
+import type { GeneratePdfRequest } from '@/features/letters/types'
+
+// In-memory letters resource: acknowledges sends and echoes a PDF blob from the template.
+
+export function lettersHello(): string {
+ return 'letters'
+}
+
+export function sendMail(): void {
+ // no-op: accepts any send
+}
+
+export function generatePdf(data: GeneratePdfRequest): Blob {
+ return new Blob([data.template], { type: 'application/pdf' })
+}
diff --git a/web-client/e2e/support/server/members.ts b/web-client/e2e/support/server/members.ts
new file mode 100644
index 00000000..5a43b456
--- /dev/null
+++ b/web-client/e2e/support/server/members.ts
@@ -0,0 +1,136 @@
+import {
+ memberFixtures as memberFixturesSource,
+ memberSummaryFixtures as memberSummaryFixturesSource,
+} from '@/testing/fixtures'
+import { httpError } from '@/testing/httpError'
+import { scopeMembers } from '@/testing/scope'
+import type { AuthUser, Member, MemberCreate, MemberPartialUpdate, MemberSummary } from '@/types'
+
+// In-memory members resource. State is a deep clone of the fixtures; reset() restores it per test.
+let memberFixtures: Member[] = []
+let memberSummaryFixtures: MemberSummary[] = []
+
+export function reset(): void {
+ memberFixtures = memberFixturesSource.map((m) => ({ ...m }))
+ memberSummaryFixtures = memberSummaryFixturesSource.map((m) => ({ ...m }))
+}
+
+reset()
+
+export function listMembers(user: AuthUser): MemberSummary[] {
+ return scopeMembers(memberSummaryFixtures, user)
+}
+
+export function getMember(id: string, user: AuthUser): Member {
+ const found = memberFixtures.find((m) => m.id === id)
+ const scoped = found ? scopeMembers([found], user) : []
+ if (!scoped[0]) throw httpError(404, 'Member not found')
+ return scoped[0]
+}
+
+export function createMember(data: MemberCreate, user: AuthUser): Member {
+ if (user.role !== 'admin') {
+ throw httpError(403, 'Only admins can create members.')
+ }
+
+ const fieldErrors: { message: string }[] = []
+ if (!data.first_name.trim()) fieldErrors.push({ message: 'first_name: must not be blank' })
+ if (!data.last_name.trim()) fieldErrors.push({ message: 'last_name: must not be blank' })
+ if (!data.password) fieldErrors.push({ message: 'password: must not be blank' })
+ if (fieldErrors.length > 0) {
+ throw httpError(400, 'Validation failed', fieldErrors)
+ }
+
+ const email = data.email.trim()
+ assertUniqueMemberEmail(email)
+
+ const member: Member = {
+ id: newMemberId(),
+ first_name: data.first_name.trim(),
+ last_name: data.last_name.trim(),
+ email,
+ birthday: data.birthday ?? '',
+ phone_number: data.phone_number ?? '',
+ address: data.address ?? '',
+ joining_date: new Date().toISOString().slice(0, 10),
+ information: data.information ?? '',
+ }
+
+ memberFixtures.unshift(member)
+ memberSummaryFixtures.unshift({
+ id: member.id,
+ first_name: member.first_name,
+ last_name: member.last_name,
+ email: member.email,
+ })
+ return { ...member }
+}
+
+export function updateMember(
+ id: string,
+ data: MemberPartialUpdate,
+ user: AuthUser,
+): Member {
+ const index = memberFixtures.findIndex((member) => member.id === id)
+ const member = memberFixtures[index]
+
+ if (!member) throw httpError(404, `Member not found: ${id}`)
+ if (user.role !== 'admin' && user.id !== id) {
+ throw httpError(403, 'Access denied')
+ }
+ if (data.email !== undefined) {
+ const email = data.email.trim()
+ if (!email) throw httpError(400, 'Email is required.')
+ if (email !== member.email) assertUniqueMemberEmail(email, id)
+ }
+
+ const updated: Member = {
+ ...member,
+ first_name: data.first_name !== undefined ? data.first_name.trim() : member.first_name,
+ last_name: data.last_name !== undefined ? data.last_name.trim() : member.last_name,
+ email: data.email !== undefined ? data.email.trim() : member.email,
+ birthday: data.birthday !== undefined ? data.birthday : member.birthday,
+ phone_number: data.phone_number !== undefined ? data.phone_number : member.phone_number,
+ address: data.address !== undefined ? data.address : member.address,
+ information: data.information !== undefined ? data.information : member.information,
+ }
+
+ memberFixtures[index] = updated
+ const summaryIndex = memberSummaryFixtures.findIndex((summary) => summary.id === id)
+ if (summaryIndex !== -1) {
+ memberSummaryFixtures[summaryIndex] = {
+ id: updated.id,
+ first_name: updated.first_name,
+ last_name: updated.last_name,
+ email: updated.email,
+ }
+ }
+ return { ...updated }
+}
+
+export function deleteMember(id: string, user: AuthUser): void {
+ const index = memberFixtures.findIndex((member) => member.id === id)
+
+ if (user.role !== 'admin') {
+ throw httpError(403, 'Only admins can delete members.')
+ }
+ if (index === -1) throw httpError(404, `Member not found: ${id}`)
+
+ memberFixtures.splice(index, 1)
+ const summaryIndex = memberSummaryFixtures.findIndex((summary) => summary.id === id)
+ if (summaryIndex !== -1) memberSummaryFixtures.splice(summaryIndex, 1)
+}
+
+function assertUniqueMemberEmail(email: string, excludeId?: string): void {
+ const duplicate = memberFixtures.some(
+ (member) => member.id !== excludeId && member.email.toLowerCase() === email.toLowerCase(),
+ )
+ if (duplicate) throw httpError(409, `Email already in use: ${email}`)
+}
+
+function newMemberId(): string {
+ return (
+ globalThis.crypto?.randomUUID?.() ??
+ `dddddddd-dddd-4ddd-8ddd-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
+ )
+}
diff --git a/web-client/e2e/support/server/organization.ts b/web-client/e2e/support/server/organization.ts
new file mode 100644
index 00000000..11ea301a
--- /dev/null
+++ b/web-client/e2e/support/server/organization.ts
@@ -0,0 +1,313 @@
+import {
+ memberNamesById,
+ sportFixtures as sportFixturesSource,
+ teamFixtures as teamFixturesSource,
+} from '@/testing/fixtures'
+import { httpError } from '@/testing/httpError'
+import { isTeamCoach, type AuthUser, type MemberRef } from '@/types'
+import type {
+ Sport,
+ SportCreate,
+ SportPartialUpdate,
+ Team,
+ TeamCreate,
+ TeamPartialUpdate,
+} from '@/features/organization/types'
+
+// In-memory organization resource. State is a deep clone of the fixtures; reset() restores it per test.
+let sportFixtures: Sport[] = []
+let teamFixtures: Team[] = []
+let sportsById: Record = {}
+const pendingTeamUpdates = new Set()
+
+export function reset(): void {
+ sportFixtures = sportFixturesSource.map(cloneSport)
+ teamFixtures = teamFixturesSource.map(cloneTeam)
+ sportsById = Object.fromEntries(sportFixtures.map((sport) => [sport.id, sport]))
+ pendingTeamUpdates.clear()
+}
+
+reset()
+
+export function listSports(): Sport[] {
+ return sportFixtures.map(cloneSport)
+}
+
+export function getSport(id: string): Sport {
+ const found = sportsById[id]
+ if (!found) throw httpError(404, 'Sport not found')
+ return cloneSport(found)
+}
+
+export function listTeams(): Team[] {
+ return teamFixtures.map(cloneTeam)
+}
+
+export function getTeam(id: string): Team {
+ const found = teamFixtures.find((t) => t.id === id)
+ if (!found) throw httpError(404, 'Team not found')
+ return cloneTeam(found)
+}
+
+export function createSport(data: SportCreate, user: AuthUser): Sport {
+ const name = data.name.trim()
+
+ if (user.role !== 'admin') {
+ throw httpError(403, 'Only admins can create sports.')
+ }
+ if (!name) throw httpError(400, 'Name is required.')
+ assertUniqueSportName(name)
+
+ const sport: Sport = {
+ id: newSportId(),
+ name,
+ description: data.description?.trim() ?? '',
+ created_at: new Date().toISOString().slice(0, 10),
+ directors: memberRefsFromIds(data.directors ?? [], (id) => `Member not found: ${id}`),
+ }
+
+ sportFixtures.unshift(sport)
+ sportsById[sport.id] = sport
+ return cloneSport(sport)
+}
+
+export function updateSport(
+ id: string,
+ data: SportPartialUpdate,
+ user: AuthUser,
+): Sport {
+ const index = sportFixtures.findIndex((sport) => sport.id === id)
+ const sport = sportFixtures[index]
+
+ if (!sport) throw httpError(404, `Sport not found: ${id}`)
+ if (!canUpdateSport(sport, user)) {
+ throw httpError(403, 'Access denied')
+ }
+
+ if (data.name !== undefined) {
+ const name = data.name.trim()
+ if (!name) throw httpError(400, 'Name is required.')
+ if (name !== sport.name) assertUniqueSportName(name, id)
+ }
+
+ const updated: Sport = {
+ ...sport,
+ name: data.name !== undefined ? data.name.trim() : sport.name,
+ description: data.description !== undefined ? data.description : sport.description,
+ directors:
+ user.role === 'admin' && data.directors !== undefined
+ ? memberRefsFromIds(data.directors, (memberId) => `Member not found: ${memberId}`)
+ : sport.directors,
+ }
+
+ sportFixtures[index] = updated
+ sportsById[id] = updated
+ syncTeamSportNames(updated)
+ return cloneSport(updated)
+}
+
+export function deleteSport(id: string, user: AuthUser): void {
+ const index = sportFixtures.findIndex((sport) => sport.id === id)
+
+ if (user.role !== 'admin') {
+ throw httpError(403, 'Only admins can delete sports.')
+ }
+ if (index === -1) throw httpError(404, `Sport not found: ${id}`)
+
+ sportFixtures.splice(index, 1)
+ delete sportsById[id]
+
+ for (let teamIndex = teamFixtures.length - 1; teamIndex >= 0; teamIndex -= 1) {
+ if (teamFixtures[teamIndex].sport.id === id) {
+ teamFixtures.splice(teamIndex, 1)
+ }
+ }
+}
+
+export function createTeam(data: TeamCreate, user: AuthUser): Team {
+ const name = data.name.trim()
+
+ if (!name) throw httpError(400, 'Name is required.')
+ const sport = sportsById[data.sport]
+ if (!sport) throw httpError(400, 'Sport not found.')
+ if (!canCreateTeam(data.sport, user)) {
+ throw httpError(403, 'You are not allowed to create a team for this sport.')
+ }
+
+ const team: Team = {
+ id: newTeamId(),
+ name,
+ description: data.description?.trim() ?? '',
+ address: data.address?.trim() ?? '',
+ created_at: new Date().toISOString().slice(0, 10),
+ sport: { id: sport.id, name: sport.name },
+ trainers: memberRefsFromIds(data.trainers ?? []),
+ trainees: memberRefsFromIds(data.trainees ?? []),
+ }
+
+ teamFixtures.unshift(team)
+ return cloneTeam(team)
+}
+
+export function updateTeam(
+ id: string,
+ data: TeamPartialUpdate,
+ user: AuthUser,
+): Promise {
+ if (pendingTeamUpdates.has(id)) {
+ throw httpError(409, 'A team update is already in progress.')
+ }
+
+ pendingTeamUpdates.add(id)
+
+ try {
+ const index = teamFixtures.findIndex((team) => team.id === id)
+ const team = teamFixtures[index]
+
+ if (!team) throw httpError(404, 'Team not found')
+ if (!canUpdateTeam(team, user)) {
+ throw httpError(403, 'You are not allowed to update this team.')
+ }
+ if (data.name !== undefined && data.name.trim() === '') {
+ throw httpError(400, 'Name is required.')
+ }
+ if (data.sport !== undefined && user.role !== 'admin') {
+ throw httpError(403, 'Only admins can change a team sport.')
+ }
+ if (data.trainers !== undefined && user.role !== 'admin' && user.role !== 'director') {
+ throw httpError(403, 'Only admins and directors can change team coaches.')
+ }
+
+ const nextSport = data.sport !== undefined ? sportsById[data.sport] : undefined
+ if (data.sport !== undefined && !nextSport) {
+ throw httpError(400, 'Sport not found.')
+ }
+
+ const updated: Team = {
+ ...team,
+ name: data.name ?? team.name,
+ description: data.description ?? team.description,
+ address: data.address ?? team.address,
+ sport: nextSport ? { id: nextSport.id, name: nextSport.name } : team.sport,
+ trainers: data.trainers !== undefined ? memberRefsFromIds(data.trainers) : team.trainers,
+ trainees: data.trainees !== undefined ? memberRefsFromIds(data.trainees) : team.trainees,
+ }
+
+ teamFixtures[index] = updated
+ return Promise.resolve(cloneTeam(updated)).finally(() => {
+ pendingTeamUpdates.delete(id)
+ })
+ } catch (error) {
+ pendingTeamUpdates.delete(id)
+ throw error
+ }
+}
+
+export function deleteTeam(id: string, user: AuthUser): void {
+ const index = teamFixtures.findIndex((team) => team.id === id)
+ const team = teamFixtures[index]
+
+ if (!team) throw httpError(404, 'Team not found')
+ if (!canDeleteTeam(team, user)) {
+ throw httpError(403, 'You are not allowed to delete this team.')
+ }
+
+ teamFixtures.splice(index, 1)
+}
+
+function canCreateTeam(sportId: string, user: AuthUser): boolean {
+ if (user.role === 'admin') return true
+ if (user.role !== 'director') return false
+
+ return isDirectorForSport(sportId, user.id)
+}
+
+function canUpdateTeam(team: Team, user: AuthUser): boolean {
+ switch (user.role) {
+ case 'admin':
+ return true
+ case 'director':
+ return isDirectorForSport(team.sport.id, user.id)
+ case 'trainer':
+ return isTeamCoach(team, user.id)
+ case 'member':
+ return false
+ }
+}
+
+function canDeleteTeam(team: Team, user: AuthUser): boolean {
+ if (user.role === 'admin') return true
+ if (user.role === 'director') return isDirectorForSport(team.sport.id, user.id)
+ return false
+}
+
+function canUpdateSport(sport: Sport, user: AuthUser): boolean {
+ if (user.role === 'admin') return true
+ if (user.role === 'director') {
+ return sport.directors.some((director) => director.id === user.id)
+ }
+
+ return false
+}
+
+function isDirectorForSport(sportId: string, userId: string): boolean {
+ return (
+ sportFixtures
+ .find((sport) => sport.id === sportId)
+ ?.directors.some((director) => director.id === userId) ?? false
+ )
+}
+
+function assertUniqueSportName(name: string, excludeId?: string): void {
+ const duplicate = sportFixtures.some((sport) => sport.id !== excludeId && sport.name === name)
+ if (duplicate) throw httpError(409, `Sport already exists: ${name}`)
+}
+
+function memberRefsFromIds(
+ ids: string[],
+ missingMessage: (id: string) => string = () => 'Member not found.',
+): MemberRef[] {
+ return ids.map((id) => {
+ const name = memberNamesById[id]
+ if (!name) throw httpError(400, missingMessage(id))
+ return { id, name }
+ })
+}
+
+function syncTeamSportNames(sport: Sport): void {
+ for (const team of teamFixtures) {
+ if (team.sport.id === sport.id) {
+ team.sport = { id: sport.id, name: sport.name }
+ }
+ }
+}
+
+function newSportId(): string {
+ return (
+ globalThis.crypto?.randomUUID?.() ??
+ `cccccccc-cccc-4ccc-8ccc-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
+ )
+}
+
+function newTeamId(): string {
+ return (
+ globalThis.crypto?.randomUUID?.() ??
+ `bbbbbbbb-bbbb-4bbb-8bbb-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
+ )
+}
+
+function cloneSport(sport: Sport): Sport {
+ return {
+ ...sport,
+ directors: sport.directors.map((director) => ({ ...director })),
+ }
+}
+
+function cloneTeam(team: Team): Team {
+ return {
+ ...team,
+ sport: { ...team.sport },
+ trainers: team.trainers.map((trainer) => ({ ...trainer })),
+ trainees: team.trainees.map((trainee) => ({ ...trainee })),
+ }
+}
diff --git a/web-client/e2e/support/server/payments.ts b/web-client/e2e/support/server/payments.ts
new file mode 100644
index 00000000..ca6db791
--- /dev/null
+++ b/web-client/e2e/support/server/payments.ts
@@ -0,0 +1,178 @@
+import {
+ balanceFixtures as balanceFixturesSource,
+ memberNamesById,
+ transactionFixtures as transactionFixturesSource,
+} from '@/testing/fixtures'
+import { httpError } from '@/testing/httpError'
+import {
+ directorManagesMember,
+ scopeBalances,
+ scopeTransactions,
+ trainerManagesMember,
+} from '@/testing/scope'
+import type { AuthUser, Reference } from '@/types'
+import type {
+ Balance,
+ Transaction,
+ TransactionCreate,
+ TransactionPartialUpdate,
+} from '@/features/payments/types'
+
+// In-memory payments resource. State is a deep clone of the fixtures; reset() restores it per test.
+let transactionFixtures: Transaction[] = []
+let balanceFixtures: Balance[] = []
+
+export function reset(): void {
+ transactionFixtures = transactionFixturesSource.map(cloneTransaction)
+ balanceFixtures = balanceFixturesSource.map((b) => ({ ...b, member: { ...b.member } }))
+}
+
+reset()
+
+export function listBalances(user: AuthUser): Balance[] {
+ if (user.role === 'member') throw httpError(403, 'Access denied')
+ return scopeBalances(balanceFixtures, user)
+}
+
+export function getMemberBalance(memberId: string, user: AuthUser): Balance {
+ const found = balanceFixtures.find((balance) => balance.member.id === memberId)
+ const scoped = found ? scopeBalances([found], user) : []
+ if (!scoped[0]) throw httpError(404, 'Balance not found')
+ return scoped[0]
+}
+
+export function listTransactions(user: AuthUser): Transaction[] {
+ return scopeTransactions(transactionFixtures, user)
+}
+
+export function getTransaction(id: string, user: AuthUser): Transaction {
+ const found = transactionFixtures.find((transaction) => transaction.id === id)
+ const scoped = found ? scopeTransactions([found], user) : []
+ if (!scoped[0]) throw httpError(404, 'Transaction not found')
+ return scoped[0]
+}
+
+export function createTransaction(data: TransactionCreate, user: AuthUser): Transaction {
+ const member = memberRef(data.member)
+ const title = data.title.trim()
+
+ if (!title) throw httpError(400, 'Title is required.')
+ if (!canCreateTransactionForMember(user, data.member)) {
+ throw httpError(403, 'You are not allowed to create transactions for this member.')
+ }
+
+ const transaction: Transaction = {
+ id: newTransactionId(),
+ member,
+ creator: { id: user.id, name: user.name },
+ amount_cents: data.amount_cents,
+ created_at: new Date().toISOString(),
+ title,
+ description: data.description?.trim() ?? '',
+ }
+
+ transactionFixtures.unshift(transaction)
+ syncMockBalance(member)
+ return cloneTransaction(transaction)
+}
+
+export function updateTransaction(
+ id: string,
+ data: TransactionPartialUpdate,
+ user: AuthUser,
+): Transaction {
+ const index = transactionFixtures.findIndex((transaction) => transaction.id === id)
+ const existing = transactionFixtures[index]
+
+ if (!existing) throw httpError(404, 'Transaction not found.')
+ // Same gate delete uses: only the creator or an admin may modify a transaction.
+ if (user.role !== 'admin' && existing.creator?.id !== user.id) {
+ throw httpError(403, 'You are not allowed to update this transaction.')
+ }
+
+ // Resolve a new member only if the caller is moving the transaction; keep the ref otherwise.
+ const nextMember = data.member !== undefined ? memberRef(data.member) : existing.member
+
+ const title = data.title !== undefined ? data.title.trim() : existing.title
+ if (!title) throw httpError(400, 'Title is required.')
+
+ const updated: Transaction = {
+ ...existing,
+ member: nextMember,
+ amount_cents: data.amount_cents !== undefined ? data.amount_cents : existing.amount_cents,
+ title,
+ description: data.description !== undefined ? data.description.trim() : existing.description,
+ }
+
+ transactionFixtures[index] = updated
+
+ // Re-sync the affected member balance(s). If the member changed, both the old and the new
+ // member's balances must be recomputed.
+ if (existing.member.id !== updated.member.id) {
+ syncMockBalance(existing.member)
+ }
+ syncMockBalance(updated.member)
+
+ return cloneTransaction(updated)
+}
+
+export function deleteTransaction(id: string, user: AuthUser): void {
+ const index = transactionFixtures.findIndex((transaction) => transaction.id === id)
+ const transaction = transactionFixtures[index]
+
+ if (!transaction) throw httpError(404, 'Transaction not found.')
+ if (user.role !== 'admin' && transaction.creator?.id !== user.id) {
+ throw httpError(403, 'You are not allowed to delete this transaction.')
+ }
+
+ transactionFixtures.splice(index, 1)
+ syncMockBalance(transaction.member)
+}
+
+function memberRef(memberId: string): Reference {
+ const name = memberNamesById[memberId]
+ if (!name) throw httpError(404, 'Member not found.')
+ return { id: memberId, name }
+}
+
+function canCreateTransactionForMember(user: AuthUser, memberId: string): boolean {
+ if (user.role === 'admin') return true
+ if (user.role === 'director') return directorManagesMember(user.id, memberId)
+ if (user.role === 'trainer') return trainerManagesMember(user.id, memberId)
+ return false
+}
+
+function syncMockBalance(member: Reference): void {
+ const balanceCents = transactionFixtures.reduce(
+ (sum, transaction) =>
+ transaction.member.id === member.id ? sum + transaction.amount_cents : sum,
+ 0,
+ )
+ const index = balanceFixtures.findIndex((balance) => balance.member.id === member.id)
+ const balance: Balance = {
+ member: { ...member },
+ balance_cents: balanceCents,
+ }
+
+ if (index === -1) {
+ balanceFixtures.push(balance)
+ return
+ }
+
+ balanceFixtures[index] = balance
+}
+
+function newTransactionId(): string {
+ return (
+ globalThis.crypto?.randomUUID?.() ??
+ `cccccccc-cccc-4ccc-8ccc-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
+ )
+}
+
+function cloneTransaction(transaction: Transaction): Transaction {
+ return {
+ ...transaction,
+ member: { ...transaction.member },
+ creator: transaction.creator ? { ...transaction.creator } : null,
+ }
+}
diff --git a/web-client/package.json b/web-client/package.json
index 2f4782e0..32955900 100644
--- a/web-client/package.json
+++ b/web-client/package.json
@@ -17,7 +17,9 @@
"preview": "vite preview",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "e2e": "playwright test",
+ "e2e:ui": "playwright test --ui"
},
"dependencies": {
"@fontsource/bebas-neue": "^5.2.7",
@@ -50,6 +52,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.61.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
diff --git a/web-client/playwright.config.ts b/web-client/playwright.config.ts
new file mode 100644
index 00000000..46aabee6
--- /dev/null
+++ b/web-client/playwright.config.ts
@@ -0,0 +1,33 @@
+import { defineConfig, devices } from '@playwright/test'
+
+// E2E runs against the Vite dev server. The app's /api/v1/* calls are intercepted per test
+// context by e2e/support/api.ts and served in-memory, so no real services are needed.
+// Keycloak is not needed either — e2e/support/auth.ts intercepts the OIDC endpoints so
+// keycloak.init() resolves as an authenticated session. The app runs as ONE identity, the
+// seeded admin (see e2e/README.md for why).
+const PORT = 5199
+
+export default defineConfig({
+ testDir: './e2e',
+ outputDir: './test-results',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
+ timeout: 30_000,
+ expect: { timeout: 10_000 },
+ use: {
+ baseURL: `http://localhost:${PORT}`,
+ trace: 'on-first-retry',
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+ webServer: {
+ command: `pnpm exec vite --port ${PORT} --strictPort`,
+ url: `http://localhost:${PORT}`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ env: {
+ VITE_KEYCLOAK_URL: 'http://localhost:8081/auth',
+ },
+ },
+})
diff --git a/web-client/pnpm-lock.yaml b/web-client/pnpm-lock.yaml
index 7d916df4..3aee697c 100644
--- a/web-client/pnpm-lock.yaml
+++ b/web-client/pnpm-lock.yaml
@@ -93,6 +93,9 @@ importers:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
+ '@playwright/test':
+ specifier: ^1.61.1
+ version: 1.61.1
'@types/node':
specifier: ^24.12.3
version: 24.12.4
@@ -738,6 +741,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@playwright/test@1.61.1':
+ resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -2636,6 +2644,11 @@ packages:
resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
engines: {node: '>=14.14'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3342,6 +3355,16 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
+ playwright-core@1.61.1:
+ resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.61.1:
+ resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -4733,6 +4756,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@playwright/test@1.61.1':
+ dependencies:
+ playwright: 1.61.1
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -6615,6 +6642,9 @@ snapshots:
jsonfile: 6.2.1
universalify: 2.0.1
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -7244,6 +7274,14 @@ snapshots:
pkce-challenge@5.0.1: {}
+ playwright-core@1.61.1: {}
+
+ playwright@1.61.1:
+ dependencies:
+ playwright-core: 1.61.1
+ optionalDependencies:
+ fsevents: 2.3.2
+
pluralize@8.0.0: {}
postcss-selector-parser@7.1.1:
diff --git a/web-client/src/__tests__/DashboardPage.test.tsx b/web-client/src/__tests__/DashboardPage.test.tsx
index f11622f3..38efeedd 100644
--- a/web-client/src/__tests__/DashboardPage.test.tsx
+++ b/web-client/src/__tests__/DashboardPage.test.tsx
@@ -15,15 +15,15 @@ const organizationQueryMocks = vi.hoisted(() => ({
}))
vi.mock('@/features/auth', async () => {
- const { MOCK_PERSONAS } = await import('@/mocks/personas')
+ const { TEST_PERSONAS } = await import('@/testing/personas')
return {
- useAuth: () => ({ user: MOCK_PERSONAS[mockState.persona] }),
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
}
})
vi.mock('@/app/pages/api/dashboardQueries', async () => {
- const { dashboardFixtures } = await import('@/mocks/fixtures/dashboard')
+ const { dashboardFixtures } = await import('@/testing/fixtures/dashboard')
return {
useDashboard: () => ({
@@ -35,13 +35,13 @@ vi.mock('@/app/pages/api/dashboardQueries', async () => {
})
vi.mock('@/features/sport-events/api/queries', async () => {
- const { eventSummaryFixtures } = await import('@/mocks/fixtures')
- const { MOCK_PERSONAS } = await import('@/mocks/personas')
- const { scopeEvents } = await import('@/mocks/scope')
+ const { eventSummaryFixtures } = await import('@/testing/fixtures')
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+ const { scopeEvents } = await import('@/testing/scope')
return {
useEventsList: () => ({
- data: scopeEvents(eventSummaryFixtures, MOCK_PERSONAS[mockState.persona]),
+ data: scopeEvents(eventSummaryFixtures, TEST_PERSONAS[mockState.persona]),
isLoading: false,
error: null,
}),
@@ -49,7 +49,7 @@ vi.mock('@/features/sport-events/api/queries', async () => {
})
vi.mock('@/features/organization/api/queries', async () => {
- const { sportFixtures, teamFixtures } = await import('@/mocks/fixtures/organization')
+ const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
organizationQueryMocks.useSportsList.mockImplementation((enabled = true) => ({
data: enabled ? sportFixtures : undefined,
@@ -69,8 +69,8 @@ vi.mock('@/features/organization/api/queries', async () => {
})
const { DashboardPage } = await import('@/app/pages/DashboardPage')
-const { dashboardFixtures } = await import('@/mocks/fixtures/dashboard')
-const { sportFixtures, teamFixtures } = await import('@/mocks/fixtures/organization')
+const { dashboardFixtures } = await import('@/testing/fixtures/dashboard')
+const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
const { formatCents } = await import('@/lib/format')
describe('DashboardPage', () => {
diff --git a/web-client/src/app/layout/AppShell.test.tsx b/web-client/src/app/layout/AppShell.test.tsx
new file mode 100644
index 00000000..0c67c59e
--- /dev/null
+++ b/web-client/src/app/layout/AppShell.test.tsx
@@ -0,0 +1,117 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'member' as PersonaKey,
+ logout: vi.fn(),
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona], logout: mockState.logout }),
+ }
+})
+
+vi.mock('@tanstack/react-query-devtools', () => ({
+ ReactQueryDevtools: () => null,
+}))
+
+const { AppShell } = await import('@/app/layout/AppShell')
+const { ThemeProvider } = await import('@/app/theme/ThemeProvider')
+const { NAV_ITEMS } = await import('@/app/navPolicy')
+const { TEST_PERSONAS } = await import('@/testing/personas')
+
+describe('AppShell', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ )
+ localStorage.clear()
+ mockState.persona = 'member'
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ vi.unstubAllGlobals()
+ })
+
+ async function render() {
+ await act(async () => {
+ root.render(
+
+
+
+
+ ,
+ )
+ })
+ }
+
+ function sidebarNavLabels(): string[] {
+ return Array.from(container.querySelectorAll('a[data-sidebar="menu-button"]')).map(
+ (link) => link.textContent?.trim() ?? '',
+ )
+ }
+
+ const personas: PersonaKey[] = ['member', 'coach', 'director', 'admin']
+
+ for (const persona of personas) {
+ it(`shows exactly the ${persona} persona's allowed nav items`, async () => {
+ mockState.persona = persona
+ const user = TEST_PERSONAS[persona]
+ const expected = NAV_ITEMS.filter((item) => item.roles.includes(user.role)).map(
+ (item) => item.label,
+ )
+
+ await render()
+
+ expect(sidebarNavLabels()).toEqual(expected)
+ })
+ }
+
+ it('shows the signed-in user name and email in the sidebar footer', async () => {
+ mockState.persona = 'coach'
+ const user = TEST_PERSONAS.coach
+
+ await render()
+
+ expect(container.textContent).toContain(user.name)
+ expect(container.textContent).toContain(user.email)
+ })
+
+ it('hides feedback and development from the director persona', async () => {
+ mockState.persona = 'director'
+
+ await render()
+
+ const labels = sidebarNavLabels()
+ expect(labels).not.toContain('Feedback')
+ expect(labels).not.toContain('Development')
+ expect(labels).toContain('Payments')
+ expect(labels).toContain('Members')
+ })
+})
diff --git a/web-client/src/app/pages/api/dashboardQueries.ts b/web-client/src/app/pages/api/dashboardQueries.ts
index 11e2540f..902adfae 100644
--- a/web-client/src/app/pages/api/dashboardQueries.ts
+++ b/web-client/src/app/pages/api/dashboardQueries.ts
@@ -1,9 +1,6 @@
import { useQuery } from '@tanstack/react-query'
-import { getCurrentUser } from '@/features/auth/currentUser'
import { membersClient } from '@/features/members/api/client'
-import { dashboardForUser } from '@/mocks/fixtures/dashboard'
-import { mockOr } from '@/mocks/mockSwitch'
import type { Dashboard } from '@/types'
export const dashboardKeys = {
@@ -15,10 +12,6 @@ export function useDashboard(enabled = true) {
queryKey: dashboardKeys.me,
staleTime: 30_000,
enabled,
- queryFn: () =>
- mockOr(
- () => Promise.resolve(dashboardForUser(getCurrentUser())),
- () => membersClient.get('/dashboard').then((r) => r.data),
- ),
+ queryFn: () => membersClient.get('/dashboard').then((r) => r.data),
})
}
diff --git a/web-client/src/app/router/RouteRoleGuard.test.tsx b/web-client/src/app/router/RouteRoleGuard.test.tsx
new file mode 100644
index 00000000..9c7c3e20
--- /dev/null
+++ b/web-client/src/app/router/RouteRoleGuard.test.tsx
@@ -0,0 +1,89 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { Role } from '@/types'
+
+const mockState = vi.hoisted(() => ({
+ role: 'member' as Role,
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+ const personaByRole = {
+ member: 'member',
+ trainer: 'coach',
+ director: 'director',
+ admin: 'admin',
+ } as const
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[personaByRole[mockState.role]] }),
+ }
+})
+
+const { RouteRoleGuard } = await import('@/app/router/RouteRoleGuard')
+const { ROUTE_ROLES } = await import('@/app/navPolicy')
+
+describe('RouteRoleGuard', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ mockState.role = 'member'
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function render(allow: Role[]) {
+ await act(async () => {
+ root.render(
+
+ guarded content
+ ,
+ )
+ })
+ }
+
+ it('renders children when the role is allowed', async () => {
+ mockState.role = 'trainer'
+ await render(['trainer', 'admin'])
+
+ expect(container.textContent).toContain('guarded content')
+ expect(container.textContent).not.toContain("isn't available for your role")
+ })
+
+ it('renders the not-available card when the role is denied', async () => {
+ mockState.role = 'director'
+ await render(ROUTE_ROLES['/feedback'])
+
+ expect(container.textContent).toContain("This page isn't available for your role.")
+ expect(container.textContent).toContain('Contact your organization admin')
+ expect(container.textContent).not.toContain('guarded content')
+ })
+
+ it('follows the navPolicy table for every role and route', async () => {
+ const roles: Role[] = ['member', 'trainer', 'director', 'admin']
+
+ for (const [route, allow] of Object.entries(ROUTE_ROLES)) {
+ for (const role of roles) {
+ mockState.role = role
+ await render(allow)
+
+ const shouldAllow = allow.includes(role)
+ expect(
+ container.textContent?.includes('guarded content'),
+ `${role} on ${route} should be ${shouldAllow ? 'allowed' : 'denied'}`,
+ ).toBe(shouldAllow)
+ }
+ }
+ })
+})
diff --git a/web-client/src/app/theme/ThemeProvider.test.tsx b/web-client/src/app/theme/ThemeProvider.test.tsx
new file mode 100644
index 00000000..573827f3
--- /dev/null
+++ b/web-client/src/app/theme/ThemeProvider.test.tsx
@@ -0,0 +1,109 @@
+import { act, useContext } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { ThemeProvider } from './ThemeProvider'
+import { ThemeContext, type Theme } from './ThemeContext'
+
+function stubMatchMedia(prefersDark: boolean) {
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation((query: string) => ({
+ matches: prefersDark,
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+ )
+}
+
+function ThemeProbe() {
+ const context = useContext(ThemeContext)
+ if (!context) return null
+
+ return (
+
+ {context.theme}
+ {(['light', 'dark', 'system'] as Theme[]).map((theme) => (
+
+ ))}
+
+ )
+}
+
+describe('ThemeProvider', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ stubMatchMedia(false)
+ localStorage.clear()
+ document.documentElement.classList.remove('dark')
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ vi.unstubAllGlobals()
+ })
+
+ async function render() {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ )
+ })
+ }
+
+ async function setTheme(theme: Theme) {
+ const button = Array.from(container.querySelectorAll('button')).find(
+ (candidate) => candidate.textContent === `set-${theme}`,
+ )
+ expect(button).toBeDefined()
+ await act(async () => {
+ button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ }
+
+ it('defaults to system and applies the light OS preference', async () => {
+ await render()
+
+ expect(container.querySelector('[data-testid="theme"]')?.textContent).toBe('system')
+ expect(document.documentElement.classList.contains('dark')).toBe(false)
+ })
+
+ it('setting dark flips the html class and persists to localStorage', async () => {
+ await render()
+ await setTheme('dark')
+
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
+ expect(localStorage.getItem('ui-theme')).toBe('dark')
+ })
+
+ it('restores the stored theme on mount', async () => {
+ localStorage.setItem('ui-theme', 'dark')
+ await render()
+
+ expect(container.querySelector('[data-testid="theme"]')?.textContent).toBe('dark')
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
+ })
+
+ it('system theme follows a dark OS preference', async () => {
+ stubMatchMedia(true)
+ await render()
+
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
+ await setTheme('light')
+ expect(document.documentElement.classList.contains('dark')).toBe(false)
+ expect(localStorage.getItem('ui-theme')).toBe('light')
+ })
+})
diff --git a/web-client/src/features/auth/currentUser.test.ts b/web-client/src/features/auth/currentUser.test.ts
new file mode 100644
index 00000000..096ed7f2
--- /dev/null
+++ b/web-client/src/features/auth/currentUser.test.ts
@@ -0,0 +1,57 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { KeycloakTokenParsed } from 'keycloak-js'
+
+const mockState = vi.hoisted(() => ({
+ tokenParsed: undefined as (KeycloakTokenParsed & Record) | undefined,
+}))
+
+vi.mock('@/lib/keycloak', () => ({
+ default: {
+ get tokenParsed() {
+ return mockState.tokenParsed
+ },
+ },
+}))
+
+const { getCurrentUser } = await import('./currentUser')
+
+describe('getCurrentUser', () => {
+ afterEach(() => {
+ mockState.tokenParsed = undefined
+ })
+
+ it('reads the token and collapses member_roles to the highest role', () => {
+ mockState.tokenParsed = {
+ sub: 'token-sub',
+ name: 'Token User',
+ email: 'token.user@club.de',
+ member_roles: ['Coach', 'Admin'],
+ }
+
+ expect(getCurrentUser()).toEqual({
+ id: 'token-sub',
+ name: 'Token User',
+ email: 'token.user@club.de',
+ role: 'admin',
+ })
+ })
+
+ it('prefers preferred_username, then email, as the display name fallback', () => {
+ mockState.tokenParsed = {
+ sub: 'token-sub',
+ preferred_username: 'token.user',
+ member_roles: ['Trainee'],
+ }
+ expect(getCurrentUser().name).toBe('token.user')
+
+ mockState.tokenParsed = { sub: 'token-sub', email: 'only.email@club.de' }
+ expect(getCurrentUser().name).toBe('only.email@club.de')
+ })
+
+ it('yields an anonymous member when no token is present', () => {
+ mockState.tokenParsed = undefined
+
+ expect(getCurrentUser()).toEqual({ id: '', name: 'Unknown', email: '', role: 'member' })
+ })
+})
diff --git a/web-client/src/features/auth/currentUser.ts b/web-client/src/features/auth/currentUser.ts
index 3ef7c3c7..8c151424 100644
--- a/web-client/src/features/auth/currentUser.ts
+++ b/web-client/src/features/auth/currentUser.ts
@@ -1,8 +1,6 @@
import type { KeycloakTokenParsed } from 'keycloak-js'
import keycloak from '@/lib/keycloak'
-import { USE_MOCKS } from '@/mocks/mockSwitch'
-import { MOCK_PERSONAS, type MockPersonaKey } from '@/mocks/personas'
import { highestRole, type AuthUser } from '@/types'
type AuthTokenSnapshot = KeycloakTokenParsed & {
@@ -14,21 +12,8 @@ type AuthTokenSnapshot = KeycloakTokenParsed & {
member_roles?: string[]
}
-// Identity is coupled to the mock switch: mocks on => persona identity (matches
-// fixtures), mocks off => real token. Defaults to 'member' so fixtures always resolve.
-function mockPersona(): AuthUser | null {
- if (!USE_MOCKS) return null
-
- const persona = import.meta.env.VITE_MOCK_PERSONA
- const key: MockPersonaKey =
- typeof persona === 'string' && persona in MOCK_PERSONAS
- ? (persona as MockPersonaKey)
- : 'member'
-
- return MOCK_PERSONAS[key]
-}
-
-function tokenUser(): AuthUser {
+// Identity comes solely from the Keycloak token.
+export function getCurrentUser(): AuthUser {
const parsed = keycloak.tokenParsed as AuthTokenSnapshot | undefined
return {
@@ -38,7 +23,3 @@ function tokenUser(): AuthUser {
role: highestRole(parsed?.member_roles ?? []),
}
}
-
-export function getCurrentUser(): AuthUser {
- return mockPersona() ?? tokenUser()
-}
diff --git a/web-client/src/features/auth/useAuth.ts b/web-client/src/features/auth/useAuth.ts
index 47e593ce..2682aa50 100644
--- a/web-client/src/features/auth/useAuth.ts
+++ b/web-client/src/features/auth/useAuth.ts
@@ -3,7 +3,7 @@ import type { AuthUser } from '@/types'
import { getCurrentUser } from './currentUser'
export function useAuth(): { user: AuthUser; logout: () => void } {
- // Render-time snapshot of the current identity (persona under mocks, token otherwise).
+ // Render-time snapshot of the current identity from the token.
// getCurrentUser is the single source of identity — do not re-derive it here.
const user = getCurrentUser()
const logout = () => keycloak.logout({ redirectUri: window.location.origin })
diff --git a/web-client/src/features/feedback/api/queries.ts b/web-client/src/features/feedback/api/queries.ts
index 7e381fa3..51aa5953 100644
--- a/web-client/src/features/feedback/api/queries.ts
+++ b/web-client/src/features/feedback/api/queries.ts
@@ -1,16 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { getCurrentUser } from '@/features/auth/currentUser'
-import {
- eventDetailsById,
- eventSummaryFixtures,
- feedbackDetailsById,
- feedbackSummaryFixtures,
- memberNamesById,
-} from '@/mocks/fixtures'
-import { mockHttpError, mockOr } from '@/mocks/mockSwitch'
-import { scopeFeedback, trainerManagesMember } from '@/mocks/scope'
-import type { AuthUser, Reference } from '@/types'
import { feedbackClient } from './client'
import type { Feedback, FeedbackCreate, FeedbackPartialUpdate, FeedbackSummary } from '../types'
@@ -20,32 +9,6 @@ export const feedbackKeys = {
hello: ['feedback', 'hello'] as const,
}
-// Mutable mock state (mirrors sport-events' pattern) so create/delete are visible in the demo
-// instead of always re-reading the frozen fixture arrays.
-const mockFeedbackDetailsById: Record = { ...feedbackDetailsById }
-let mockFeedbackSummaryRows: FeedbackSummary[] = [...feedbackSummaryFixtures]
-
-function feedbackSummary(feedback: Feedback): FeedbackSummary {
- const { id, event, member, creator, created_at, rating } = feedback
- return { id, event, member, creator, created_at, rating }
-}
-
-function upsertMockFeedback(feedback: Feedback): void {
- mockFeedbackDetailsById[feedback.id] = feedback
-
- const summary = feedbackSummary(feedback)
- const index = mockFeedbackSummaryRows.findIndex((row) => row.id === feedback.id)
-
- if (index === -1) {
- mockFeedbackSummaryRows = [summary, ...mockFeedbackSummaryRows]
- return
- }
-
- mockFeedbackSummaryRows = mockFeedbackSummaryRows.map((row) =>
- row.id === feedback.id ? summary : row,
- )
-}
-
export function useFeedbackHello() {
return useQuery({
queryKey: feedbackKeys.hello,
@@ -58,11 +21,7 @@ export function useFeedbackList(enabled = true) {
queryKey: feedbackKeys.all,
staleTime: 30_000,
enabled,
- queryFn: () =>
- mockOr(
- () => Promise.resolve(scopeFeedback(mockFeedbackSummaryRows, getCurrentUser())),
- () => feedbackClient.get('').then(r => r.data),
- ),
+ queryFn: () => feedbackClient.get('').then(r => r.data),
})
}
@@ -70,118 +29,16 @@ export function useFeedback(id: string) {
return useQuery({
queryKey: feedbackKeys.detail(id),
staleTime: 30_000,
- queryFn: () =>
- mockOr(
- () => {
- const found = mockFeedbackDetailsById[id]
- const scoped = found ? scopeFeedback([found], getCurrentUser()) : []
- if (!scoped[0]) throw new Error('Feedback not found')
- return Promise.resolve(scoped[0])
- },
- () => feedbackClient.get(`/${id}`).then(r => r.data),
- ),
+ queryFn: () => feedbackClient.get(`/${id}`).then(r => r.data),
enabled: !!id,
})
}
-function isFeedbackRating(rating: number): boolean {
- return Number.isInteger(rating) && rating >= 0 && rating <= 10
-}
-
-function memberRef(memberId: string): Reference | null {
- const name = memberNamesById[memberId]
- return name ? { id: memberId, name } : null
-}
-
-function eventRef(eventId: string): Reference | null {
- const event = eventDetailsById[eventId] ?? eventSummaryFixtures.find((row) => row.id === eventId)
- return event ? { id: event.id, name: event.name } : null
-}
-
-function canCreateFeedback(user: AuthUser, memberId: string): boolean {
- return user.role === 'admin' || (user.role === 'trainer' && trainerManagesMember(user.id, memberId))
-}
-
-function mockFeedbackId(): string {
- return globalThis.crypto?.randomUUID?.() ?? `ffffffff-ffff-4fff-8fff-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
-}
-
-function mockCreateFeedback(data: FeedbackCreate): Feedback {
- const user = getCurrentUser()
- const event = eventRef(data.event)
- const member = memberRef(data.member)
-
- if (!event) throw mockHttpError(404, 'Event not found')
- if (!member) throw mockHttpError(404, 'Member not found')
- if (!data.feedback.trim()) throw mockHttpError(400, 'Feedback is required')
- if (!isFeedbackRating(data.rating)) {
- throw mockHttpError(400, 'Rating must be an integer between 0 and 10')
- }
- if (!canCreateFeedback(user, data.member)) {
- throw mockHttpError(403, 'You are not allowed to create feedback for this member')
- }
-
- const created: Feedback = {
- id: mockFeedbackId(),
- event,
- member,
- creator: { id: user.id, name: user.name },
- created_at: new Date().toISOString(),
- feedback: data.feedback,
- rating: data.rating,
- }
-
- upsertMockFeedback(created)
- return created
-}
-
-function mockDeleteFeedback(id: string): void {
- const user = getCurrentUser()
- const existing = mockFeedbackDetailsById[id] ?? mockFeedbackSummaryRows.find((row) => row.id === id)
-
- if (!existing) throw mockHttpError(404, 'Feedback not found')
- if (user.role !== 'admin' && existing.creator?.id !== user.id) {
- throw mockHttpError(403, 'You are not allowed to delete this feedback')
- }
-
- delete mockFeedbackDetailsById[id]
- mockFeedbackSummaryRows = mockFeedbackSummaryRows.filter((row) => row.id !== id)
-}
-
-function mockUpdateFeedback(id: string, data: FeedbackPartialUpdate): Feedback {
- const user = getCurrentUser()
- const existing = mockFeedbackDetailsById[id]
-
- if (!existing) throw mockHttpError(404, 'Feedback not found')
- if (user.role !== 'admin' && existing.creator?.id !== user.id) {
- throw mockHttpError(403, 'You are not allowed to update this feedback')
- }
- if (data.feedback !== undefined && !data.feedback.trim()) {
- throw mockHttpError(400, 'Feedback is required')
- }
- if (data.rating !== undefined && !isFeedbackRating(data.rating)) {
- throw mockHttpError(400, 'Rating must be an integer between 0 and 10')
- }
-
- const updated: Feedback = {
- ...existing,
- ...(data.feedback !== undefined ? { feedback: data.feedback } : {}),
- ...(data.rating !== undefined ? { rating: data.rating } : {}),
- }
-
- upsertMockFeedback(updated)
- return updated
-}
-
export function useCreateFeedback() {
const qc = useQueryClient()
return useMutation({
- mutationFn: data =>
- mockOr(
- () => Promise.resolve(mockCreateFeedback(data)),
- () => feedbackClient.post('', data).then(r => r.data),
- ),
+ mutationFn: data => feedbackClient.post('', data).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: feedbackKeys.all }),
})
}
@@ -190,11 +47,7 @@ export function useUpdateFeedback() {
const qc = useQueryClient()
return useMutation({
- mutationFn: ({ id, ...data }) =>
- mockOr(
- () => Promise.resolve(mockUpdateFeedback(id, data)),
- () => feedbackClient.patch(`/${id}`, data).then(r => r.data),
- ),
+ mutationFn: ({ id, ...data }) => feedbackClient.patch(`/${id}`, data).then(r => r.data),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: feedbackKeys.all })
qc.invalidateQueries({ queryKey: feedbackKeys.detail(id) })
@@ -206,14 +59,7 @@ export function useDeleteFeedback() {
const qc = useQueryClient()
return useMutation({
- mutationFn: id =>
- mockOr(
- () => {
- mockDeleteFeedback(id)
- return Promise.resolve(undefined)
- },
- () => feedbackClient.delete(`/${id}`).then(() => undefined),
- ),
+ mutationFn: id => feedbackClient.delete(`/${id}`).then(() => undefined),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: feedbackKeys.all })
qc.removeQueries({ queryKey: feedbackKeys.detail(id) })
diff --git a/web-client/src/features/feedback/components/FeedbackComposeDialog.test.tsx b/web-client/src/features/feedback/components/FeedbackComposeDialog.test.tsx
new file mode 100644
index 00000000..9d8a700e
--- /dev/null
+++ b/web-client/src/features/feedback/components/FeedbackComposeDialog.test.tsx
@@ -0,0 +1,140 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mutationMocks = vi.hoisted(() => ({
+ createFeedback: vi.fn(),
+}))
+
+vi.mock('@/features/feedback/api/queries', () => ({
+ useCreateFeedback: () => ({ mutateAsync: mutationMocks.createFeedback, isPending: false }),
+}))
+
+vi.mock('@/features/sport-events', async () => {
+ const { eventSummaryFixtures } = await import('@/testing/fixtures')
+
+ return {
+ useEventsList: () => ({ data: eventSummaryFixtures, isLoading: false, error: null }),
+ }
+})
+
+const { FeedbackComposeDialog } = await import('./FeedbackComposeDialog')
+const { useFeedbackUiStore } = await import('../model/feedbackUiStore')
+const { eventSummaryFixtures } = await import('@/testing/fixtures')
+const { httpError } = await import('@/testing/httpError')
+
+const TARGET = { id: 'member-1', name: 'Lena Roth', eventId: eventSummaryFixtures[0].id }
+
+describe('FeedbackComposeDialog', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ useFeedbackUiStore.getState().closeCompose()
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function renderOpen() {
+ await act(async () => {
+ root.render()
+ })
+ await act(async () => {
+ useFeedbackUiStore.getState().openCompose(TARGET)
+ })
+ }
+
+ async function fillField(id: string, value: string) {
+ const element = document.body.querySelector(`#${id}`)
+ expect(element, `#${id} should be rendered`).not.toBeNull()
+ const prototype =
+ element instanceof HTMLTextAreaElement
+ ? window.HTMLTextAreaElement.prototype
+ : window.HTMLInputElement.prototype
+ const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
+
+ await act(async () => {
+ valueSetter?.call(element, value)
+ element?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ }
+
+ async function submitForm() {
+ const form = document.body.querySelector('[role="dialog"] form')
+ expect(form).not.toBeNull()
+ await act(async () => {
+ form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ })
+ }
+
+ it('shows the subject and skips the event step when an event is preselected', async () => {
+ await renderOpen()
+
+ expect(document.body.textContent).toContain('Give Feedback')
+ expect(document.body.textContent).toContain(TARGET.name)
+ // No event selector — straight to the details step.
+ expect(document.body.querySelector('#feedback-event')).toBeNull()
+ expect(document.body.querySelector('#feedback-body')).not.toBeNull()
+ })
+
+ it('requires the feedback text', async () => {
+ await renderOpen()
+ await submitForm()
+
+ expect(document.body.textContent).toContain('Feedback is required.')
+ expect(mutationMocks.createFeedback).not.toHaveBeenCalled()
+ })
+
+ it('rejects an out-of-range rating', async () => {
+ await renderOpen()
+ await fillField('feedback-body', 'Strong session.')
+ await fillField('feedback-rating', '11')
+ await submitForm()
+
+ expect(document.body.textContent).toContain('Rating must be an integer from 0 to 10.')
+ expect(mutationMocks.createFeedback).not.toHaveBeenCalled()
+ })
+
+ it('submits the trimmed feedback with the parsed rating and closes', async () => {
+ mutationMocks.createFeedback.mockResolvedValue({ id: 'new-feedback' })
+
+ await renderOpen()
+ await fillField('feedback-body', ' Strong session. ')
+ await fillField('feedback-rating', '9')
+ await submitForm()
+
+ expect(mutationMocks.createFeedback).toHaveBeenCalledWith({
+ event: TARGET.eventId,
+ member: TARGET.id,
+ feedback: 'Strong session.',
+ rating: 9,
+ })
+ expect(useFeedbackUiStore.getState().composeTarget).toBeNull()
+ expect(useFeedbackUiStore.getState().composeNotice).toBe(`Feedback added for ${TARGET.name}.`)
+ })
+
+ it('surfaces a server 403 as the form error and stays open', async () => {
+ mutationMocks.createFeedback.mockRejectedValue(
+ httpError(403, 'You are not allowed to create feedback for this member'),
+ )
+
+ await renderOpen()
+ await fillField('feedback-body', 'Strong session.')
+ await fillField('feedback-rating', '9')
+ await submitForm()
+
+ expect(document.body.textContent).toContain(
+ 'You are not allowed to create feedback for this member',
+ )
+ expect(useFeedbackUiStore.getState().composeTarget).not.toBeNull()
+ })
+})
diff --git a/web-client/src/features/feedback/components/FeedbackEditDialog.test.tsx b/web-client/src/features/feedback/components/FeedbackEditDialog.test.tsx
new file mode 100644
index 00000000..173fc75d
--- /dev/null
+++ b/web-client/src/features/feedback/components/FeedbackEditDialog.test.tsx
@@ -0,0 +1,130 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mutationMocks = vi.hoisted(() => ({
+ updateFeedback: vi.fn(),
+}))
+
+vi.mock('@/features/feedback/api/queries', () => ({
+ useUpdateFeedback: () => ({ mutateAsync: mutationMocks.updateFeedback, isPending: false }),
+}))
+
+const { FeedbackEditDialog } = await import('./FeedbackEditDialog')
+const { useFeedbackUiStore } = await import('../model/feedbackUiStore')
+const { httpError } = await import('@/testing/httpError')
+
+const TARGET = {
+ id: 'feedback-1',
+ memberName: 'Lena Roth',
+ eventName: 'Football Juniors Match',
+ feedback: 'Solid defending.',
+ rating: 7,
+}
+
+describe('FeedbackEditDialog', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ useFeedbackUiStore.getState().closeEdit()
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function renderOpen() {
+ await act(async () => {
+ root.render()
+ })
+ await act(async () => {
+ useFeedbackUiStore.getState().openEdit(TARGET)
+ })
+ }
+
+ async function fillField(id: string, value: string) {
+ const element = document.body.querySelector(`#${id}`)
+ expect(element, `#${id} should be rendered`).not.toBeNull()
+ const prototype =
+ element instanceof HTMLTextAreaElement
+ ? window.HTMLTextAreaElement.prototype
+ : window.HTMLInputElement.prototype
+ const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
+
+ await act(async () => {
+ valueSetter?.call(element, value)
+ element?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ }
+
+ async function submitForm() {
+ const form = document.body.querySelector('[role="dialog"] form')
+ expect(form).not.toBeNull()
+ await act(async () => {
+ form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ })
+ }
+
+ it('prefills the target feedback and shows the read-only context', async () => {
+ await renderOpen()
+
+ expect(document.body.textContent).toContain(TARGET.eventName)
+ expect(document.body.textContent).toContain(TARGET.memberName)
+ expect(
+ document.body.querySelector('#feedback-edit-body')?.value,
+ ).toBe(TARGET.feedback)
+ expect(
+ document.body.querySelector('#feedback-edit-rating')?.value,
+ ).toBe(String(TARGET.rating))
+ })
+
+ it('requires feedback text and an in-range rating', async () => {
+ await renderOpen()
+
+ await fillField('feedback-edit-body', ' ')
+ await submitForm()
+ expect(document.body.textContent).toContain('Feedback is required.')
+
+ await fillField('feedback-edit-body', 'Better positioning now.')
+ await fillField('feedback-edit-rating', '11')
+ await submitForm()
+ expect(document.body.textContent).toContain('Rating must be an integer from 0 to 10.')
+ expect(mutationMocks.updateFeedback).not.toHaveBeenCalled()
+ })
+
+ it('submits the trimmed update and closes on success', async () => {
+ mutationMocks.updateFeedback.mockResolvedValue({ id: TARGET.id })
+
+ await renderOpen()
+ await fillField('feedback-edit-body', ' Better positioning now. ')
+ await fillField('feedback-edit-rating', '9')
+ await submitForm()
+
+ expect(mutationMocks.updateFeedback).toHaveBeenCalledWith({
+ id: TARGET.id,
+ feedback: 'Better positioning now.',
+ rating: 9,
+ })
+ expect(useFeedbackUiStore.getState().editTarget).toBeNull()
+ })
+
+ it('surfaces a server 403 and stays open', async () => {
+ mutationMocks.updateFeedback.mockRejectedValue(
+ httpError(403, 'You are not allowed to update this feedback'),
+ )
+
+ await renderOpen()
+ await submitForm()
+
+ expect(document.body.textContent).toContain('You are not allowed to update this feedback')
+ expect(useFeedbackUiStore.getState().editTarget).not.toBeNull()
+ })
+})
diff --git a/web-client/src/features/feedback/pages/FeedbackPage.test.tsx b/web-client/src/features/feedback/pages/FeedbackPage.test.tsx
new file mode 100644
index 00000000..f35013fe
--- /dev/null
+++ b/web-client/src/features/feedback/pages/FeedbackPage.test.tsx
@@ -0,0 +1,172 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'admin' as PersonaKey,
+ feedbackError: null as Error | null,
+ feedbackLoading: false,
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
+ }
+})
+
+vi.mock('@/features/feedback/api/queries', async () => {
+ const { feedbackSummaryFixtures } = await import('@/testing/fixtures')
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+ const { scopeFeedback } = await import('@/testing/scope')
+
+ return {
+ useFeedbackList: () => ({
+ data: mockState.feedbackLoading
+ ? undefined
+ : scopeFeedback(feedbackSummaryFixtures, TEST_PERSONAS[mockState.persona]),
+ isLoading: mockState.feedbackLoading,
+ error: mockState.feedbackError,
+ }),
+ useFeedback: () => ({ data: undefined, isLoading: false, error: null }),
+ useCreateFeedback: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useUpdateFeedback: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useDeleteFeedback: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ }
+})
+
+vi.mock('@/features/members/api/queries', async () => {
+ const { memberSummaryFixtures } = await import('@/testing/fixtures')
+
+ return {
+ useMembers: () => ({ data: memberSummaryFixtures, isLoading: false, error: null }),
+ }
+})
+
+vi.mock('@/features/sport-events/api/queries', async () => {
+ const { eventSummaryFixtures } = await import('@/testing/fixtures')
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+ const { scopeEvents } = await import('@/testing/scope')
+
+ return {
+ useEventsList: () => ({
+ data: scopeEvents(eventSummaryFixtures, TEST_PERSONAS[mockState.persona]),
+ isLoading: false,
+ error: null,
+ }),
+ }
+})
+
+vi.mock('@/features/organization/api/queries', async () => {
+ const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
+
+ return {
+ useSportsList: () => ({ data: sportFixtures, isLoading: false, error: null }),
+ useTeamsList: () => ({ data: teamFixtures, isLoading: false, error: null }),
+ }
+})
+
+const { FeedbackPage } = await import('./FeedbackPage')
+const { feedbackSummaryFixtures } = await import('@/testing/fixtures')
+const { TEST_PERSONAS } = await import('@/testing/personas')
+const { scopeFeedback } = await import('@/testing/scope')
+
+describe('FeedbackPage', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.persona = 'admin'
+ mockState.feedbackError = null
+ mockState.feedbackLoading = false
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function render() {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ )
+ })
+ }
+
+ function bodyRowCount(): number {
+ return container.querySelectorAll('table tbody tr').length
+ }
+
+ function buttonNamed(name: string): HTMLButtonElement | undefined {
+ return Array.from(container.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes(name),
+ )
+ }
+
+ it('shows all rows and the compose entry point for the admin', async () => {
+ await render()
+
+ expect(bodyRowCount()).toBe(feedbackSummaryFixtures.length)
+ expect(buttonNamed('New feedback')).toBeDefined()
+ expect(container.textContent).toContain('Feedback coaches have given, by event.')
+ })
+
+ it('scopes rows to the coach and shows the coverage stat', async () => {
+ mockState.persona = 'coach'
+ const scoped = scopeFeedback(feedbackSummaryFixtures, TEST_PERSONAS.coach)
+
+ await render()
+
+ expect(bodyRowCount()).toBe(scoped.length)
+ expect(scoped.length).toBeGreaterThan(0)
+ expect(container.textContent).toContain("Feedback you've given, by event.")
+ expect(container.textContent).toContain('Coverage')
+ // Coach view drops the redundant "From" column.
+ expect(container.textContent).not.toContain('From')
+ })
+
+ it('shows the member persona only their own feedback', async () => {
+ mockState.persona = 'member'
+ const scoped = scopeFeedback(feedbackSummaryFixtures, TEST_PERSONAS.member)
+
+ await render()
+
+ expect(container.textContent).toContain('Feedback coaches have given you, by event.')
+ if (scoped.length === 0) {
+ expect(container.textContent).toContain('No feedback is listed yet.')
+ } else {
+ expect(bodyRowCount()).toBe(scoped.length)
+ }
+ expect(buttonNamed('New feedback')).toBeUndefined()
+ })
+
+ it('shows the director the empty state and no compose button', async () => {
+ mockState.persona = 'director'
+
+ await render()
+
+ expect(container.textContent).toContain('No feedback is listed yet.')
+ expect(buttonNamed('New feedback')).toBeUndefined()
+ })
+
+ it('surfaces a query error', async () => {
+ mockState.feedbackError = new Error('Request failed with status code 403')
+
+ await render()
+
+ expect(container.textContent).toContain('Request failed with status code 403')
+ })
+})
diff --git a/web-client/src/features/helper/api/queries.ts b/web-client/src/features/helper/api/queries.ts
index dae3c894..c9215ffa 100644
--- a/web-client/src/features/helper/api/queries.ts
+++ b/web-client/src/features/helper/api/queries.ts
@@ -1,15 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { getCurrentUser } from '@/features/auth/currentUser'
-import {
- memberNamesById,
- memberReportSummariesById,
- reportById as reportByIdFixture,
- teamFixtures,
- teamReportSummariesById,
-} from '@/mocks/fixtures'
-import { mockOr } from '@/mocks/mockSwitch'
-import { scopeReport, scopeTeamReport } from '@/mocks/scope'
import type { MemberReportSummary, Report, TeamReportSummary } from '@/types'
import { helperClient } from './client'
@@ -20,18 +10,6 @@ export const helperKeys = {
report: (reportId: string) => ['helper', 'reports', reportId] as const,
}
-const mockReportById: Record = { ...reportByIdFixture }
-const mockMemberReportSummariesById: Record = Object.fromEntries(
- Object.entries(memberReportSummariesById).map(([memberId, rows]) => [memberId, [...rows]]),
-)
-const mockTeamReportSummariesById: Record = Object.fromEntries(
- Object.entries(teamReportSummariesById).map(([teamId, rows]) => [teamId, [...rows]]),
-)
-
-function mockReportId(): string {
- return globalThis.crypto?.randomUUID?.() ?? `ffffffff-ffff-4fff-8fff-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
-}
-
export function useHelperHello() {
return useQuery({
queryKey: helperKeys.hello,
@@ -45,15 +23,7 @@ export function useGenerateMemberReport(memberId: string) {
const qc = useQueryClient()
return useMutation({
- mutationFn: () =>
- mockOr(
- () => {
- if (!scopeReport(memberId, getCurrentUser())) throw new Error('Report not allowed')
- mockGenerateMemberReport(memberId)
- return Promise.resolve()
- },
- () => helperClient.post(`/reports/member/${memberId}`).then(() => undefined),
- ),
+ mutationFn: () => helperClient.post(`/reports/member/${memberId}`).then(() => undefined),
onSuccess: () => qc.invalidateQueries({ queryKey: helperKeys.memberReports(memberId) }),
})
}
@@ -62,59 +32,11 @@ export function useGenerateTeamReport(teamId: string) {
const qc = useQueryClient()
return useMutation({
- mutationFn: () =>
- mockOr(
- () => {
- if (!scopeTeamReport(teamId, getCurrentUser())) throw new Error('Report not allowed')
- mockGenerateTeamReport(teamId)
- return Promise.resolve()
- },
- () => helperClient.post(`/reports/team/${teamId}`).then(() => undefined),
- ),
+ mutationFn: () => helperClient.post(`/reports/team/${teamId}`).then(() => undefined),
onSuccess: () => qc.invalidateQueries({ queryKey: helperKeys.teamReports(teamId) }),
})
}
-function mockGenerateMemberReport(memberId: string): void {
- const name = memberNamesById[memberId]
- if (!name) return
-
- const member = { id: memberId, name }
- const report: Report = {
- id: mockReportId(),
- kind: 'member',
- member,
- created_at: new Date().toISOString(),
- text: `# Development report — ${name}\n\nReport generation in progress.`,
- }
-
- mockReportById[report.id] = report
- mockMemberReportSummariesById[memberId] = [
- { id: report.id, member, created_at: report.created_at },
- ...(mockMemberReportSummariesById[memberId] ?? []),
- ]
-}
-
-function mockGenerateTeamReport(teamId: string): void {
- const team = teamFixtures.find((item) => item.id === teamId)
- if (!team) return
-
- const teamRef = { id: team.id, name: team.name }
- const report: Report = {
- id: mockReportId(),
- kind: 'team',
- team: teamRef,
- created_at: new Date().toISOString(),
- text: `# Team report — ${team.name}\n\nReport generation in progress.`,
- }
-
- mockReportById[report.id] = report
- mockTeamReportSummariesById[teamId] = [
- { id: report.id, team: teamRef, created_at: report.created_at },
- ...(mockTeamReportSummariesById[teamId] ?? []),
- ]
-}
-
// ---- list summaries (newest-first, no text) ----
export function useMemberReports(memberId: string, enabled = true) {
@@ -122,14 +44,7 @@ export function useMemberReports(memberId: string, enabled = true) {
queryKey: helperKeys.memberReports(memberId),
staleTime: 30_000,
enabled: enabled && !!memberId,
- queryFn: () =>
- mockOr(
- () => {
- if (!scopeReport(memberId, getCurrentUser())) return Promise.resolve([])
- return Promise.resolve(mockMemberReportSummariesById[memberId] ?? [])
- },
- () => helperClient.get(`/reports/member/${memberId}`).then(r => r.data),
- ),
+ queryFn: () => helperClient.get(`/reports/member/${memberId}`).then(r => r.data),
})
}
@@ -138,14 +53,7 @@ export function useTeamReports(teamId: string, enabled = true) {
queryKey: helperKeys.teamReports(teamId),
staleTime: 30_000,
enabled: enabled && !!teamId,
- queryFn: () =>
- mockOr(
- () => {
- if (!scopeTeamReport(teamId, getCurrentUser())) return Promise.resolve([])
- return Promise.resolve(mockTeamReportSummariesById[teamId] ?? [])
- },
- () => helperClient.get(`/reports/team/${teamId}`).then(r => r.data),
- ),
+ queryFn: () => helperClient.get(`/reports/team/${teamId}`).then(r => r.data),
})
}
@@ -155,53 +63,17 @@ export function useReport(reportId: string | null) {
return useQuery({
queryKey: helperKeys.report(reportId ?? ''),
enabled: !!reportId,
- queryFn: () =>
- mockOr(
- () => {
- const found = reportId ? mockReportById[reportId] : undefined
- if (!found) throw new Error('Report not found')
- return Promise.resolve(found)
- },
- () => helperClient.get(`/reports/${reportId}`).then(r => r.data),
- ),
+ queryFn: () => helperClient.get(`/reports/${reportId}`).then(r => r.data),
})
}
// ---- delete ----
-function mockDeleteReport(reportId: string): void {
- const report = mockReportById[reportId]
- if (!report) return
-
- delete mockReportById[reportId]
-
- if (report.kind === 'member' && report.member) {
- const memberId = report.member.id
- mockMemberReportSummariesById[memberId] = (mockMemberReportSummariesById[memberId] ?? []).filter(
- (row) => row.id !== reportId,
- )
- }
-
- if (report.kind === 'team' && report.team) {
- const teamId = report.team.id
- mockTeamReportSummariesById[teamId] = (mockTeamReportSummariesById[teamId] ?? []).filter(
- (row) => row.id !== reportId,
- )
- }
-}
-
export function useDeleteReport() {
const qc = useQueryClient()
return useMutation({
- mutationFn: ({ reportId }) =>
- mockOr(
- () => {
- mockDeleteReport(reportId)
- return Promise.resolve()
- },
- () => helperClient.delete(`/reports/${reportId}`).then(() => undefined),
- ),
+ mutationFn: ({ reportId }) => helperClient.delete(`/reports/${reportId}`).then(() => undefined),
onSuccess: (_, { reportId, listKey }) => {
qc.invalidateQueries({ queryKey: listKey })
qc.removeQueries({ queryKey: helperKeys.report(reportId) })
diff --git a/web-client/src/features/helper/pages/HelperPage.test.tsx b/web-client/src/features/helper/pages/HelperPage.test.tsx
new file mode 100644
index 00000000..6a15337c
--- /dev/null
+++ b/web-client/src/features/helper/pages/HelperPage.test.tsx
@@ -0,0 +1,141 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'member' as PersonaKey,
+}))
+
+const mutationMocks = vi.hoisted(() => ({
+ generateMember: vi.fn(),
+ generateTeam: vi.fn(),
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
+ }
+})
+
+vi.mock('@/features/helper/api/queries', async () => {
+ const { memberReportSummariesById, teamReportSummariesById } = await import('@/testing/fixtures')
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ helperKeys: {
+ memberReports: (memberId: string) => ['helper', 'reports', 'member', memberId] as const,
+ teamReports: (teamId: string) => ['helper', 'reports', 'team', teamId] as const,
+ report: (reportId: string) => ['helper', 'reports', reportId] as const,
+ },
+ useMemberReports: (memberId: string, enabled = true) => ({
+ data:
+ enabled && memberId === TEST_PERSONAS[mockState.persona].id
+ ? (memberReportSummariesById[memberId] ?? [])
+ : undefined,
+ isLoading: false,
+ isError: false,
+ }),
+ useTeamReports: (teamId: string, enabled = true) => ({
+ data: enabled ? (teamReportSummariesById[teamId] ?? []) : undefined,
+ isLoading: false,
+ isError: false,
+ }),
+ useReport: () => ({ data: undefined, isLoading: false, error: null }),
+ useGenerateMemberReport: () => ({
+ mutate: mutationMocks.generateMember,
+ isPending: false,
+ error: null,
+ }),
+ useGenerateTeamReport: () => ({
+ mutate: mutationMocks.generateTeam,
+ isPending: false,
+ error: null,
+ }),
+ useDeleteReport: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ }
+})
+
+vi.mock('@/features/organization/api/queries', async () => {
+ const { teamFixtures } = await import('@/testing/fixtures/organization')
+
+ return {
+ useTeamsList: (enabled = true) => ({
+ data: enabled ? teamFixtures : undefined,
+ isLoading: false,
+ error: null,
+ }),
+ }
+})
+
+const { HelperPage } = await import('./HelperPage')
+const { teamFixtures } = await import('@/testing/fixtures/organization')
+const { TEST_PERSONAS } = await import('@/testing/personas')
+
+describe('HelperPage', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.persona = 'member'
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function render() {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ )
+ })
+ }
+
+ it('scopes the member persona to their own development reports', async () => {
+ await render()
+
+ // Member scope: the subject is the signed-in user, not a team.
+ expect(container.textContent).toContain('Your progress reports from the coaching staff.')
+ })
+
+ it('scopes the coach persona to their coached team', async () => {
+ mockState.persona = 'coach'
+ const coachedTeam = teamFixtures.find((team) =>
+ team.trainers.some((trainer) => trainer.id === TEST_PERSONAS.coach.id),
+ )
+
+ await render()
+
+ expect(coachedTeam).toBeDefined()
+ expect(container.textContent).toContain(coachedTeam?.name ?? '')
+ })
+
+ it('triggers a report generation from the page action', async () => {
+ await render()
+
+ const generateButton = Array.from(container.querySelectorAll('button')).find((button) =>
+ button.textContent?.toLowerCase().includes('generate'),
+ )
+ expect(generateButton).toBeDefined()
+
+ await act(async () => {
+ generateButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+
+ expect(mutationMocks.generateMember).toHaveBeenCalled()
+ })
+})
diff --git a/web-client/src/features/letters/api/queries.ts b/web-client/src/features/letters/api/queries.ts
index 3d781262..daf6e538 100644
--- a/web-client/src/features/letters/api/queries.ts
+++ b/web-client/src/features/letters/api/queries.ts
@@ -1,6 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query'
-import { mockOr } from '@/mocks/mockSwitch'
import { lettersClient } from './client'
import type { GeneratePdfRequest, SendMailRequest } from '../types'
@@ -11,37 +10,25 @@ export const lettersKeys = {
export function useLettersHello() {
return useQuery({
queryKey: lettersKeys.hello,
- queryFn: () =>
- mockOr(
- () => Promise.resolve('Letters service mock'),
- () => lettersClient.get('/hello').then(r => r.data),
- ),
+ queryFn: () => lettersClient.get('/hello').then(r => r.data),
})
}
export function useSendMail() {
return useMutation({
mutationFn: data =>
- mockOr(
- () => Promise.resolve(),
- () =>
- lettersClient.post('/mail', data, {
- headers: { 'Content-Type': 'application/json' },
- }).then(() => undefined),
- ),
+ lettersClient.post('/mail', data, {
+ headers: { 'Content-Type': 'application/json' },
+ }).then(() => undefined),
})
}
export function useGeneratePdf() {
return useMutation({
mutationFn: data =>
- mockOr(
- () => Promise.resolve(new Blob([data.template], { type: 'application/pdf' })),
- () =>
- lettersClient.post('/pdf', data, {
- headers: { 'Content-Type': 'application/json' },
- responseType: 'blob',
- }).then(r => r.data),
- ),
+ lettersClient.post('/pdf', data, {
+ headers: { 'Content-Type': 'application/json' },
+ responseType: 'blob',
+ }).then(r => r.data),
})
}
diff --git a/web-client/src/features/letters/pages/LettersPage.test.tsx b/web-client/src/features/letters/pages/LettersPage.test.tsx
new file mode 100644
index 00000000..82d98394
--- /dev/null
+++ b/web-client/src/features/letters/pages/LettersPage.test.tsx
@@ -0,0 +1,179 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'admin' as PersonaKey,
+}))
+
+const mutationMocks = vi.hoisted(() => ({
+ sendMail: vi.fn(),
+ generatePdf: vi.fn(),
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
+ }
+})
+
+vi.mock('@/features/letters/api', () => ({
+ useSendMail: () => ({ mutateAsync: mutationMocks.sendMail, isPending: false }),
+ useGeneratePdf: () => ({ mutateAsync: mutationMocks.generatePdf, isPending: false }),
+}))
+
+vi.mock('@/features/organization/api/queries', async () => {
+ const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
+
+ return {
+ useSportsList: (enabled = true) => ({
+ data: enabled ? sportFixtures : undefined,
+ isLoading: false,
+ error: null,
+ }),
+ useTeamsList: (enabled = true) => ({
+ data: enabled ? teamFixtures : undefined,
+ isLoading: false,
+ error: null,
+ }),
+ }
+})
+
+const { LettersPage } = await import('./LettersPage')
+const { TEST_PERSONAS } = await import('@/testing/personas')
+const { teamFixtures } = await import('@/testing/fixtures/organization')
+
+describe('LettersPage', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.persona = 'admin'
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function render() {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ )
+ })
+ }
+
+ async function fillField(id: string, value: string) {
+ const element = container.querySelector(`#${id}`)
+ expect(element).not.toBeNull()
+ const prototype =
+ element instanceof HTMLTextAreaElement
+ ? window.HTMLTextAreaElement.prototype
+ : window.HTMLInputElement.prototype
+ const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
+
+ await act(async () => {
+ valueSetter?.call(element, value)
+ element?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ }
+
+ async function submitForm() {
+ const form = container.querySelector('form')
+ expect(form).not.toBeNull()
+ await act(async () => {
+ form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ })
+ }
+
+ it('blocks the member persona with the role empty state', async () => {
+ mockState.persona = 'member'
+
+ await render()
+
+ expect(container.textContent).toContain('Letters are not available for this role.')
+ expect(container.querySelector('form')).toBeNull()
+ })
+
+ it('describes the coach audience as their coached teams', async () => {
+ mockState.persona = 'coach'
+ const coachedTeams = teamFixtures.filter((team) =>
+ team.trainers.some((trainer) => trainer.id === TEST_PERSONAS.coach.id),
+ )
+
+ await render()
+
+ expect(coachedTeams.length).toBeGreaterThan(0)
+ expect(container.textContent).toContain(coachedTeams[0].name)
+ })
+
+ it('requires a subject for mail letters', async () => {
+ await render()
+ await fillField('letter-template', 'Hello
')
+ await submitForm()
+
+ expect(container.textContent).toContain('Subject is required.')
+ expect(mutationMocks.sendMail).not.toHaveBeenCalled()
+ })
+
+ it('requires a template body', async () => {
+ await render()
+ await fillField('letter-subject', 'Newsletter')
+ await submitForm()
+
+ expect(container.textContent).toContain('Template is required.')
+ expect(mutationMocks.sendMail).not.toHaveBeenCalled()
+ })
+
+ it('sends the trimmed subject and raw template on submit', async () => {
+ mutationMocks.sendMail.mockResolvedValue(undefined)
+
+ await render()
+ await fillField('letter-subject', ' Newsletter ')
+ await fillField('letter-template', 'Hello {{first_name}}
')
+ await submitForm()
+
+ expect(mutationMocks.sendMail).toHaveBeenCalledWith({
+ subject: 'Newsletter',
+ template: 'Hello {{first_name}}
',
+ })
+ expect(container.textContent).toContain('Mail sent to all members.')
+ })
+
+ it('surfaces a send failure as a form error', async () => {
+ mutationMocks.sendMail.mockRejectedValue(new Error('Mail delivery failed'))
+
+ await render()
+ await fillField('letter-subject', 'Newsletter')
+ await fillField('letter-template', 'Hello
')
+ await submitForm()
+
+ expect(container.textContent).toContain('Mail delivery failed')
+ })
+
+ it('renders the live preview iframe once a template exists', async () => {
+ await render()
+
+ expect(container.querySelector('iframe')).toBeNull()
+ await fillField('letter-template', 'Hello {{first_name}}
')
+
+ const iframe = container.querySelector('iframe')
+ expect(iframe).not.toBeNull()
+ // Tokens are replaced with sample values inside the srcdoc document.
+ expect(iframe?.getAttribute('srcdoc')).toContain('Hello Alex')
+ })
+})
diff --git a/web-client/src/features/members/api/queries.ts b/web-client/src/features/members/api/queries.ts
index 03060d6c..39b9143b 100644
--- a/web-client/src/features/members/api/queries.ts
+++ b/web-client/src/features/members/api/queries.ts
@@ -1,9 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { getCurrentUser } from '@/features/auth/currentUser'
-import { memberFixtures, memberSummaryFixtures } from '@/mocks/fixtures'
-import { mockHttpError, mockOr } from '@/mocks/mockSwitch'
-import { scopeMembers } from '@/mocks/scope'
import { membersClient } from './client'
import type { Member, MemberCreate, MemberPartialUpdate, MemberSummary } from '../types'
@@ -25,11 +21,7 @@ export function useMembers(enabled = true) {
queryKey: membersKeys.all,
staleTime: 30_000,
enabled,
- queryFn: () =>
- mockOr(
- () => Promise.resolve(scopeMembers(memberSummaryFixtures, getCurrentUser())),
- () => membersClient.get('').then(r => r.data),
- ),
+ queryFn: () => membersClient.get('').then(r => r.data),
})
}
@@ -37,16 +29,7 @@ export function useMember(id: string) {
return useQuery({
queryKey: membersKeys.detail(id),
staleTime: 30_000,
- queryFn: () =>
- mockOr(
- () => {
- const found = memberFixtures.find(m => m.id === id)
- const scoped = found ? scopeMembers([found], getCurrentUser()) : []
- if (!scoped[0]) throw new Error('Member not found')
- return Promise.resolve(scoped[0])
- },
- () => membersClient.get(`/${id}`).then(r => r.data),
- ),
+ queryFn: () => membersClient.get(`/${id}`).then(r => r.data),
enabled: !!id,
})
}
@@ -55,11 +38,7 @@ export function useCreateMember() {
const qc = useQueryClient()
return useMutation({
- mutationFn: data =>
- mockOr(
- () => Promise.resolve(mockCreateMember(data)),
- () => membersClient.post('', data).then(r => r.data),
- ),
+ mutationFn: data => membersClient.post('', data).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: membersKeys.all }),
})
}
@@ -68,11 +47,7 @@ export function useUpdateMember() {
const qc = useQueryClient()
return useMutation({
- mutationFn: ({ id, ...data }) =>
- mockOr(
- () => Promise.resolve(mockUpdateMember({ id, ...data })),
- () => membersClient.patch(`/${id}`, data).then(r => r.data),
- ),
+ mutationFn: ({ id, ...data }) => membersClient.patch(`/${id}`, data).then(r => r.data),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: membersKeys.all })
qc.invalidateQueries({ queryKey: membersKeys.detail(id) })
@@ -84,124 +59,10 @@ export function useDeleteMember() {
const qc = useQueryClient()
return useMutation({
- mutationFn: id =>
- mockOr(
- () => {
- mockDeleteMember(id)
- return Promise.resolve(undefined)
- },
- () => membersClient.delete(`/${id}`).then(() => undefined),
- ),
+ mutationFn: id => membersClient.delete(`/${id}`).then(() => undefined),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: membersKeys.all })
qc.removeQueries({ queryKey: membersKeys.detail(id) })
},
})
}
-
-function mockCreateMember(data: MemberCreate): Member {
- const user = getCurrentUser()
-
- if (user.role !== 'admin') {
- throw mockHttpError(403, 'Only admins can create members.')
- }
-
- const fieldErrors: { message: string }[] = []
- if (!data.first_name.trim()) fieldErrors.push({ message: 'first_name: must not be blank' })
- if (!data.last_name.trim()) fieldErrors.push({ message: 'last_name: must not be blank' })
- if (!data.password) fieldErrors.push({ message: 'password: must not be blank' })
- if (fieldErrors.length > 0) {
- throw mockHttpError(400, 'Validation failed', fieldErrors)
- }
-
- const email = data.email.trim()
- assertUniqueMemberEmail(email)
-
- const member: Member = {
- id: mockMemberId(),
- first_name: data.first_name.trim(),
- last_name: data.last_name.trim(),
- email,
- birthday: data.birthday ?? '',
- phone_number: data.phone_number ?? '',
- address: data.address ?? '',
- joining_date: new Date().toISOString().slice(0, 10),
- information: data.information ?? '',
- }
-
- memberFixtures.unshift(member)
- memberSummaryFixtures.unshift({
- id: member.id,
- first_name: member.first_name,
- last_name: member.last_name,
- email: member.email,
- })
- return { ...member }
-}
-
-function mockUpdateMember({ id, ...data }: { id: string } & MemberPartialUpdate): Member {
- const user = getCurrentUser()
- const index = memberFixtures.findIndex((member) => member.id === id)
- const member = memberFixtures[index]
-
- if (!member) throw mockHttpError(404, `Member not found: ${id}`)
- if (user.role !== 'admin' && user.id !== id) {
- throw mockHttpError(403, 'Access denied')
- }
- if (data.email !== undefined) {
- const email = data.email.trim()
- if (!email) throw mockHttpError(400, 'Email is required.')
- if (email !== member.email) assertUniqueMemberEmail(email, id)
- }
-
- const updated: Member = {
- ...member,
- first_name: data.first_name !== undefined ? data.first_name.trim() : member.first_name,
- last_name: data.last_name !== undefined ? data.last_name.trim() : member.last_name,
- email: data.email !== undefined ? data.email.trim() : member.email,
- birthday: data.birthday !== undefined ? data.birthday : member.birthday,
- phone_number: data.phone_number !== undefined ? data.phone_number : member.phone_number,
- address: data.address !== undefined ? data.address : member.address,
- information: data.information !== undefined ? data.information : member.information,
- }
-
- memberFixtures[index] = updated
- const summaryIndex = memberSummaryFixtures.findIndex((summary) => summary.id === id)
- if (summaryIndex !== -1) {
- memberSummaryFixtures[summaryIndex] = {
- id: updated.id,
- first_name: updated.first_name,
- last_name: updated.last_name,
- email: updated.email,
- }
- }
- return { ...updated }
-}
-
-function mockDeleteMember(id: string): void {
- const user = getCurrentUser()
- const index = memberFixtures.findIndex((member) => member.id === id)
-
- if (user.role !== 'admin') {
- throw mockHttpError(403, 'Only admins can delete members.')
- }
- if (index === -1) throw mockHttpError(404, `Member not found: ${id}`)
-
- memberFixtures.splice(index, 1)
- const summaryIndex = memberSummaryFixtures.findIndex((summary) => summary.id === id)
- if (summaryIndex !== -1) memberSummaryFixtures.splice(summaryIndex, 1)
-}
-
-function assertUniqueMemberEmail(email: string, excludeId?: string): void {
- const duplicate = memberFixtures.some(
- (member) => member.id !== excludeId && member.email.toLowerCase() === email.toLowerCase(),
- )
- if (duplicate) throw mockHttpError(409, `Email already in use: ${email}`)
-}
-
-function mockMemberId(): string {
- return (
- globalThis.crypto?.randomUUID?.() ??
- `dddddddd-dddd-4ddd-8ddd-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
- )
-}
diff --git a/web-client/src/features/members/components/MemberEditorDialog.test.tsx b/web-client/src/features/members/components/MemberEditorDialog.test.tsx
new file mode 100644
index 00000000..0bf8eca3
--- /dev/null
+++ b/web-client/src/features/members/components/MemberEditorDialog.test.tsx
@@ -0,0 +1,186 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'admin' as PersonaKey,
+}))
+
+const mutationMocks = vi.hoisted(() => ({
+ createMember: vi.fn(),
+ updateMember: vi.fn(),
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
+ }
+})
+
+vi.mock('@/features/members/api/queries', () => ({
+ useMember: () => ({ data: undefined, isLoading: false, error: null }),
+ useCreateMember: () => ({ mutateAsync: mutationMocks.createMember, isPending: false }),
+ useUpdateMember: () => ({ mutateAsync: mutationMocks.updateMember, isPending: false }),
+}))
+
+vi.mock('@/components/ui/date-picker', () => ({
+ DatePicker: ({
+ id,
+ value,
+ onChange,
+ }: {
+ id?: string
+ value: string
+ onChange: (value: string) => void
+ }) => onChange(event.target.value)} />,
+}))
+
+const { MemberEditorDialog } = await import('./MemberEditorDialog')
+const { useMembersUiStore } = await import('../model/membersUiStore')
+const { httpError } = await import('@/testing/httpError')
+
+describe('MemberEditorDialog', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.persona = 'admin'
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ useMembersUiStore.getState().closeEditor()
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function renderOpenCreate() {
+ await act(async () => {
+ root.render()
+ })
+ await act(async () => {
+ useMembersUiStore.getState().openCreateMember()
+ })
+ }
+
+ async function fillField(id: string, value: string) {
+ const element = document.body.querySelector(`#${id}`)
+ expect(element, `#${id} should be rendered`).not.toBeNull()
+ const valueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value',
+ )?.set
+
+ await act(async () => {
+ valueSetter?.call(element, value)
+ element?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ }
+
+ async function submitForm() {
+ const form = document.body.querySelector('[role="dialog"] form')
+ expect(form).not.toBeNull()
+ await act(async () => {
+ form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ })
+ }
+
+ it('refuses non-admin personas', async () => {
+ mockState.persona = 'coach'
+ await renderOpenCreate()
+
+ expect(document.body.textContent).toContain('You are not allowed to create members.')
+ expect(document.body.querySelector('#member-first-name')).toBeNull()
+ })
+
+ it('blocks a blank identity step with a validation message', async () => {
+ await renderOpenCreate()
+ await submitForm()
+
+ expect(document.body.textContent).toContain('First name is required.')
+ expect(mutationMocks.createMember).not.toHaveBeenCalled()
+ })
+
+ it('requires a valid email and a password on create', async () => {
+ await renderOpenCreate()
+ await fillField('member-first-name', 'Nina')
+ await fillField('member-last-name', 'Neu')
+ await fillField('member-email', 'not-an-email')
+ await submitForm()
+ expect(document.body.textContent).toContain('A valid email is required.')
+
+ await fillField('member-email', 'nina.neu@club.de')
+ await submitForm()
+ expect(document.body.textContent).toContain('Password is required.')
+ })
+
+ it('walks the stepper and submits the create payload', async () => {
+ mutationMocks.createMember.mockResolvedValue({ id: 'new-member' })
+
+ await renderOpenCreate()
+ await fillField('member-first-name', 'Nina')
+ await fillField('member-last-name', 'Neu')
+ await fillField('member-email', 'nina.neu@club.de')
+ await fillField('member-password', 'initial-secret')
+ await submitForm() // -> contact step
+ expect(document.body.querySelector('#member-phone')).not.toBeNull()
+ await fillField('member-phone', '+49 111 2223334')
+ await submitForm() // -> notes step
+ expect(document.body.querySelector('#member-information')).not.toBeNull()
+ await submitForm() // -> create
+
+ expect(mutationMocks.createMember).toHaveBeenCalledWith({
+ first_name: 'Nina',
+ last_name: 'Neu',
+ email: 'nina.neu@club.de',
+ password: 'initial-secret',
+ phone_number: '+49 111 2223334',
+ })
+ expect(useMembersUiStore.getState().editorTarget).toBeNull()
+ expect(useMembersUiStore.getState().mutationNotice).toBe('Member created.')
+ })
+
+ it('surfaces a server 409 as the dialog form error', async () => {
+ mutationMocks.createMember.mockRejectedValue(
+ httpError(409, 'Email already in use: nina.neu@club.de'),
+ )
+
+ await renderOpenCreate()
+ await fillField('member-first-name', 'Nina')
+ await fillField('member-last-name', 'Neu')
+ await fillField('member-email', 'nina.neu@club.de')
+ await fillField('member-password', 'initial-secret')
+ await submitForm()
+ await submitForm()
+ await submitForm()
+
+ expect(document.body.textContent).toContain('Email already in use: nina.neu@club.de')
+ expect(useMembersUiStore.getState().editorTarget).not.toBeNull()
+ })
+
+ it('maps bean-validation field errors onto the form', async () => {
+ mutationMocks.createMember.mockRejectedValue(
+ httpError(400, 'Validation failed', [{ message: 'first_name: must not be blank' }]),
+ )
+
+ await renderOpenCreate()
+ await fillField('member-first-name', 'Nina')
+ await fillField('member-last-name', 'Neu')
+ await fillField('member-email', 'nina.neu@club.de')
+ await fillField('member-password', 'initial-secret')
+ await submitForm()
+ await submitForm()
+ await submitForm()
+
+ expect(document.body.textContent).toContain('Validation failed')
+ })
+})
diff --git a/web-client/src/features/members/model/useMembersViewModel.test.ts b/web-client/src/features/members/model/useMembersViewModel.test.ts
index 3981f1f2..262c19e2 100644
--- a/web-client/src/features/members/model/useMembersViewModel.test.ts
+++ b/web-client/src/features/members/model/useMembersViewModel.test.ts
@@ -80,7 +80,7 @@ describe('buildMembersView', () => {
trainees: [otherTrainee],
})
- // mock scoping (scopeMembers) already narrows this to the trainer's own trainees before
+ // scopeMembers already narrows this to the trainer's own trainees before
// buildMembersView ever sees it — mirror that here rather than passing the whole club.
const scopedMembers = [member(trainee.id, 'Trainee', 'One')]
@@ -120,7 +120,7 @@ describe('buildMembersView', () => {
trainees: [basketballTrainee],
})
- // director's sport scope (mock scopeMembers) limits rows to members of their sport's teams.
+ // director's sport scope (scopeMembers) limits rows to members of their sport's teams.
const scopedMembers = [
member(footballTrainee.id, 'Trainee', 'One'),
member(otherFootballTrainee.id, 'Trainee', 'Two'),
diff --git a/web-client/src/features/members/pages/MembersPage.test.tsx b/web-client/src/features/members/pages/MembersPage.test.tsx
new file mode 100644
index 00000000..5d9a0232
--- /dev/null
+++ b/web-client/src/features/members/pages/MembersPage.test.tsx
@@ -0,0 +1,168 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { MemberSummary } from '@/types'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'admin' as PersonaKey,
+ membersOverride: null as MemberSummary[] | null,
+ membersError: null as Error | null,
+ membersLoading: false,
+}))
+
+const mutationMocks = vi.hoisted(() => ({
+ deleteMember: vi.fn(),
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
+ }
+})
+
+vi.mock('@/features/members/api/queries', async () => {
+ const { memberSummaryFixtures } = await import('@/testing/fixtures')
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+ const { scopeMembers } = await import('@/testing/scope')
+
+ return {
+ useMembers: () => ({
+ data: mockState.membersLoading
+ ? undefined
+ : (mockState.membersOverride ??
+ scopeMembers(memberSummaryFixtures, TEST_PERSONAS[mockState.persona])),
+ isLoading: mockState.membersLoading,
+ error: mockState.membersError,
+ }),
+ useMember: () => ({ data: undefined, isLoading: false, error: null }),
+ useCreateMember: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useUpdateMember: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useDeleteMember: () => ({ mutateAsync: mutationMocks.deleteMember, isPending: false }),
+ }
+})
+
+vi.mock('@/features/organization/api/queries', async () => {
+ const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
+
+ return {
+ useSportsList: () => ({ data: sportFixtures, isLoading: false, error: null }),
+ useTeamsList: () => ({ data: teamFixtures, isLoading: false, error: null }),
+ }
+})
+
+const { MembersPage } = await import('./MembersPage')
+const { memberSummaryFixtures } = await import('@/testing/fixtures')
+const { TEST_PERSONAS } = await import('@/testing/personas')
+const { scopeMembers } = await import('@/testing/scope')
+
+describe('MembersPage', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.persona = 'admin'
+ mockState.membersOverride = null
+ mockState.membersError = null
+ mockState.membersLoading = false
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function render() {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ )
+ })
+ }
+
+ function bodyRowCount(): number {
+ return container.querySelectorAll('table tbody tr').length
+ }
+
+ function buttonNamed(name: string): HTMLButtonElement | undefined {
+ return Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent?.includes(name) || button.getAttribute('aria-label') === name,
+ )
+ }
+
+ it('renders every member with admin actions for the admin persona', async () => {
+ await render()
+
+ expect(bodyRowCount()).toBe(memberSummaryFixtures.length)
+ expect(buttonNamed('New member')).toBeDefined()
+ expect(buttonNamed('Edit Lena Roth')).toBeDefined()
+ expect(buttonNamed('Delete Lena Roth')).toBeDefined()
+ })
+
+ it('renders only the coach-scoped members without admin actions', async () => {
+ mockState.persona = 'coach'
+ const scoped = scopeMembers(memberSummaryFixtures, TEST_PERSONAS.coach)
+ const outsideScope = memberSummaryFixtures.find(
+ (member) => !scoped.some((row) => row.id === member.id),
+ )
+
+ await render()
+
+ expect(bodyRowCount()).toBe(scoped.length)
+ expect(scoped.length).toBeGreaterThan(0)
+ expect(outsideScope).toBeDefined()
+ expect(container.textContent).not.toContain(
+ `${outsideScope?.first_name} ${outsideScope?.last_name}`,
+ )
+ expect(buttonNamed('New member')).toBeUndefined()
+ expect(buttonNamed('Edit Lena Roth')).toBeUndefined()
+ })
+
+ it('shows only the member themself for the member persona', async () => {
+ mockState.persona = 'member'
+
+ await render()
+
+ expect(bodyRowCount()).toBe(1)
+ expect(container.textContent).toContain('Lena Roth')
+ })
+
+ it('shows the empty state when no members are visible', async () => {
+ mockState.membersOverride = []
+
+ await render()
+
+ expect(container.textContent).toContain('No members are listed yet.')
+ expect(container.querySelector('table')).toBeNull()
+ })
+
+ it('shows the error message when the query fails', async () => {
+ mockState.membersError = new Error('Request failed with status code 500')
+
+ await render()
+
+ expect(container.textContent).toContain('Request failed with status code 500')
+ })
+
+ it('shows the loading skeleton while fetching', async () => {
+ mockState.membersLoading = true
+
+ await render()
+
+ expect(container.querySelector('table')).toBeNull()
+ expect(container.textContent).not.toContain('No members are listed yet.')
+ })
+})
diff --git a/web-client/src/features/organization/api/queries.ts b/web-client/src/features/organization/api/queries.ts
index 74d68e3d..9a56ae07 100644
--- a/web-client/src/features/organization/api/queries.ts
+++ b/web-client/src/features/organization/api/queries.ts
@@ -1,9 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { getCurrentUser } from '@/features/auth/currentUser'
-import { memberNamesById, sportFixtures, sportsById, teamFixtures } from '@/mocks/fixtures'
-import { mockHttpError, mockOr } from '@/mocks/mockSwitch'
-import { isTeamCoach, type AuthUser, type MemberRef } from '@/types'
import { organizationClient } from './client'
import type {
Sport,
@@ -36,11 +32,7 @@ export function useSports() {
export function useSportsList(enabled = true) {
return useQuery({
queryKey: organizationKeys.sports,
- queryFn: () =>
- mockOr(
- () => Promise.resolve(sportFixtures.map(cloneSport)),
- () => organizationClient.get('/sports').then(r => r.data),
- ),
+ queryFn: () => organizationClient.get('/sports').then(r => r.data),
enabled,
staleTime: 5 * 60_000,
})
@@ -49,15 +41,7 @@ export function useSportsList(enabled = true) {
export function useSport(id: string) {
return useQuery({
queryKey: organizationKeys.sport(id),
- queryFn: () =>
- mockOr(
- () => {
- const found = sportsById[id]
- if (!found) throw new Error('Sport not found')
- return Promise.resolve(cloneSport(found))
- },
- () => organizationClient.get(`/sports/${encodeURIComponent(id)}`).then(r => r.data),
- ),
+ queryFn: () => organizationClient.get(`/sports/${encodeURIComponent(id)}`).then(r => r.data),
enabled: !!id,
})
}
@@ -66,11 +50,7 @@ export function useCreateSport() {
const qc = useQueryClient()
return useMutation({
- mutationFn: data =>
- mockOr(
- () => Promise.resolve(mockCreateSport(data)),
- () => organizationClient.post('/sports', data).then(r => r.data),
- ),
+ mutationFn: data => organizationClient.post('/sports', data).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: organizationKeys.sports }),
})
}
@@ -79,11 +59,7 @@ export function useUpdateSport() {
const qc = useQueryClient()
return useMutation({
- mutationFn: ({ id, ...data }) =>
- mockOr(
- () => Promise.resolve(mockUpdateSport({ id, ...data })),
- () => organizationClient.patch(`/sports/${id}`, data).then(r => r.data),
- ),
+ mutationFn: ({ id, ...data }) => organizationClient.patch(`/sports/${id}`, data).then(r => r.data),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: organizationKeys.sports })
qc.invalidateQueries({ queryKey: organizationKeys.sport(id) })
@@ -96,14 +72,7 @@ export function useDeleteSport() {
const qc = useQueryClient()
return useMutation({
- mutationFn: id =>
- mockOr(
- () => {
- mockDeleteSport(id)
- return Promise.resolve(undefined)
- },
- () => organizationClient.delete(`/sports/${id}`).then(() => undefined),
- ),
+ mutationFn: id => organizationClient.delete(`/sports/${id}`).then(() => undefined),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: organizationKeys.sports })
qc.invalidateQueries({ queryKey: organizationKeys.teams })
@@ -119,11 +88,7 @@ export function useTeams() {
export function useTeamsList(enabled = true) {
return useQuery({
queryKey: organizationKeys.teams,
- queryFn: () =>
- mockOr(
- () => Promise.resolve(teamFixtures.map(cloneTeam)),
- () => organizationClient.get('/teams').then(r => r.data),
- ),
+ queryFn: () => organizationClient.get('/teams').then(r => r.data),
enabled,
staleTime: 5 * 60_000,
})
@@ -132,15 +97,7 @@ export function useTeamsList(enabled = true) {
export function useTeam(id: string) {
return useQuery({
queryKey: organizationKeys.team(id),
- queryFn: () =>
- mockOr(
- () => {
- const found = teamFixtures.find(t => t.id === id)
- if (!found) throw new Error('Team not found')
- return Promise.resolve(cloneTeam(found))
- },
- () => organizationClient.get(`/teams/${id}`).then(r => r.data),
- ),
+ queryFn: () => organizationClient.get(`/teams/${id}`).then(r => r.data),
enabled: !!id,
})
}
@@ -149,11 +106,7 @@ export function useCreateTeam() {
const qc = useQueryClient()
return useMutation({
- mutationFn: data =>
- mockOr(
- () => Promise.resolve(mockCreateTeam(data)),
- () => organizationClient.post('/teams', data).then(r => r.data),
- ),
+ mutationFn: data => organizationClient.post('/teams', data).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: organizationKeys.teams }),
})
}
@@ -162,11 +115,7 @@ export function useUpdateTeam() {
const qc = useQueryClient()
return useMutation({
- mutationFn: ({ id, ...data }) =>
- mockOr(
- () => mockUpdateTeam({ id, ...data }),
- () => organizationClient.patch(`/teams/${id}`, data).then(r => r.data),
- ),
+ mutationFn: ({ id, ...data }) => organizationClient.patch(`/teams/${id}`, data).then(r => r.data),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: organizationKeys.teams })
qc.invalidateQueries({ queryKey: organizationKeys.team(id) })
@@ -178,280 +127,10 @@ export function useDeleteTeam() {
const qc = useQueryClient()
return useMutation({
- mutationFn: id =>
- mockOr(
- () => {
- mockDeleteTeam(id)
- return Promise.resolve(undefined)
- },
- () => organizationClient.delete(`/teams/${id}`).then(() => undefined),
- ),
+ mutationFn: id => organizationClient.delete(`/teams/${id}`).then(() => undefined),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: organizationKeys.teams })
qc.removeQueries({ queryKey: organizationKeys.team(id) })
},
})
}
-
-const pendingTeamUpdates = new Set()
-
-function mockCreateSport(data: SportCreate): Sport {
- const user = getCurrentUser()
- const name = data.name.trim()
-
- if (user.role !== 'admin') {
- throw mockHttpError(403, 'Only admins can create sports.')
- }
- if (!name) throw mockHttpError(400, 'Name is required.')
- assertUniqueSportName(name)
-
- const sport: Sport = {
- id: mockSportId(),
- name,
- description: data.description?.trim() ?? '',
- created_at: new Date().toISOString().slice(0, 10),
- directors: memberRefsFromIds(data.directors ?? [], (id) => `Member not found: ${id}`),
- }
-
- sportFixtures.unshift(sport)
- sportsById[sport.id] = sport
- return cloneSport(sport)
-}
-
-function mockUpdateSport({ id, ...data }: { id: string } & SportPartialUpdate): Sport {
- const user = getCurrentUser()
- const index = sportFixtures.findIndex((sport) => sport.id === id)
- const sport = sportFixtures[index]
-
- if (!sport) throw mockHttpError(404, `Sport not found: ${id}`)
- if (!canUpdateSport(sport, user)) {
- throw mockHttpError(403, 'Access denied')
- }
-
- if (data.name !== undefined) {
- const name = data.name.trim()
- if (!name) throw mockHttpError(400, 'Name is required.')
- if (name !== sport.name) assertUniqueSportName(name, id)
- }
-
- const updated: Sport = {
- ...sport,
- name: data.name !== undefined ? data.name.trim() : sport.name,
- description: data.description !== undefined ? data.description : sport.description,
- directors:
- user.role === 'admin' && data.directors !== undefined
- ? memberRefsFromIds(data.directors, (memberId) => `Member not found: ${memberId}`)
- : sport.directors,
- }
-
- sportFixtures[index] = updated
- sportsById[id] = updated
- syncTeamSportNames(updated)
- return cloneSport(updated)
-}
-
-function mockDeleteSport(id: string): void {
- const user = getCurrentUser()
- const index = sportFixtures.findIndex((sport) => sport.id === id)
-
- if (user.role !== 'admin') {
- throw mockHttpError(403, 'Only admins can delete sports.')
- }
- if (index === -1) throw mockHttpError(404, `Sport not found: ${id}`)
-
- sportFixtures.splice(index, 1)
- delete sportsById[id]
-
- for (let teamIndex = teamFixtures.length - 1; teamIndex >= 0; teamIndex -= 1) {
- if (teamFixtures[teamIndex].sport.id === id) {
- teamFixtures.splice(teamIndex, 1)
- }
- }
-}
-
-function mockCreateTeam(data: TeamCreate): Team {
- const user = getCurrentUser()
- const name = data.name.trim()
-
- if (!name) throw mockHttpError(400, 'Name is required.')
- const sport = sportsById[data.sport]
- if (!sport) throw mockHttpError(400, 'Sport not found.')
- if (!canCreateTeam(data.sport, user)) {
- throw mockHttpError(403, 'You are not allowed to create a team for this sport.')
- }
-
- const team: Team = {
- id: mockTeamId(),
- name,
- description: data.description?.trim() ?? '',
- address: data.address?.trim() ?? '',
- created_at: new Date().toISOString().slice(0, 10),
- sport: { id: sport.id, name: sport.name },
- trainers: memberRefsFromIds(data.trainers ?? []),
- trainees: memberRefsFromIds(data.trainees ?? []),
- }
-
- teamFixtures.unshift(team)
- return cloneTeam(team)
-}
-
-function mockUpdateTeam({ id, ...data }: { id: string } & TeamPartialUpdate): Promise {
- if (pendingTeamUpdates.has(id)) {
- throw mockHttpError(409, 'A team update is already in progress.')
- }
-
- pendingTeamUpdates.add(id)
- const user = getCurrentUser()
-
- try {
- const index = teamFixtures.findIndex((team) => team.id === id)
- const team = teamFixtures[index]
-
- if (!team) throw mockHttpError(404, 'Team not found')
- if (!canUpdateTeam(team, user)) {
- throw mockHttpError(403, 'You are not allowed to update this team.')
- }
- if (data.name !== undefined && data.name.trim() === '') {
- throw mockHttpError(400, 'Name is required.')
- }
- if (data.sport !== undefined && user.role !== 'admin') {
- throw mockHttpError(403, 'Only admins can change a team sport.')
- }
- if (data.trainers !== undefined && user.role !== 'admin' && user.role !== 'director') {
- throw mockHttpError(403, 'Only admins and directors can change team coaches.')
- }
-
- const nextSport = data.sport !== undefined ? sportsById[data.sport] : undefined
- if (data.sport !== undefined && !nextSport) {
- throw mockHttpError(400, 'Sport not found.')
- }
-
- const updated: Team = {
- ...team,
- name: data.name ?? team.name,
- description: data.description ?? team.description,
- address: data.address ?? team.address,
- sport: nextSport ? { id: nextSport.id, name: nextSport.name } : team.sport,
- trainers: data.trainers !== undefined ? memberRefsFromIds(data.trainers) : team.trainers,
- trainees: data.trainees !== undefined ? memberRefsFromIds(data.trainees) : team.trainees,
- }
-
- teamFixtures[index] = updated
- return Promise.resolve(cloneTeam(updated)).finally(() => {
- pendingTeamUpdates.delete(id)
- })
- } catch (error) {
- pendingTeamUpdates.delete(id)
- throw error
- }
-}
-
-function mockDeleteTeam(id: string): void {
- const user = getCurrentUser()
- const index = teamFixtures.findIndex((team) => team.id === id)
- const team = teamFixtures[index]
-
- if (!team) throw mockHttpError(404, 'Team not found')
- if (!canDeleteTeam(team, user)) {
- throw mockHttpError(403, 'You are not allowed to delete this team.')
- }
-
- teamFixtures.splice(index, 1)
-}
-
-function canCreateTeam(sportId: string, user: AuthUser): boolean {
- if (user.role === 'admin') return true
- if (user.role !== 'director') return false
-
- return isDirectorForSport(sportId, user.id)
-}
-
-function canUpdateTeam(team: Team, user: AuthUser): boolean {
- switch (user.role) {
- case 'admin':
- return true
- case 'director':
- return isDirectorForSport(team.sport.id, user.id)
- case 'trainer':
- return isTeamCoach(team, user.id)
- case 'member':
- return false
- }
-}
-
-function canDeleteTeam(team: Team, user: AuthUser): boolean {
- if (user.role === 'admin') return true
- if (user.role === 'director') return isDirectorForSport(team.sport.id, user.id)
- return false
-}
-
-function canUpdateSport(sport: Sport, user: AuthUser): boolean {
- if (user.role === 'admin') return true
- if (user.role === 'director') {
- return sport.directors.some((director) => director.id === user.id)
- }
-
- return false
-}
-
-function isDirectorForSport(sportId: string, userId: string): boolean {
- return (
- sportFixtures
- .find((sport) => sport.id === sportId)
- ?.directors.some((director) => director.id === userId) ?? false
- )
-}
-
-function assertUniqueSportName(name: string, excludeId?: string): void {
- const duplicate = sportFixtures.some((sport) => sport.id !== excludeId && sport.name === name)
- if (duplicate) throw mockHttpError(409, `Sport already exists: ${name}`)
-}
-
-function memberRefsFromIds(
- ids: string[],
- missingMessage: (id: string) => string = () => 'Member not found.',
-): MemberRef[] {
- return ids.map((id) => {
- const name = memberNamesById[id]
- if (!name) throw mockHttpError(400, missingMessage(id))
- return { id, name }
- })
-}
-
-function syncTeamSportNames(sport: Sport): void {
- for (const team of teamFixtures) {
- if (team.sport.id === sport.id) {
- team.sport = { id: sport.id, name: sport.name }
- }
- }
-}
-
-function mockSportId(): string {
- return (
- globalThis.crypto?.randomUUID?.() ??
- `cccccccc-cccc-4ccc-8ccc-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
- )
-}
-
-function mockTeamId(): string {
- return (
- globalThis.crypto?.randomUUID?.() ??
- `bbbbbbbb-bbbb-4bbb-8bbb-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
- )
-}
-
-function cloneSport(sport: Sport): Sport {
- return {
- ...sport,
- directors: sport.directors.map((director) => ({ ...director })),
- }
-}
-
-function cloneTeam(team: Team): Team {
- return {
- ...team,
- sport: { ...team.sport },
- trainers: team.trainers.map((trainer) => ({ ...trainer })),
- trainees: team.trainees.map((trainee) => ({ ...trainee })),
- }
-}
diff --git a/web-client/src/features/organization/pages/OrganizationPage.test.tsx b/web-client/src/features/organization/pages/OrganizationPage.test.tsx
new file mode 100644
index 00000000..f773f7f0
--- /dev/null
+++ b/web-client/src/features/organization/pages/OrganizationPage.test.tsx
@@ -0,0 +1,131 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'member' as PersonaKey,
+ teamsError: null as Error | null,
+ teamsLoading: false,
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
+ }
+})
+
+vi.mock('@/features/organization/api/queries', async () => {
+ const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
+
+ const mutation = () => ({ mutateAsync: vi.fn(), isPending: false })
+
+ return {
+ useSportsList: () => ({ data: sportFixtures, isLoading: false, error: null }),
+ useTeamsList: () => ({
+ data: mockState.teamsLoading ? undefined : teamFixtures,
+ isLoading: mockState.teamsLoading,
+ error: mockState.teamsError,
+ }),
+ useSport: () => ({ data: undefined, isLoading: false, error: null }),
+ useTeam: () => ({ data: undefined, isLoading: false, error: null }),
+ useCreateSport: mutation,
+ useUpdateSport: mutation,
+ useDeleteSport: mutation,
+ useCreateTeam: mutation,
+ useUpdateTeam: mutation,
+ useDeleteTeam: mutation,
+ }
+})
+
+vi.mock('@/features/members/api/queries', async () => {
+ const { memberSummaryFixtures } = await import('@/testing/fixtures')
+
+ return {
+ useMembers: () => ({ data: memberSummaryFixtures, isLoading: false, error: null }),
+ }
+})
+
+const { OrganizationPage } = await import('./OrganizationPage')
+const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
+
+describe('OrganizationPage', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.persona = 'member'
+ mockState.teamsError = null
+ mockState.teamsLoading = false
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function render() {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ )
+ })
+ }
+
+ function buttonNamed(name: string): HTMLButtonElement | undefined {
+ return Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim() === name,
+ )
+ }
+
+ it('lists every sport and its teams for the member persona without manage actions', async () => {
+ await render()
+
+ for (const sport of sportFixtures) {
+ expect(container.textContent).toContain(sport.name)
+ }
+ expect(container.textContent).toContain(teamFixtures[0].name)
+ expect(buttonNamed('New sport')).toBeUndefined()
+ expect(buttonNamed('New team')).toBeUndefined()
+ })
+
+ it('gives the admin sport and team creation entry points', async () => {
+ mockState.persona = 'admin'
+
+ await render()
+
+ expect(buttonNamed('New sport')).toBeDefined()
+ expect(buttonNamed('New team')).toBeDefined()
+ })
+
+ it('gives the director a team creation entry point but no sport creation', async () => {
+ mockState.persona = 'director'
+
+ await render()
+
+ expect(buttonNamed('New sport')).toBeUndefined()
+ expect(buttonNamed('New team')).toBeDefined()
+ })
+
+ it('renders the loading state while the teams query is in flight', async () => {
+ // The page has no dedicated query-error branch — it renders loading, empty,
+ // or the sports list; deletion errors are surfaced per-dialog instead.
+ mockState.teamsLoading = true
+
+ await render()
+
+ expect(container.textContent).not.toContain(teamFixtures[0].name)
+ })
+})
diff --git a/web-client/src/features/payments/api/queries.ts b/web-client/src/features/payments/api/queries.ts
index 7e257f7a..239a5157 100644
--- a/web-client/src/features/payments/api/queries.ts
+++ b/web-client/src/features/payments/api/queries.ts
@@ -1,15 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { getCurrentUser } from '@/features/auth/currentUser'
-import {
- balanceFixtures,
- memberNamesById,
- transactionFixtures,
-} from '@/mocks/fixtures'
-import { mockHttpError, mockOr } from '@/mocks/mockSwitch'
-import { directorManagesMember, scopeBalances, scopeTransactions, trainerManagesMember } from '@/mocks/scope'
import { paymentsClient } from './client'
-import type { AuthUser, Reference } from '@/types'
import type { Balance, Transaction, TransactionCreate, TransactionPartialUpdate } from '../types'
export const paymentsKeys = {
@@ -32,15 +23,7 @@ export function useBalances(enabled = true) {
queryKey: paymentsKeys.balances,
staleTime: 30_000,
enabled,
- queryFn: () =>
- mockOr(
- () => {
- const user = getCurrentUser()
- if (user.role === 'member') throw mockHttpError(403, 'Access denied')
- return Promise.resolve(scopeBalances(balanceFixtures, user))
- },
- () => paymentsClient.get('/balances').then(r => r.data),
- ),
+ queryFn: () => paymentsClient.get('/balances').then(r => r.data),
})
}
@@ -48,16 +31,7 @@ export function useMemberBalance(memberId: string) {
return useQuery({
queryKey: paymentsKeys.balance(memberId),
staleTime: 30_000,
- queryFn: () =>
- mockOr(
- () => {
- const found = balanceFixtures.find(balance => balance.member.id === memberId)
- const scoped = found ? scopeBalances([found], getCurrentUser()) : []
- if (!scoped[0]) throw new Error('Balance not found')
- return Promise.resolve(scoped[0])
- },
- () => paymentsClient.get(`/balances/${memberId}`).then(r => r.data),
- ),
+ queryFn: () => paymentsClient.get(`/balances/${memberId}`).then(r => r.data),
enabled: !!memberId,
})
}
@@ -67,11 +41,7 @@ export function useTransactions(enabled = true) {
queryKey: paymentsKeys.transactions,
staleTime: 30_000,
enabled,
- queryFn: () =>
- mockOr(
- () => Promise.resolve(scopeTransactions(transactionFixtures, getCurrentUser())),
- () => paymentsClient.get('/transactions').then(r => r.data),
- ),
+ queryFn: () => paymentsClient.get('/transactions').then(r => r.data),
})
}
@@ -79,16 +49,7 @@ export function useTransaction(id: string) {
return useQuery({
queryKey: paymentsKeys.transaction(id),
staleTime: 30_000,
- queryFn: () =>
- mockOr(
- () => {
- const found = transactionFixtures.find(transaction => transaction.id === id)
- const scoped = found ? scopeTransactions([found], getCurrentUser()) : []
- if (!scoped[0]) throw new Error('Transaction not found')
- return Promise.resolve(scoped[0])
- },
- () => paymentsClient.get(`/transactions/${id}`).then(r => r.data),
- ),
+ queryFn: () => paymentsClient.get(`/transactions/${id}`).then(r => r.data),
enabled: !!id,
})
}
@@ -97,11 +58,7 @@ export function useCreateTransaction() {
const qc = useQueryClient()
return useMutation({
- mutationFn: data =>
- mockOr(
- () => Promise.resolve(mockCreateTransaction(data)),
- () => paymentsClient.post('/transactions', data).then(r => r.data),
- ),
+ mutationFn: data => paymentsClient.post('/transactions', data).then(r => r.data),
onSuccess: (transaction) => {
qc.invalidateQueries({ queryKey: paymentsKeys.transactions })
qc.invalidateQueries({ queryKey: paymentsKeys.balances })
@@ -114,11 +71,7 @@ export function useUpdateTransaction() {
const qc = useQueryClient()
return useMutation({
- mutationFn: ({ id, ...data }) =>
- mockOr(
- () => Promise.resolve(mockUpdateTransaction({ id, ...data })),
- () => paymentsClient.patch(`/transactions/${id}`, data).then(r => r.data),
- ),
+ mutationFn: ({ id, ...data }) => paymentsClient.patch(`/transactions/${id}`, data).then(r => r.data),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: paymentsKeys.transactions })
qc.invalidateQueries({ queryKey: paymentsKeys.balances })
@@ -131,14 +84,7 @@ export function useDeleteTransaction() {
const qc = useQueryClient()
return useMutation({
- mutationFn: id =>
- mockOr(
- () => {
- mockDeleteTransaction(id)
- return Promise.resolve(undefined)
- },
- () => paymentsClient.delete(`/transactions/${id}`).then(() => undefined),
- ),
+ mutationFn: id => paymentsClient.delete(`/transactions/${id}`).then(() => undefined),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: paymentsKeys.transactions })
qc.invalidateQueries({ queryKey: paymentsKeys.balances })
@@ -146,127 +92,3 @@ export function useDeleteTransaction() {
},
})
}
-
-function mockCreateTransaction(data: TransactionCreate): Transaction {
- const user = getCurrentUser()
- const member = memberRef(data.member)
- const title = data.title.trim()
-
- if (!title) throw mockHttpError(400, 'Title is required.')
- if (!canCreateTransactionForMember(user, data.member)) {
- throw mockHttpError(403, 'You are not allowed to create transactions for this member.')
- }
-
- const transaction: Transaction = {
- id: mockTransactionId(),
- member,
- creator: { id: user.id, name: user.name },
- amount_cents: data.amount_cents,
- created_at: new Date().toISOString(),
- title,
- description: data.description?.trim() ?? '',
- }
-
- transactionFixtures.unshift(transaction)
- syncMockBalance(member)
- return cloneTransaction(transaction)
-}
-
-function mockUpdateTransaction({ id, ...data }: { id: string } & TransactionPartialUpdate): Transaction {
- const user = getCurrentUser()
- const index = transactionFixtures.findIndex((transaction) => transaction.id === id)
- const existing = transactionFixtures[index]
-
- if (!existing) throw mockHttpError(404, 'Transaction not found.')
- // Same gate delete uses: only the creator or an admin may modify a transaction.
- if (user.role !== 'admin' && existing.creator?.id !== user.id) {
- throw mockHttpError(403, 'You are not allowed to update this transaction.')
- }
-
- // Resolve a new member only if the caller is moving the transaction; keep the ref otherwise.
- const nextMember = data.member !== undefined ? memberRef(data.member) : existing.member
-
- const title = data.title !== undefined ? data.title.trim() : existing.title
- if (!title) throw mockHttpError(400, 'Title is required.')
-
- const updated: Transaction = {
- ...existing,
- member: nextMember,
- amount_cents: data.amount_cents !== undefined ? data.amount_cents : existing.amount_cents,
- title,
- description: data.description !== undefined ? data.description.trim() : existing.description,
- }
-
- transactionFixtures[index] = updated
-
- // Re-sync the affected member balance(s). If the member changed, both the old and the new
- // member's balances must be recomputed.
- if (existing.member.id !== updated.member.id) {
- syncMockBalance(existing.member)
- }
- syncMockBalance(updated.member)
-
- return cloneTransaction(updated)
-}
-
-function mockDeleteTransaction(id: string): void {
- const user = getCurrentUser()
- const index = transactionFixtures.findIndex((transaction) => transaction.id === id)
- const transaction = transactionFixtures[index]
-
- if (!transaction) throw mockHttpError(404, 'Transaction not found.')
- if (user.role !== 'admin' && transaction.creator?.id !== user.id) {
- throw mockHttpError(403, 'You are not allowed to delete this transaction.')
- }
-
- transactionFixtures.splice(index, 1)
- syncMockBalance(transaction.member)
-}
-
-function memberRef(memberId: string): Reference {
- const name = memberNamesById[memberId]
- if (!name) throw mockHttpError(404, 'Member not found.')
- return { id: memberId, name }
-}
-
-function canCreateTransactionForMember(user: AuthUser, memberId: string): boolean {
- if (user.role === 'admin') return true
- if (user.role === 'director') return directorManagesMember(user.id, memberId)
- if (user.role === 'trainer') return trainerManagesMember(user.id, memberId)
- return false
-}
-
-function syncMockBalance(member: Reference): void {
- const balanceCents = transactionFixtures.reduce(
- (sum, transaction) =>
- transaction.member.id === member.id ? sum + transaction.amount_cents : sum,
- 0,
- )
- const index = balanceFixtures.findIndex((balance) => balance.member.id === member.id)
- const balance: Balance = {
- member: { ...member },
- balance_cents: balanceCents,
- }
-
- if (index === -1) {
- balanceFixtures.push(balance)
- return
- }
-
- balanceFixtures[index] = balance
-}
-
-function mockTransactionId(): string {
- return (
- globalThis.crypto?.randomUUID?.() ??
- `cccccccc-cccc-4ccc-8ccc-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
- )
-}
-
-function cloneTransaction(transaction: Transaction): Transaction {
- return {
- ...transaction,
- member: { ...transaction.member },
- creator: transaction.creator ? { ...transaction.creator } : null,
- }
-}
diff --git a/web-client/src/features/payments/pages/PaymentsPage.test.tsx b/web-client/src/features/payments/pages/PaymentsPage.test.tsx
new file mode 100644
index 00000000..5c633a77
--- /dev/null
+++ b/web-client/src/features/payments/pages/PaymentsPage.test.tsx
@@ -0,0 +1,295 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'admin' as PersonaKey,
+ transactionsError: null as Error | null,
+}))
+
+const mutationMocks = vi.hoisted(() => ({
+ createTransaction: vi.fn(),
+ deleteTransaction: vi.fn(),
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
+ }
+})
+
+vi.mock('@/features/payments/api/queries', async () => {
+ const { balanceFixtures, transactionFixtures } = await import('@/testing/fixtures')
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+ const { scopeBalances, scopeTransactions } = await import('@/testing/scope')
+
+ return {
+ useTransactions: (enabled = true) => ({
+ data: enabled
+ ? scopeTransactions(transactionFixtures, TEST_PERSONAS[mockState.persona])
+ : undefined,
+ isLoading: false,
+ error: mockState.transactionsError,
+ }),
+ useBalances: (enabled = true) => ({
+ data:
+ enabled && TEST_PERSONAS[mockState.persona].role !== 'member'
+ ? scopeBalances(balanceFixtures, TEST_PERSONAS[mockState.persona])
+ : undefined,
+ isLoading: false,
+ error: null,
+ }),
+ useCreateTransaction: () => ({
+ mutateAsync: mutationMocks.createTransaction,
+ isPending: false,
+ }),
+ useDeleteTransaction: () => ({
+ mutateAsync: mutationMocks.deleteTransaction,
+ isPending: false,
+ }),
+ }
+})
+
+vi.mock('@/features/members/api/queries', async () => {
+ const { memberSummaryFixtures } = await import('@/testing/fixtures')
+
+ return {
+ useMembers: () => ({ data: memberSummaryFixtures, isLoading: false, error: null }),
+ }
+})
+
+vi.mock('@/features/organization/api/queries', async () => {
+ const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
+
+ return {
+ useSportsList: () => ({ data: sportFixtures, isLoading: false, error: null }),
+ useTeamsList: () => ({ data: teamFixtures, isLoading: false, error: null }),
+ }
+})
+
+const { PaymentsPage } = await import('./PaymentsPage')
+const { usePaymentsUiStore } = await import('../model/paymentsUiStore')
+const { balanceFixtures, transactionFixtures } = await import('@/testing/fixtures')
+const { httpError } = await import('@/testing/httpError')
+const { TEST_PERSONAS } = await import('@/testing/personas')
+const { scopeBalances, scopeTransactions } = await import('@/testing/scope')
+
+describe('PaymentsPage', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.persona = 'admin'
+ mockState.transactionsError = null
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function render() {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ )
+ })
+ }
+
+ function buttonNamed(name: string): HTMLButtonElement | undefined {
+ return Array.from(container.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes(name),
+ )
+ }
+
+ it('renders the managed lens with balances for the admin', async () => {
+ await render()
+
+ const scopedBalances = scopeBalances(balanceFixtures, TEST_PERSONAS.admin)
+
+ expect(container.textContent).toContain('Member balances and transaction history.')
+ expect(container.textContent).toContain('Balances')
+ expect(buttonNamed('New Transaction')).toBeDefined()
+ expect(container.textContent).toContain(scopedBalances[0].member.name)
+ })
+
+ it('renders the managed lens scoped to the director sports', async () => {
+ mockState.persona = 'director'
+ const scopedBalances = scopeBalances(balanceFixtures, TEST_PERSONAS.director)
+
+ await render()
+
+ expect(container.textContent).toContain('Member balances and transaction history.')
+ expect(scopedBalances.length).toBeGreaterThan(0)
+ expect(container.textContent).toContain(scopedBalances[0].member.name)
+ })
+
+ it('renders the self view for the member persona', async () => {
+ mockState.persona = 'member'
+ const scoped = scopeTransactions(transactionFixtures, TEST_PERSONAS.member)
+
+ await render()
+
+ expect(container.textContent).toContain('Your balance and transaction history.')
+ expect(buttonNamed('New Transaction')).toBeUndefined()
+ if (scoped.length === 0) {
+ expect(container.textContent).toContain('No transactions are listed yet.')
+ } else {
+ expect(container.querySelectorAll('table tbody tr').length).toBe(scoped.length)
+ }
+ })
+
+ it('surfaces a transactions query error', async () => {
+ mockState.transactionsError = new Error('Request failed with status code 500')
+
+ await render()
+
+ expect(container.textContent).toContain('Request failed with status code 500')
+ })
+
+ describe('transaction create dialog', () => {
+ async function fillField(id: string, value: string) {
+ const element = document.body.querySelector(`#${id}`)
+ expect(element, `#${id} should be rendered`).not.toBeNull()
+ const valueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value',
+ )?.set
+
+ await act(async () => {
+ valueSetter?.call(element, value)
+ element?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ }
+
+ async function submitDialogForm() {
+ const form = document.body.querySelector('[role="dialog"] form')
+ expect(form).not.toBeNull()
+ await act(async () => {
+ form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ })
+ }
+
+ async function openDialog() {
+ await render()
+ await act(async () => {
+ usePaymentsUiStore.getState().openCreateDialog()
+ })
+ }
+
+ afterEach(async () => {
+ await act(async () => {
+ usePaymentsUiStore.getState().closeCreateDialog()
+ })
+ })
+
+ it('requires a member selection before anything else', async () => {
+ await openDialog()
+ await fillField('payment-amount', '25')
+ await fillField('payment-title', 'Court fee')
+ await submitDialogForm()
+
+ expect(document.body.textContent).toContain('Select a member.')
+ expect(mutationMocks.createTransaction).not.toHaveBeenCalled()
+ })
+
+ it('rejects a malformed amount', async () => {
+ await openDialog()
+ await pickMember('Marie Wolf')
+ await fillField('payment-amount', 'twelve')
+ await fillField('payment-title', 'Court fee')
+ await submitDialogForm()
+
+ expect(document.body.textContent).toContain(
+ 'Enter an amount in euros with up to two decimals.',
+ )
+ expect(mutationMocks.createTransaction).not.toHaveBeenCalled()
+ })
+
+ it('sends charges as negative cents and closes on success', async () => {
+ mutationMocks.createTransaction.mockResolvedValue({
+ id: 'new-transaction',
+ member: { id: 'member-marie', name: 'Marie Wolf' },
+ })
+
+ await openDialog()
+ await pickMember('Marie Wolf')
+ await fillField('payment-amount', '25.50')
+ await fillField('payment-title', 'Court fee')
+ await submitDialogForm()
+
+ expect(mutationMocks.createTransaction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ amount_cents: -2550,
+ title: 'Court fee',
+ }),
+ )
+ expect(usePaymentsUiStore.getState().isCreateDialogOpen).toBe(false)
+ expect(usePaymentsUiStore.getState().mutationNotice).toBe(
+ 'Transaction recorded for Marie Wolf.',
+ )
+ })
+
+ it('maps server field errors onto the form', async () => {
+ mutationMocks.createTransaction.mockRejectedValue(
+ httpError(400, 'Validation failed', [{ message: 'title: must not be blank' }]),
+ )
+
+ await openDialog()
+ await pickMember('Marie Wolf')
+ await fillField('payment-amount', '25')
+ await fillField('payment-title', 'x')
+ await submitDialogForm()
+
+ expect(document.body.textContent).toContain('Validation failed')
+ expect(document.body.textContent).toContain('must not be blank')
+ expect(usePaymentsUiStore.getState().isCreateDialogOpen).toBe(true)
+ })
+
+ // Opens the member combobox popover and clicks the named option. Radix
+ // triggers listen for the pointer sequence, not just a bare click.
+ async function pickMember(name: string) {
+ const trigger = document.body.querySelector('#payment-member')
+ expect(trigger).not.toBeNull()
+ await act(async () => {
+ trigger?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
+ trigger?.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
+ trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))
+ })
+
+ // The picker caps visible results, so narrow via the popover's own search
+ // input (the only with a combobox role; toolbar searches are plain).
+ const search = document.body.querySelector('input[role="combobox"]')
+ expect(search).not.toBeNull()
+ const valueSetter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value',
+ )?.set
+ await act(async () => {
+ valueSetter?.call(search, name)
+ search?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+
+ const option = Array.from(
+ document.body.querySelectorAll('[role="option"]'),
+ ).find((candidate) => candidate.textContent?.includes(name))
+ expect(option, `member option ${name} should be listed`).toBeDefined()
+ await act(async () => {
+ option?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ }
+ })
+})
diff --git a/web-client/src/features/profile/pages/ProfilePage.test.tsx b/web-client/src/features/profile/pages/ProfilePage.test.tsx
index e2c35fe0..ee1d49cd 100644
--- a/web-client/src/features/profile/pages/ProfilePage.test.tsx
+++ b/web-client/src/features/profile/pages/ProfilePage.test.tsx
@@ -159,16 +159,33 @@ describe('ProfilePage', () => {
})
})
- it('keeps identity and admin-only fields out of the self-service form', async () => {
+ it('lets the member edit their name but not their email', async () => {
await render()
- expect((field('profile-first-name') as HTMLInputElement).readOnly).toBe(true)
- expect((field('profile-last-name') as HTMLInputElement).readOnly).toBe(true)
+ expect((field('profile-first-name') as HTMLInputElement).readOnly).toBe(false)
+ expect((field('profile-last-name') as HTMLInputElement).readOnly).toBe(false)
expect((field('profile-email') as HTMLInputElement).readOnly).toBe(true)
- expect(container.textContent).toContain('Managed in your account.')
expect(container.textContent).not.toContain('Password')
expect(container.textContent).not.toContain('Role')
expect(container.textContent).not.toContain('Team')
+
+ await changeField('profile-first-name', 'Magda')
+ await submitProfileForm()
+
+ expect(memberQueryMocks.mutateAsync).toHaveBeenCalledWith({
+ id: currentUserMock.user.id,
+ first_name: 'Magda',
+ })
+ })
+
+ it('blocks submit with a validation message when identity fields are blanked', async () => {
+ await render()
+
+ await changeField('profile-first-name', '')
+ await submitProfileForm()
+
+ expect(memberQueryMocks.mutateAsync).not.toHaveBeenCalled()
+ expect(container.textContent).toContain('First name is required.')
})
it('resets unsaved app-owned edits on cancel', async () => {
diff --git a/web-client/src/features/profile/pages/ProfilePage.tsx b/web-client/src/features/profile/pages/ProfilePage.tsx
index 02bbae68..e9561f0b 100644
--- a/web-client/src/features/profile/pages/ProfilePage.tsx
+++ b/web-client/src/features/profile/pages/ProfilePage.tsx
@@ -12,8 +12,9 @@ import { getCurrentUser } from '@/features/auth/currentUser'
import { useMember, useUpdateMember } from '@/features/members/api/queries'
import {
buildMemberEditorInitialState,
- buildMemberProfileUpdatePayload,
+ buildMemberUpdatePayload,
type MemberEditorFormState,
+ validateMemberEditorForm,
} from '@/features/members/model/memberEditor'
import { serverErrorFieldMessages, serverErrorMessage } from '@/lib/server-error'
import type { Member } from '@/types'
@@ -80,7 +81,7 @@ function ProfileForm({ currentUserId, member }: { currentUserId: string; member:
const [notice, setNotice] = useState(null)
const [formError, setFormError] = useState(null)
const [fieldErrors, setFieldErrors] = useState | null>(null)
- const payload = useMemo(() => buildMemberProfileUpdatePayload(member, form), [form, member])
+ const payload = useMemo(() => buildMemberUpdatePayload(member, form), [form, member])
const hasChanges = Object.keys(payload).length > 0
const isPending = updateMember.isPending
@@ -94,6 +95,14 @@ function ProfileForm({ currentUserId, member }: { currentUserId: string; member:
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
+ const validationError = validateMemberEditorForm(form)
+ if (validationError) {
+ setFormError(validationError)
+ setFieldErrors(null)
+ setNotice(null)
+ return
+ }
+
if (!hasChanges) {
setNotice('No profile changes to save.')
setFormError(null)
@@ -131,13 +140,39 @@ function ProfileForm({ currentUserId, member }: { currentUserId: string; member:
Identity
- Name and email are managed by your account identity.
+ Update your name below. Email is managed by your account and can't be changed here.
-
-
+
+
+
setForm({ ...form, firstName: event.target.value })}
+ disabled={isPending}
+ aria-invalid={fieldErrors?.first_name !== undefined}
+ />
+ {fieldErrors?.first_name && (
+
{fieldErrors.first_name}
+ )}
+
+
+
+
+
setForm({ ...form, lastName: event.target.value })}
+ disabled={isPending}
+ aria-invalid={fieldErrors?.last_name !== undefined}
+ />
+ {fieldErrors?.last_name && (
+
{fieldErrors.last_name}
+ )}
+
+
-
-
-
- )
-}
-
function ProfileFormSkeleton() {
return (
diff --git a/web-client/src/features/sport-events/api/queries.ts b/web-client/src/features/sport-events/api/queries.ts
index c73a66ad..c58a225d 100644
--- a/web-client/src/features/sport-events/api/queries.ts
+++ b/web-client/src/features/sport-events/api/queries.ts
@@ -1,17 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { getCurrentUser } from '@/features/auth/currentUser'
-import {
- eventDetailsById,
- eventSummaryFixtures,
- memberNamesById,
- sportFixtures,
- teamFixtures,
-} from '@/mocks/fixtures'
-import { mockOr } from '@/mocks/mockSwitch'
-import { scopeEvents } from '@/mocks/scope'
import { sportEventsClient } from './client'
-import type { AuthUser, EventListItem, Reference } from '@/types'
+import type { EventListItem } from '@/types'
import type { SportEvent, EventCreate, EventPartialUpdate } from '../types'
export const eventKeys = {
@@ -23,167 +13,6 @@ export const eventKeys = {
export const sportEventsKeys = eventKeys
-const mockEventDetailsById: Record
= { ...eventDetailsById }
-let mockEventSummaryRows: EventListItem[] = eventSummaryFixtures.map((event) => ({
- ...event,
- attendees: event.attendees?.map((attendee) => ({ ...attendee })),
- sports_linked: event.sports_linked?.map((sport) => ({ ...sport })),
- teams_linked: event.teams_linked?.map((team) => ({ ...team })),
-}))
-
-function mockEventError(message: string): Error {
- return new Error(message)
-}
-
-function mockEventId(): string {
- return globalThis.crypto?.randomUUID?.() ?? `eeeeeeee-eeee-4eee-8eee-${Date.now().toString(16).padStart(12, '0').slice(-12)}`
-}
-
-function uniqueIds(ids: string[] | undefined): string[] {
- return Array.from(new Set(ids ?? []))
-}
-
-function memberRef(id: string): Reference {
- const name = memberNamesById[id]
- if (!name) throw mockEventError('Member not found')
- return { id, name }
-}
-
-function sportRef(id: string): Reference {
- const sport = sportFixtures.find((item) => item.id === id)
- if (!sport) throw mockEventError('Sport not found')
- return { id: sport.id, name: sport.name }
-}
-
-function teamRef(id: string): Reference {
- const team = teamFixtures.find((item) => item.id === id)
- if (!team) throw mockEventError('Team not found')
- return { id: team.id, name: team.name }
-}
-
-function eventSummary(event: SportEvent): EventListItem {
- return {
- id: event.id,
- name: event.name,
- start_time: event.start_time,
- end_time: event.end_time,
- attendees: event.attendees?.map((attendee) => ({ ...attendee })),
- sports_linked: event.sports_linked?.map((sport) => ({ ...sport })),
- teams_linked: event.teams_linked?.map((team) => ({ ...team })),
- }
-}
-
-function validateEventTimes(startTime: string, endTime: string): void {
- if (Number.isNaN(new Date(startTime).getTime()) || Number.isNaN(new Date(endTime).getTime())) {
- throw mockEventError('Start and end time are required')
- }
-
- if (new Date(endTime) <= new Date(startTime)) {
- throw mockEventError('End time must be after start time')
- }
-}
-
-function canManageEvent(user: AuthUser, event: SportEvent): boolean {
- return user.role === 'admin' || event.creator?.id === user.id
-}
-
-function canCreateMockEvent(user: AuthUser): boolean {
- return user.role === 'trainer' || user.role === 'director' || user.role === 'admin'
-}
-
-function upsertMockEvent(event: SportEvent): void {
- mockEventDetailsById[event.id] = event
-
- const summary = eventSummary(event)
- const index = mockEventSummaryRows.findIndex((row) => row.id === event.id)
-
- if (index === -1) {
- mockEventSummaryRows = [summary, ...mockEventSummaryRows]
- return
- }
-
- mockEventSummaryRows = mockEventSummaryRows.map((row) =>
- row.id === event.id ? summary : row,
- )
-}
-
-function mockCreateEvent(data: EventCreate): SportEvent {
- const user = getCurrentUser()
- if (!canCreateMockEvent(user)) {
- throw mockEventError('You are not allowed to create events')
- }
- const name = data.name.trim()
-
- if (!name) throw mockEventError('Name is required')
- validateEventTimes(data.start_time, data.end_time)
-
- const event: SportEvent = {
- id: mockEventId(),
- name,
- description: data.description?.trim() ?? '',
- start_time: data.start_time,
- end_time: data.end_time,
- attendees: uniqueIds(data.attendees).map(memberRef),
- sports_linked: uniqueIds(data.sports_linked).map(sportRef),
- teams_linked: uniqueIds(data.teams_linked).map(teamRef),
- creator: { id: user.id, name: user.name },
- }
-
- upsertMockEvent(event)
- return event
-}
-
-function mockUpdateEvent({ id, ...data }: { id: string } & EventPartialUpdate): SportEvent {
- const user = getCurrentUser()
- const current = mockEventDetailsById[id]
-
- if (!current) throw mockEventError('Event not found')
- if (!canManageEvent(user, current)) {
- throw mockEventError('You are not allowed to update this event')
- }
-
- const name = data.name === undefined ? current.name : data.name.trim()
- if (!name) throw mockEventError('Name is required')
-
- const startTime = data.start_time ?? current.start_time
- const endTime = data.end_time ?? current.end_time
- validateEventTimes(startTime, endTime)
-
- const event: SportEvent = {
- ...current,
- name,
- description: data.description === undefined ? current.description : data.description.trim(),
- start_time: startTime,
- end_time: endTime,
- attendees:
- data.attendees === undefined ? current.attendees : uniqueIds(data.attendees).map(memberRef),
- sports_linked:
- data.sports_linked === undefined
- ? current.sports_linked
- : uniqueIds(data.sports_linked).map(sportRef),
- teams_linked:
- data.teams_linked === undefined
- ? current.teams_linked
- : uniqueIds(data.teams_linked).map(teamRef),
- }
-
- upsertMockEvent(event)
- return event
-}
-
-function mockDeleteEvent(id: string): void {
- const user = getCurrentUser()
- const event = mockEventDetailsById[id]
-
- if (!event) throw mockEventError('Event not found')
- if (!canManageEvent(user, event)) {
- throw mockEventError('You are not allowed to delete this event')
- }
-
- delete mockEventDetailsById[id]
- mockEventSummaryRows = mockEventSummaryRows.filter((row) => row.id !== id)
-}
-
export function useSportEventsHello() {
return useQuery({
queryKey: eventKeys.hello,
@@ -196,29 +25,14 @@ export function useEventsList(enabled = true) {
queryKey: eventKeys.list(),
staleTime: 30_000,
enabled,
- queryFn: () =>
- mockOr(
- () => Promise.resolve(scopeEvents(mockEventSummaryRows, getCurrentUser())),
- () => sportEventsClient.get('').then(r => r.data),
- ),
+ queryFn: () => sportEventsClient.get('').then(r => r.data),
})
}
export function useEvent(id: string | null | undefined) {
return useQuery({
queryKey: eventKeys.detail(id),
- queryFn: () =>
- mockOr(
- () => {
- const found = id ? mockEventDetailsById[id] : undefined
- const scoped = found ? scopeEvents([found], getCurrentUser()) : []
- if (!scoped[0]) {
- throw new Error('Event not found')
- }
- return Promise.resolve(scoped[0])
- },
- () => sportEventsClient.get(`/${id}`).then(r => r.data),
- ),
+ queryFn: () => sportEventsClient.get(`/${id}`).then(r => r.data),
enabled: !!id,
})
}
@@ -230,11 +44,7 @@ export function useCreateSportEvent() {
const qc = useQueryClient()
return useMutation({
- mutationFn: data =>
- mockOr(
- () => Promise.resolve(mockCreateEvent(data)),
- () => sportEventsClient.post('', data).then(r => r.data),
- ),
+ mutationFn: data => sportEventsClient.post('', data).then(r => r.data),
onSuccess: (event) => {
qc.setQueryData(eventKeys.detail(event.id), event)
qc.invalidateQueries({ queryKey: eventKeys.all })
@@ -246,11 +56,7 @@ export function useUpdateSportEvent() {
const qc = useQueryClient()
return useMutation({
- mutationFn: ({ id, ...data }) =>
- mockOr(
- () => Promise.resolve(mockUpdateEvent({ id, ...data })),
- () => sportEventsClient.patch(`/${id}`, data).then(r => r.data),
- ),
+ mutationFn: ({ id, ...data }) => sportEventsClient.patch(`/${id}`, data).then(r => r.data),
onSuccess: (event, { id }) => {
qc.setQueryData(eventKeys.detail(id), event)
qc.invalidateQueries({ queryKey: eventKeys.all })
@@ -263,14 +69,7 @@ export function useDeleteSportEvent() {
const qc = useQueryClient()
return useMutation({
- mutationFn: id =>
- mockOr(
- () => {
- mockDeleteEvent(id)
- return Promise.resolve(undefined)
- },
- () => sportEventsClient.delete(`/${id}`).then(() => undefined),
- ),
+ mutationFn: id => sportEventsClient.delete(`/${id}`).then(() => undefined),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: eventKeys.all })
qc.removeQueries({ queryKey: eventKeys.detail(id) })
diff --git a/web-client/src/features/sport-events/components/SportEventEditorDialog.test.tsx b/web-client/src/features/sport-events/components/SportEventEditorDialog.test.tsx
new file mode 100644
index 00000000..d858dc3d
--- /dev/null
+++ b/web-client/src/features/sport-events/components/SportEventEditorDialog.test.tsx
@@ -0,0 +1,181 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'admin' as PersonaKey,
+}))
+
+const mutationMocks = vi.hoisted(() => ({
+ createEvent: vi.fn(),
+ updateEvent: vi.fn(),
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
+ }
+})
+
+vi.mock('@/features/sport-events/api/queries', () => ({
+ useEvent: () => ({ data: undefined, isLoading: false, error: null }),
+ useCreateSportEvent: () => ({ mutateAsync: mutationMocks.createEvent, isPending: false }),
+ useUpdateSportEvent: () => ({ mutateAsync: mutationMocks.updateEvent, isPending: false }),
+}))
+
+vi.mock('@/features/organization/api/queries', async () => {
+ const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
+
+ return {
+ useSportsList: () => ({ data: sportFixtures, isLoading: false, error: null }),
+ useTeamsList: () => ({ data: teamFixtures, isLoading: false, error: null }),
+ }
+})
+
+vi.mock('@/components/ui/date-picker', () => ({
+ DatePicker: ({
+ id,
+ value,
+ onChange,
+ }: {
+ id?: string
+ value: string
+ onChange: (value: string) => void
+ }) => onChange(event.target.value)} />,
+ DateTimePicker: ({
+ id,
+ value,
+ onChange,
+ }: {
+ id?: string
+ value: string
+ onChange: (value: string) => void
+ }) => onChange(event.target.value)} />,
+}))
+
+const { SportEventEditorDialog } = await import('./SportEventEditorDialog')
+const { useEventsUiStore } = await import('../model/eventsUiStore')
+
+describe('SportEventEditorDialog', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.persona = 'admin'
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ useEventsUiStore.getState().closeEditor()
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function renderOpenCreate() {
+ await act(async () => {
+ root.render()
+ })
+ await act(async () => {
+ useEventsUiStore.getState().openCreate()
+ })
+ }
+
+ async function fillField(id: string, value: string) {
+ const element = document.body.querySelector(`#${id}`)
+ expect(element, `#${id} should be rendered`).not.toBeNull()
+ const prototype =
+ element instanceof HTMLTextAreaElement
+ ? window.HTMLTextAreaElement.prototype
+ : window.HTMLInputElement.prototype
+ const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
+
+ await act(async () => {
+ valueSetter?.call(element, value)
+ element?.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ }
+
+ async function submitForm() {
+ const form = document.body.querySelector('[role="dialog"] form')
+ expect(form).not.toBeNull()
+ await act(async () => {
+ form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
+ })
+ }
+
+ it('requires a name on the details step', async () => {
+ await renderOpenCreate()
+ await submitForm()
+
+ expect(document.body.textContent).toContain('Name is required.')
+ expect(mutationMocks.createEvent).not.toHaveBeenCalled()
+ })
+
+ it('rejects an end time before the start time on the schedule step', async () => {
+ await renderOpenCreate()
+ await fillField('event-name', 'Friendly Match')
+ await submitForm() // -> schedule
+
+ await fillField('event-start', '2026-08-01T18:00')
+ await fillField('event-end', '2026-08-01T17:00')
+ await submitForm()
+
+ expect(document.body.textContent).toContain('End time must be after start time.')
+ expect(mutationMocks.createEvent).not.toHaveBeenCalled()
+ })
+
+ it('submits the create payload with ISO times', async () => {
+ mutationMocks.createEvent.mockResolvedValue({ id: 'new-event' })
+
+ await renderOpenCreate()
+ await fillField('event-name', 'Friendly Match')
+ await submitForm() // -> schedule
+ await fillField('event-start', '2026-08-01T18:00')
+ await fillField('event-end', '2026-08-01T19:30')
+ await submitForm() // -> sports & teams
+ await submitForm() // -> attendees
+ await submitForm() // -> create
+
+ expect(mutationMocks.createEvent).toHaveBeenCalledWith({
+ name: 'Friendly Match',
+ description: undefined,
+ start_time: new Date('2026-08-01T18:00').toISOString(),
+ end_time: new Date('2026-08-01T19:30').toISOString(),
+ sports_linked: [],
+ teams_linked: [],
+ attendees: [],
+ })
+ expect(useEventsUiStore.getState().editorTarget).toBeNull()
+ expect(useEventsUiStore.getState().mutationNotice).toBe('Event created.')
+ })
+
+ it('pre-links the coach persona teams and roster on create', async () => {
+ mockState.persona = 'coach'
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+ const { teamFixtures } = await import('@/testing/fixtures/organization')
+ const coachTeamIds = teamFixtures
+ .filter((team) => team.trainers.some((trainer) => trainer.id === TEST_PERSONAS.coach.id))
+ .map((team) => team.id)
+ mutationMocks.createEvent.mockResolvedValue({ id: 'new-event' })
+
+ await renderOpenCreate()
+ await fillField('event-name', 'Team Training')
+ await submitForm()
+ await submitForm()
+ await submitForm()
+ await submitForm()
+
+ const payload = mutationMocks.createEvent.mock.calls[0][0]
+ expect(payload.teams_linked).toEqual(coachTeamIds)
+ expect(payload.attendees.length).toBeGreaterThan(0)
+ })
+})
diff --git a/web-client/src/features/sport-events/model/useEventsViewModel.test.ts b/web-client/src/features/sport-events/model/useEventsViewModel.test.ts
index 2606dd26..4ba1107b 100644
--- a/web-client/src/features/sport-events/model/useEventsViewModel.test.ts
+++ b/web-client/src/features/sport-events/model/useEventsViewModel.test.ts
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
-import { eventDetailsById, eventSummaryFixtures, teamFixtures } from '@/mocks/fixtures'
-import { MOCK_PERSONAS } from '@/mocks/personas'
-import { scopeEvents } from '@/mocks/scope'
+import { eventDetailsById, eventSummaryFixtures, teamFixtures } from '@/testing/fixtures'
+import { TEST_PERSONAS } from '@/testing/personas'
+import { scopeEvents } from '@/testing/scope'
import type { EventListItem, Team } from '@/types'
import { buildEventsView, eventAttendanceStatus, userTeamIds } from './useEventsViewModel'
@@ -98,7 +98,7 @@ describe('eventAttendanceStatus', () => {
})
it('derives missed, attended and upcoming rows for the member persona fixtures', () => {
- const member = MOCK_PERSONAS.member
+ const member = TEST_PERSONAS.member
const view = buildEventsView(
scopeEvents(eventSummaryFixtures, member),
now,
@@ -116,7 +116,7 @@ describe('eventAttendanceStatus', () => {
})
it("collapses a coach's own past event to a role-agnostic past status", () => {
- const coach = MOCK_PERSONAS.coach
+ const coach = TEST_PERSONAS.coach
const coachOwnedPastEvent = Object.values(eventDetailsById).find(
(eventDetail) =>
eventDetail.creator?.id === coach.id && new Date(eventDetail.start_time) < now,
diff --git a/web-client/src/features/sport-events/pages/SportEventsPage.test.tsx b/web-client/src/features/sport-events/pages/SportEventsPage.test.tsx
new file mode 100644
index 00000000..f4bbf170
--- /dev/null
+++ b/web-client/src/features/sport-events/pages/SportEventsPage.test.tsx
@@ -0,0 +1,145 @@
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { MemoryRouter } from 'react-router-dom'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PersonaKey = 'member' | 'coach' | 'director' | 'admin'
+
+const mockState = vi.hoisted(() => ({
+ persona: 'admin' as PersonaKey,
+ eventsError: null as Error | null,
+ eventsLoading: false,
+}))
+
+vi.mock('@/features/auth', async () => {
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+
+ return {
+ useAuth: () => ({ user: TEST_PERSONAS[mockState.persona] }),
+ }
+})
+
+vi.mock('@/features/sport-events/api/queries', async () => {
+ const { eventSummaryFixtures } = await import('@/testing/fixtures')
+ const { TEST_PERSONAS } = await import('@/testing/personas')
+ const { scopeEvents } = await import('@/testing/scope')
+
+ return {
+ useEventsList: () => ({
+ data: mockState.eventsLoading
+ ? undefined
+ : scopeEvents(eventSummaryFixtures, TEST_PERSONAS[mockState.persona]),
+ isLoading: mockState.eventsLoading,
+ error: mockState.eventsError,
+ }),
+ useEvent: () => ({ data: undefined, isLoading: false, error: null }),
+ useCreateSportEvent: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useUpdateSportEvent: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useDeleteSportEvent: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ }
+})
+
+vi.mock('@/features/organization/api/queries', async () => {
+ const { sportFixtures, teamFixtures } = await import('@/testing/fixtures/organization')
+
+ return {
+ useSportsList: () => ({ data: sportFixtures, isLoading: false, error: null }),
+ useTeamsList: () => ({ data: teamFixtures, isLoading: false, error: null }),
+ }
+})
+
+const { SportEventsPage } = await import('./SportEventsPage')
+const { eventSummaryFixtures } = await import('@/testing/fixtures')
+const { TEST_PERSONAS } = await import('@/testing/personas')
+const { scopeEvents } = await import('@/testing/scope')
+
+describe('SportEventsPage', () => {
+ let container: HTMLDivElement
+ let root: Root
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockState.persona = 'admin'
+ mockState.eventsError = null
+ mockState.eventsLoading = false
+ document.body.innerHTML = ''
+ container = document.getElementById('root') as HTMLDivElement
+ root = createRoot(container)
+ })
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount()
+ })
+ document.body.innerHTML = ''
+ })
+
+ async function render() {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ )
+ })
+ }
+
+ function buttonNamed(name: string): HTMLButtonElement | undefined {
+ return Array.from(container.querySelectorAll('button')).find((button) =>
+ button.textContent?.includes(name),
+ )
+ }
+
+ function bodyRowCount(): number {
+ return container.querySelectorAll('table tbody tr').length
+ }
+
+ it('shows every event and the create button for the admin', async () => {
+ await render()
+
+ expect(bodyRowCount()).toBe(eventSummaryFixtures.length)
+ expect(buttonNamed('New event')).toBeDefined()
+ // Admin may manage every event (creator-or-admin rule).
+ expect(
+ container.querySelector(`button[aria-label="Delete ${eventSummaryFixtures[0].name}"]`),
+ ).not.toBeNull()
+ })
+
+ it('scopes rows to the member persona team events and hides the create button', async () => {
+ mockState.persona = 'member'
+ const scoped = scopeEvents(eventSummaryFixtures, TEST_PERSONAS.member)
+
+ await render()
+
+ expect(bodyRowCount()).toBe(scoped.length)
+ expect(scoped.length).toBeGreaterThan(0)
+ expect(scoped.length).toBeLessThan(eventSummaryFixtures.length)
+ expect(buttonNamed('New event')).toBeUndefined()
+ })
+
+ it('shows the coach persona their scoped events with a create button', async () => {
+ mockState.persona = 'coach'
+ const scoped = scopeEvents(eventSummaryFixtures, TEST_PERSONAS.coach)
+
+ await render()
+
+ expect(bodyRowCount()).toBe(scoped.length)
+ expect(buttonNamed('New event')).toBeDefined()
+ })
+
+ it('surfaces a query error', async () => {
+ mockState.eventsError = new Error('Request failed with status code 500')
+
+ await render()
+
+ expect(container.textContent).toContain('Request failed with status code 500')
+ })
+
+ it('renders the loading skeleton while fetching', async () => {
+ mockState.eventsLoading = true
+
+ await render()
+
+ expect(container.querySelector('table')).toBeNull()
+ })
+})
diff --git a/web-client/src/lib/keycloak.ts b/web-client/src/lib/keycloak.ts
index a76ee441..27e3ce79 100644
--- a/web-client/src/lib/keycloak.ts
+++ b/web-client/src/lib/keycloak.ts
@@ -53,7 +53,7 @@ export function createApiClient(baseURL: string): AxiosInstance {
try {
await refreshTokenIfNeeded(TOKEN_REFRESH_MIN_VALIDITY_SECONDS)
} catch {
- // Let the backend's 401 decide when we need a full re-auth redirect.
+ // Let the server's 401 decide when we need a full re-auth redirect.
}
if (keycloak.token) {
config.headers.Authorization = `Bearer ${keycloak.token}`
diff --git a/web-client/src/mocks/mockSwitch.ts b/web-client/src/mocks/mockSwitch.ts
deleted file mode 100644
index 060c2570..00000000
--- a/web-client/src/mocks/mockSwitch.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-// VITE_USE_MOCKS=true => queries serve fixtures; otherwise they hit the backend.
-export const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === 'true'
-
-// Thunks so the unused branch is never evaluated.
-export function mockOr(mock: () => T, live: () => T): T {
- return USE_MOCKS ? mock() : live()
-}
-
-// Builds an axios-shaped error so mock branches can reject like the live API does.
-// `errors` mirrors the server's bean-validation 400 shape ({message, errors: [{message}]})
-// so per-field form UIs can be exercised without a live backend.
-export function mockHttpError(
- status: number,
- message: string,
- errors?: { message: string }[],
-): Error {
- return Object.assign(new Error(message), {
- isAxiosError: true,
- response: {
- status,
- data: errors ? { message, errors } : { message },
- },
- })
-}
diff --git a/web-client/src/mocks/fixtures/dashboard.ts b/web-client/src/testing/fixtures/dashboard.ts
similarity index 90%
rename from web-client/src/mocks/fixtures/dashboard.ts
rename to web-client/src/testing/fixtures/dashboard.ts
index 6948ffa8..4b825479 100644
--- a/web-client/src/mocks/fixtures/dashboard.ts
+++ b/web-client/src/testing/fixtures/dashboard.ts
@@ -5,8 +5,8 @@ import type {
Reference,
TeamBalanceSummary,
} from '@/types'
-import type { MockPersonaKey } from '../personas'
-import { MOCK_PERSONAS } from '../personas'
+import type { TestPersonaKey } from '../personas'
+import { TEST_PERSONAS } from '../personas'
import { scopeEvents, scopeFeedback } from '../scope'
import { eventSummaryFixtures } from './events'
import { feedbackSummaryFixtures } from './feedback'
@@ -52,8 +52,8 @@ function sportRef(id: string): Reference {
return { id, name: sport?.name ?? '' }
}
-function dashboardFor(key: MockPersonaKey): Dashboard {
- const user = MOCK_PERSONAS[key]
+function dashboardFor(key: TestPersonaKey): Dashboard {
+ const user = TEST_PERSONAS[key]
switch (user.role) {
case 'member':
@@ -116,7 +116,7 @@ function dashboardFor(key: MockPersonaKey): Dashboard {
}
}
-export const dashboardFixtures: Record = {
+export const dashboardFixtures: Record = {
member: dashboardFor('member'),
coach: dashboardFor('coach'),
director: dashboardFor('director'),
@@ -125,14 +125,14 @@ export const dashboardFixtures: Record = {
// Pick the pre-built envelope for the current persona (matched by id, role as fallback).
export function dashboardForUser(user: AuthUser): Dashboard {
- const byId = (Object.keys(dashboardFixtures) as MockPersonaKey[]).find(
- (key) => MOCK_PERSONAS[key].id === user.id,
+ const byId = (Object.keys(dashboardFixtures) as TestPersonaKey[]).find(
+ (key) => TEST_PERSONAS[key].id === user.id,
)
if (byId) return dashboardFixtures[byId]
const role = user.role
- const byRole = (Object.keys(dashboardFixtures) as MockPersonaKey[]).find(
- (key) => MOCK_PERSONAS[key].role === role,
+ const byRole = (Object.keys(dashboardFixtures) as TestPersonaKey[]).find(
+ (key) => TEST_PERSONAS[key].role === role,
)
return dashboardFixtures[byRole ?? 'member']
}
diff --git a/web-client/src/mocks/fixtures/events.ts b/web-client/src/testing/fixtures/events.ts
similarity index 100%
rename from web-client/src/mocks/fixtures/events.ts
rename to web-client/src/testing/fixtures/events.ts
diff --git a/web-client/src/mocks/fixtures/feedback.ts b/web-client/src/testing/fixtures/feedback.ts
similarity index 100%
rename from web-client/src/mocks/fixtures/feedback.ts
rename to web-client/src/testing/fixtures/feedback.ts
diff --git a/web-client/src/mocks/fixtures/finance.ts b/web-client/src/testing/fixtures/finance.ts
similarity index 100%
rename from web-client/src/mocks/fixtures/finance.ts
rename to web-client/src/testing/fixtures/finance.ts
diff --git a/web-client/src/mocks/fixtures/index.ts b/web-client/src/testing/fixtures/index.ts
similarity index 100%
rename from web-client/src/mocks/fixtures/index.ts
rename to web-client/src/testing/fixtures/index.ts
diff --git a/web-client/src/mocks/fixtures/members.ts b/web-client/src/testing/fixtures/members.ts
similarity index 99%
rename from web-client/src/mocks/fixtures/members.ts
rename to web-client/src/testing/fixtures/members.ts
index e5d7410a..e91bdf08 100644
--- a/web-client/src/mocks/fixtures/members.ts
+++ b/web-client/src/testing/fixtures/members.ts
@@ -1,6 +1,6 @@
import type { Member, MemberSummary } from '@/types'
-// Signed-in member (`sub`) for the default mock persona.
+// Signed-in member (`sub`) for the default test persona.
export const CURRENT_MEMBER_ID = '11111111-1111-1111-1111-111111111111'
export const memberFixtures: Member[] = [
diff --git a/web-client/src/mocks/fixtures/organization.ts b/web-client/src/testing/fixtures/organization.ts
similarity index 100%
rename from web-client/src/mocks/fixtures/organization.ts
rename to web-client/src/testing/fixtures/organization.ts
diff --git a/web-client/src/mocks/fixtures/report.ts b/web-client/src/testing/fixtures/report.ts
similarity index 100%
rename from web-client/src/mocks/fixtures/report.ts
rename to web-client/src/testing/fixtures/report.ts
diff --git a/web-client/src/testing/httpError.ts b/web-client/src/testing/httpError.ts
new file mode 100644
index 00000000..b5c10507
--- /dev/null
+++ b/web-client/src/testing/httpError.ts
@@ -0,0 +1,15 @@
+// Test-only: builds an axios-shaped error so tests can reject like the live API does.
+// `errors` mirrors the server's bean-validation 400 shape ({message, errors: [{message}]}).
+export function httpError(
+ status: number,
+ message: string,
+ errors?: { message: string }[],
+): Error {
+ return Object.assign(new Error(message), {
+ isAxiosError: true,
+ response: {
+ status,
+ data: errors ? { message, errors } : { message },
+ },
+ })
+}
diff --git a/web-client/src/mocks/personas.ts b/web-client/src/testing/personas.ts
similarity index 81%
rename from web-client/src/mocks/personas.ts
rename to web-client/src/testing/personas.ts
index 20ed50b5..93d64d6d 100644
--- a/web-client/src/mocks/personas.ts
+++ b/web-client/src/testing/personas.ts
@@ -1,9 +1,9 @@
import type { AuthUser } from '@/types'
// Persona ids are real members in the fixtures. `coach` maps to auth role `trainer`.
-export type MockPersona = AuthUser
+export type TestPersona = AuthUser
-export const MOCK_PERSONAS = {
+export const TEST_PERSONAS = {
"member": {
"id": "11111111-1111-1111-1111-111111111111",
"role": "member",
@@ -28,6 +28,6 @@ export const MOCK_PERSONAS = {
"name": "Admin Devoops",
"email": "admin.devoops@club.de"
}
-} as const satisfies Record
+} as const satisfies Record
-export type MockPersonaKey = keyof typeof MOCK_PERSONAS
+export type TestPersonaKey = keyof typeof TEST_PERSONAS
diff --git a/web-client/src/mocks/scope.test.ts b/web-client/src/testing/scope.test.ts
similarity index 97%
rename from web-client/src/mocks/scope.test.ts
rename to web-client/src/testing/scope.test.ts
index 0df2faa3..41fa99b9 100644
--- a/web-client/src/mocks/scope.test.ts
+++ b/web-client/src/testing/scope.test.ts
@@ -11,7 +11,7 @@ import {
teamFixtures,
transactionFixtures,
} from './fixtures'
-import { MOCK_PERSONAS } from './personas'
+import { TEST_PERSONAS } from './personas'
import {
scopeBalances,
scopeEvents,
@@ -24,10 +24,10 @@ import {
type EventRow = EventListItem | SportEvent
const users = {
- member: MOCK_PERSONAS.member,
- trainer: MOCK_PERSONAS.coach,
- director: MOCK_PERSONAS.director,
- admin: MOCK_PERSONAS.admin,
+ member: TEST_PERSONAS.member,
+ trainer: TEST_PERSONAS.coach,
+ director: TEST_PERSONAS.director,
+ admin: TEST_PERSONAS.admin,
} satisfies Record
function ids(rows: { id: string }[]): string[] {
diff --git a/web-client/src/mocks/scope.ts b/web-client/src/testing/scope.ts
similarity index 92%
rename from web-client/src/mocks/scope.ts
rename to web-client/src/testing/scope.ts
index 4ddc177a..d211b5ef 100644
--- a/web-client/src/mocks/scope.ts
+++ b/web-client/src/testing/scope.ts
@@ -96,10 +96,14 @@ function eventInDirectorScope(row: EventRow, user: AuthUser): boolean {
return eventHasSport(row, sports) || eventHasTeam(row, sportTeamIds(sports))
}
+// Admin branches copy the array: the live API returns a fresh array per response,
+// and TanStack Query's structural sharing keeps the previous `data` identity when a
+// refetch yields the same reference — which would hide in-place fixture mutations
+// (create/update/delete) from memoized view-models until a reload.
export function scopeFeedback(rows: T[], user: AuthUser): T[] {
switch (user.role) {
case 'admin':
- return rows
+ return [...rows]
case 'trainer':
return rows.filter((row) => row.creator?.id === user.id)
case 'member':
@@ -112,7 +116,7 @@ export function scopeFeedback(rows: T[], user: AuthUs
export function scopeTransactions(rows: T[], user: AuthUser): T[] {
switch (user.role) {
case 'admin':
- return rows
+ return [...rows]
case 'member':
return rows.filter((row) => row.member.id === user.id || row.creator?.id === user.id)
case 'director': {
@@ -130,7 +134,7 @@ export function scopeTransactions(rows: T[], user: AuthUs
export function scopeBalances(rows: T[], user: AuthUser): T[] {
switch (user.role) {
case 'admin':
- return rows
+ return [...rows]
case 'member':
return rows.filter((row) => row.member.id === user.id)
case 'director': {
@@ -145,7 +149,7 @@ export function scopeBalances(rows: T[], user: AuthUser): T[]
export function scopeEvents(rows: T[], user: AuthUser): T[] {
switch (user.role) {
case 'admin':
- return rows
+ return [...rows]
case 'member':
return rows.filter((row) => eventInMemberScope(row, user))
case 'trainer':
@@ -158,7 +162,7 @@ export function scopeEvents(rows: T[], user: AuthUser): T[]
export function scopeMembers(rows: T[], user: AuthUser): T[] {
switch (user.role) {
case 'admin':
- return rows
+ return [...rows]
case 'member':
return rows.filter((row) => row.id === user.id)
case 'trainer': {
diff --git a/web-client/src/types.test.ts b/web-client/src/types.test.ts
index 36164af2..11f73732 100644
--- a/web-client/src/types.test.ts
+++ b/web-client/src/types.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
-import { dashboardFixtures } from '@/mocks/fixtures/dashboard'
+import { dashboardFixtures } from '@/testing/fixtures/dashboard'
import { creatorName, memberRefName, type Reference } from './types'
// These lock the two shapes PR #99 actually ships (see the reconciliation task):
diff --git a/web-client/src/vite-env.d.ts b/web-client/src/vite-env.d.ts
index e890a41c..5bc35947 100644
--- a/web-client/src/vite-env.d.ts
+++ b/web-client/src/vite-env.d.ts
@@ -3,10 +3,6 @@
interface ImportMetaEnv {
/** Keycloak base URL (e.g. http://localhost:8081/auth). */
readonly VITE_KEYCLOAK_URL?: string
- /** 'true' => feature queries serve fixtures instead of the backend. */
- readonly VITE_USE_MOCKS?: string
- /** Identity override: 'member' | 'coach' | 'director' | 'admin'. */
- readonly VITE_MOCK_PERSONA?: string
}
interface ImportMeta {
diff --git a/web-client/tsconfig.e2e.json b/web-client/tsconfig.e2e.json
new file mode 100644
index 00000000..cad3ffe4
--- /dev/null
+++ b/web-client/tsconfig.e2e.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.e2e.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["e2e", "playwright.config.ts"]
+}
diff --git a/web-client/tsconfig.json b/web-client/tsconfig.json
index 1ffef600..a999c8a6 100644
--- a/web-client/tsconfig.json
+++ b/web-client/tsconfig.json
@@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.e2e.json" }
]
}
diff --git a/web-client/vite.config.ts b/web-client/vite.config.ts
index bc44dfcb..0f8a9b03 100644
--- a/web-client/vite.config.ts
+++ b/web-client/vite.config.ts
@@ -35,6 +35,9 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
passWithNoTests: true,
+ // Heavy page/dialog component tests exceed the 5s default when v8 coverage
+ // instrumentation is on (pnpm test:coverage); the suite itself stays fast.
+ testTimeout: 15_000,
setupFiles: ['src/setupTests.ts'],
include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}'],
coverage: {