From 2dfb1e8bae684df7c091072bf341ea5780818a09 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sun, 10 May 2026 15:24:33 +0200 Subject: [PATCH] test(e2e): real-backend 409 conflict contract test (TEST-CONTRACT-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B4 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1). - Add api/database/seeders/E2EBaselineSeeder.php — deterministic seed for Playwright e2e: e2e@test.local user (org_admin) on a fresh org + event + stage + StageDay + artist + engagement + performance (version=0). Writes seeded IDs to api/storage/app/e2e-fixtures.json so the Playwright fixture can construct API URLs without API discovery calls. - Add apps/app/tests/playwright-e2e/global-setup.ts — runs `php artisan migrate:fresh --force --seed` against crewli_test (the existing PHPUnit MySQL test DB) before the test suite starts. Uses --env=testing to satisfy the dangerous-bash hook's migrate:fresh guard. - Add apps/app/tests/playwright-e2e/utils/fixtures.ts — typed reader for e2e-fixtures.json. Cached after first read. - Add apps/app/tests/playwright-e2e/utils/auth.ts — login helper that POSTs /api/v1/auth/login and returns user/org IDs. Uses Bearer-via- cookie flow (per api/.../SetAuthCookie.php), not stateful Sanctum. - Add apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts — the contract test: first move with version=0 returns 200, second move with same stale version returns 409 with shape `errors.conflict: 'version_mismatch'`. Catches the schema-drift bug class that timetable-stabilization B5 surfaced. - Update apps/app/playwright.config.ts — wire globalSetup, webServer for `php artisan serve --port=8001`, baseURL `http://localhost:8001` (NOT 127.0.0.1 — auth cookie's domain=localhost requires hostname match). - Update .gitignore — runtime e2e-fixtures.json never committed. DoD-19 met locally: `pnpm test:e2e` passes against a real Laravel test server. CI integration deferred to TEST-INFRA-002 (per A-1 amendment). Constraint: e2e tests share the crewli_test DB with PHPUnit. Running both concurrently would collide. Documented in ARCH-TESTING.md (B5). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 + api/database/seeders/E2EBaselineSeeder.php | 112 ++++++++++++++++++ apps/app/playwright.config.ts | 48 ++++++-- apps/app/tests/playwright-e2e/global-setup.ts | 72 +++++++++++ .../timetable/409-conflict.spec.ts | 112 ++++++++++++++++++ apps/app/tests/playwright-e2e/utils/auth.ts | 60 ++++++++++ .../tests/playwright-e2e/utils/fixtures.ts | 42 +++++++ 7 files changed, 440 insertions(+), 10 deletions(-) create mode 100644 api/database/seeders/E2EBaselineSeeder.php create mode 100644 apps/app/tests/playwright-e2e/global-setup.ts create mode 100644 apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts create mode 100644 apps/app/tests/playwright-e2e/utils/auth.ts create mode 100644 apps/app/tests/playwright-e2e/utils/fixtures.ts diff --git a/.gitignore b/.gitignore index 6a973024..2d89c75c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ apps/app/playwright-report/ apps/app/blob-report/ apps/app/playwright/.cache/ +# Playwright e2e seed-data fixture file — written by E2EBaselineSeeder +# during globalSetup, never source-of-truth. +api/storage/app/e2e-fixtures.json + # Misc *.pem .cache/ diff --git a/api/database/seeders/E2EBaselineSeeder.php b/api/database/seeders/E2EBaselineSeeder.php new file mode 100644 index 00000000..c6e9321e --- /dev/null +++ b/api/database/seeders/E2EBaselineSeeder.php @@ -0,0 +1,112 @@ +call(RoleSeeder::class); + + $org = Organisation::factory()->create([ + 'name' => 'E2E Test Organisation', + ]); + + $user = User::factory()->create([ + 'email' => 'e2e@test.local', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ]); + $org->users()->attach($user, ['role' => 'org_admin']); + + $event = Event::factory()->create([ + 'organisation_id' => $org->id, + 'name' => 'E2E Test Festival', + 'start_date' => CarbonImmutable::now()->subDay(), + 'end_date' => CarbonImmutable::now()->addDays(30), + ]); + + $stage = Stage::factory()->create([ + 'event_id' => $event->id, + 'name' => 'E2E Stage', + ]); + + StageDay::query()->create([ + 'stage_id' => $stage->id, + 'event_id' => $event->id, + ]); + + $artist = Artist::factory()->create([ + 'organisation_id' => $org->id, + 'name' => 'E2E Artist', + ]); + + $engagement = ArtistEngagement::factory()->create([ + 'artist_id' => $artist->id, + 'event_id' => $event->id, + ]); + + $start = CarbonImmutable::now()->addDays(2)->setTime(20, 0); + Performance::factory()->create([ + 'engagement_id' => $engagement->id, + 'event_id' => $event->id, + 'stage_id' => $stage->id, + 'lane' => 0, + 'start_at' => $start, + 'end_at' => $start->addHour(), + 'version' => 0, + ]); + + $performance = Performance::query() + ->where('event_id', $event->id) + ->where('stage_id', $stage->id) + ->first(); + + // Write seeded IDs to a known location the Playwright e2e + // fixture reads. Avoids artisan-stdout-parsing fragility. + $fixturePath = storage_path('app/e2e-fixtures.json'); + @mkdir(dirname($fixturePath), 0755, true); + file_put_contents($fixturePath, json_encode([ + 'user_email' => 'e2e@test.local', + 'user_password' => 'password', + 'organisation_id' => $org->id, + 'event_id' => $event->id, + 'stage_id' => $stage->id, + 'performance_id' => $performance?->id, + ], JSON_PRETTY_PRINT)); + + $this->command?->info("E2E fixtures written to {$fixturePath}"); + } +} diff --git a/apps/app/playwright.config.ts b/apps/app/playwright.config.ts index 8b6dfb26..588023ca 100644 --- a/apps/app/playwright.config.ts +++ b/apps/app/playwright.config.ts @@ -1,8 +1,32 @@ import { defineConfig, devices } from '@playwright/test' -// E2E config — drives a real Vite dev server + a real Laravel test -// server. Used by `pnpm test:e2e`. Component tests live in -// playwright-ct.config.ts (different runner). +// E2E config — drives a real Laravel test server. Used by `pnpm test:e2e`. +// Component tests live in playwright-ct.config.ts (different runner). +// +// Architecture (B4 / TEST-CONTRACT-001): +// - globalSetup runs `php artisan migrate:fresh --force --seed` against +// crewli_test (the existing PHPUnit MySQL test DB) using the +// E2EBaselineSeeder. Writes seeded IDs to api/storage/app/e2e-fixtures.json. +// - webServer auto-starts `php artisan serve --port=8001` against the +// same DB so the test can hit a live HTTP endpoint. +// - Tests use page.context().request to call /api/v1/* with cookie auth +// established via /api/v1/auth/login (Bearer-via-cookie, not stateful +// Sanctum SPA flow — see api/.../SetAuthCookie.php). +// +// SCOPE: contract tests only (request shape verification). UI-driven e2e +// tests would additionally need the Vite dev server (port 5174). Add that +// to webServer when first UI-driven e2e test lands. +// +// CI integration: deferred to TEST-INFRA-002. This config currently +// targets local-machine execution. + +const E2E_API_PORT = Number(process.env.E2E_API_PORT ?? 8001) + +// Use `localhost` (not 127.0.0.1) so the auth cookie set with +// `domain=localhost` (per api/.env's SESSION_DOMAIN) is matched on +// subsequent requests. Playwright's APIRequestContext respects +// cookie scope strictly. +const E2E_API_URL = `http://localhost:${E2E_API_PORT}` export default defineConfig({ testDir: './tests/playwright-e2e', @@ -11,13 +35,17 @@ export default defineConfig({ retries: 0, workers: 1, reporter: process.env.CI ? 'github' : 'list', + globalSetup: './tests/playwright-e2e/global-setup.ts', use: { - baseURL: process.env.E2E_FRONTEND_URL ?? 'http://localhost:5173', + baseURL: E2E_API_URL, trace: 'off', video: 'off', screenshot: 'off', viewport: { width: 1440, height: 900 }, + extraHTTPHeaders: { + Accept: 'application/json', + }, }, projects: [ @@ -27,14 +55,14 @@ export default defineConfig({ }, ], - // Auto-start the SPA dev server. Laravel's test server is started - // by the per-test fixture in tests/playwright-e2e/fixtures/laravel.ts - // because its lifecycle requires per-run seed control. + // Auto-start the Laravel test server. globalSetup runs first + // (migrate:fresh + seed), then this command spawns artisan serve + // against the now-seeded crewli_test DB. webServer: { - command: 'pnpm dev', - url: process.env.E2E_FRONTEND_URL ?? 'http://localhost:5173', + command: 'cd ../../api && DB_DATABASE=crewli_test php artisan --env=testing serve --port=8001', + url: `${E2E_API_URL}/up`, reuseExistingServer: !process.env.CI, - timeout: 120_000, + timeout: 60_000, stdout: 'ignore', stderr: 'pipe', }, diff --git a/apps/app/tests/playwright-e2e/global-setup.ts b/apps/app/tests/playwright-e2e/global-setup.ts new file mode 100644 index 00000000..2a3e888a --- /dev/null +++ b/apps/app/tests/playwright-e2e/global-setup.ts @@ -0,0 +1,72 @@ +import { execSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Playwright globalSetup — runs once before all e2e tests. +// +// Responsibilities: +// 1. Verify api/.env exists (Laravel needs APP_KEY etc.) +// 2. Run `php artisan migrate:fresh --force --seed --seeder=E2EBaselineSeeder` +// against crewli_test (override DB_DATABASE on the command line). +// The seeder writes seeded IDs to api/storage/app/e2e-fixtures.json. +// 3. Verify the fixtures file was produced. +// +// Failures here abort the test run before any spec runs — prevents +// confusing per-test failures when the test DB is in an unexpected state. +// +// Why crewli_test (and not a dedicated crewli_e2e DB): +// The PHPUnit suite already uses crewli_test with `RefreshDatabase` +// (transaction-rollback per test). Running e2e and PHPUnit +// concurrently would collide; the lifecycle here assumes serial +// execution, which is the realistic local-dev flow. Documented in +// dev-docs/ARCH-TESTING.md §5 (mock-vs-real-backend). + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..') +const API_DIR = path.join(REPO_ROOT, 'api') +const FIXTURES_PATH = path.join(API_DIR, 'storage', 'app', 'e2e-fixtures.json') + +export default async function globalSetup(): Promise { + console.log('[e2e setup] Repo root:', REPO_ROOT) + console.log('[e2e setup] API dir:', API_DIR) + + if (!existsSync(path.join(API_DIR, '.env'))) { + throw new Error( + '[e2e setup] api/.env not found. Run `cp api/.env.example api/.env` and configure DB_* + APP_KEY.', + ) + } + + console.log('[e2e setup] Running migrate:fresh + E2EBaselineSeeder against crewli_test...') + + // --env=testing avoids the dangerous-bash hook's migrate:fresh guard + // (which requires --env=testing to permit the destructive op). + // DB_DATABASE override forces crewli_test even though the .env may + // point at the dev DB. + try { + execSync( + 'DB_DATABASE=crewli_test php artisan --env=testing migrate:fresh --force --seed --seeder="Database\\\\Seeders\\\\E2EBaselineSeeder"', + { + cwd: API_DIR, + stdio: 'inherit', + env: { ...process.env, DB_DATABASE: 'crewli_test' }, + }, + ) + } + catch (err) { + throw new Error(`[e2e setup] migrate:fresh + seed failed: ${(err as Error).message}`) + } + + if (!existsSync(FIXTURES_PATH)) { + throw new Error( + `[e2e setup] Expected fixtures file at ${FIXTURES_PATH} but it does not exist. ` + + 'Did E2EBaselineSeeder run? Check the artisan output above.', + ) + } + + const fixtures = JSON.parse(readFileSync(FIXTURES_PATH, 'utf-8')) as Record + + console.log('[e2e setup] Fixtures ready:', Object.keys(fixtures).join(', ')) +} diff --git a/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts b/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts new file mode 100644 index 00000000..571f0205 --- /dev/null +++ b/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test' + +import { loginAsBaselineUser } from '../utils/auth' +import { readFixtures } from '../utils/fixtures' + +// ============================================================================= +// TEST-CONTRACT-001 — 409 conflict shape on optimistic-locking failure +// ============================================================================= +// +// Why this test exists: +// --------------------- +// The timetable-stabilization sprint's B5 incident (commit ff056e3) +// caught a schema-drift bug where the frontend's Zod schema for +// MoveTimetableConflictResponse diverged from the backend's response +// shape. Vitest+jsdom tests passed because they mocked both sides. +// This test hits the REAL backend and validates the response shape +// against what the SPA actually parses. +// +// Scenario: +// --------- +// 1. Login as e2e@test.local (seeded via E2EBaselineSeeder). +// 2. POST /timetable/move with version=0 → expect 200 (succeeds; perf +// version becomes 1). +// 3. POST same move again with version=0 → expect 409 with shape: +// { success: false, errors: { conflict: 'version_mismatch' }, ... } +// 4. Assert structural properties of the 409 response so future +// backend changes that drift this shape FAIL the test instead of +// silently passing through mock-equipped Vitest assertions. +// +// What this test does NOT cover: +// ------------------------------ +// - Multi-context race (two real browsers): single-context replay is +// functionally equivalent for contract validation; the 409 is +// returned by the server based on stored version, not on session +// identity. +// - UI rollback behaviour: that's a future UI-driven e2e that needs +// the Vite webServer running. Out of B4 scope. +// - Exhaustive 4xx/5xx variants: only the 409 shape is the documented +// regression risk. +// ============================================================================= + +test.describe('TEST-CONTRACT-001 — timetable move 409 conflict', () => { + test('second move with stale version returns 409 with version_mismatch', async ({ browser }) => { + const fixtures = readFixtures() + const context = await browser.newContext({ baseURL: process.env.E2E_API_URL ?? 'http://localhost:8001' }) + const { request, organisationId } = await loginAsBaselineUser(context) + + expect(organisationId).toBe(fixtures.organisation_id) + + const moveUrl = `/api/v1/organisations/${fixtures.organisation_id}/events/${fixtures.event_id}/timetable/move` + + // Use a stable target time independent of the original seed, so + // we control where the perf lands. Take the seeded perf's + // engagement window and shift by an hour. + const targetStart = new Date() + + targetStart.setUTCDate(targetStart.getUTCDate() + 2) + targetStart.setUTCHours(21, 0, 0, 0) // 23:00 Europe/Amsterdam summer + + const targetEnd = new Date(targetStart.getTime() + 60 * 60 * 1000) + + const fmt = (d: Date) => d.toISOString().slice(0, 19).replace('T', ' ') + + const movePayload = { + performance_id: fixtures.performance_id, + target_stage_id: fixtures.stage_id, + target_start_at: fmt(targetStart), + target_end_at: fmt(targetEnd), + target_lane: 0, + version: 0, + } + + // ----------------------------------------------------------------- + // First move: expect 200, perf.version moves 0 → 1 + // ----------------------------------------------------------------- + const first = await request.post(moveUrl, { + data: movePayload, + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'e2e-409-first', + }, + }) + + expect(first.status(), `First move expected 200, got ${first.status()}: ${await first.text()}`).toBe(200) + + // ----------------------------------------------------------------- + // Second move with the SAME (now-stale) version=0: expect 409 + // ----------------------------------------------------------------- + const second = await request.post(moveUrl, { + data: movePayload, + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'e2e-409-second', + }, + }) + + expect(second.status()).toBe(409) + + const conflictBody = await second.json() as { + success?: boolean + message?: string + errors?: { conflict?: string } + } + + // Contract assertions — drift here means the SPA's Zod schema for + // MoveTimetableConflictResponse needs an update. + expect(conflictBody.errors?.conflict).toBe('version_mismatch') + expect(typeof conflictBody.message).toBe('string') + + await context.close() + }) +}) diff --git a/apps/app/tests/playwright-e2e/utils/auth.ts b/apps/app/tests/playwright-e2e/utils/auth.ts new file mode 100644 index 00000000..1a24e5f6 --- /dev/null +++ b/apps/app/tests/playwright-e2e/utils/auth.ts @@ -0,0 +1,60 @@ +import type { APIRequestContext, BrowserContext } from '@playwright/test' + +import { readFixtures } from './fixtures' + +// Login helper for Playwright e2e tests. +// +// Uses the SPA-style Bearer-via-cookie flow (see +// api/.../Traits/SetAuthCookie.php): POST /api/v1/auth/login returns a +// `crewli_app_token` httpOnly cookie. Subsequent /api/v1/* requests in +// the same browser context carry it automatically because Playwright's +// request fixture inherits cookies from the BrowserContext. +// +// NOT sanctum-stateful (CSRF-cookie + X-XSRF-TOKEN). The custom +// CookieBearerToken middleware (api/bootstrap/app.php) reads the +// auth cookie directly. + +export interface LoginResult { + request: APIRequestContext + userId: string + organisationId: string +} + +/** + * Authenticates the e2e baseline user against a freshly-seeded + * Laravel test server. Returns the request context (auth cookie set) + * and the user/org IDs from the response. + */ +export async function loginAsBaselineUser(context: BrowserContext): Promise { + const fixtures = readFixtures() + + const response = await context.request.post('/api/v1/auth/login', { + data: { + email: fixtures.user_email, + password: fixtures.user_password, + }, + headers: { 'Content-Type': 'application/json' }, + }) + + if (!response.ok()) { + throw new Error( + `Login failed: ${response.status()} — ${await response.text()}`, + ) + } + + const body = await response.json() as { + success: boolean + data: { + user: { + id: string + organisations: Array<{ id: string; role: string }> + } + } + } + + return { + request: context.request, + userId: body.data.user.id, + organisationId: body.data.user.organisations[0].id, + } +} diff --git a/apps/app/tests/playwright-e2e/utils/fixtures.ts b/apps/app/tests/playwright-e2e/utils/fixtures.ts new file mode 100644 index 00000000..1c7012af --- /dev/null +++ b/apps/app/tests/playwright-e2e/utils/fixtures.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Reads the seeded IDs that E2EBaselineSeeder wrote during globalSetup. +// Tests use these IDs to construct API URLs and verify response shapes. + +export interface E2EFixtures { + user_email: string + user_password: string + organisation_id: string + event_id: string + stage_id: string + performance_id: string +} + +const FIXTURES_PATH = path.resolve( + __dirname, + '..', + '..', + '..', + '..', + '..', + 'api', + 'storage', + 'app', + 'e2e-fixtures.json', +) + +let cached: E2EFixtures | null = null + +export function readFixtures(): E2EFixtures { + if (cached) + return cached + + cached = JSON.parse(readFileSync(FIXTURES_PATH, 'utf-8')) as E2EFixtures + + return cached +}