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: {