test(e2e): real-backend 409 conflict contract test (TEST-CONTRACT-001)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 15:24:33 +02:00
parent f6509d938b
commit 2dfb1e8bae
7 changed files with 440 additions and 10 deletions

View File

@@ -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',
},

View File

@@ -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<void> {
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<string, unknown>
console.log('[e2e setup] Fixtures ready:', Object.keys(fixtures).join(', '))
}

View File

@@ -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()
})
})

View File

@@ -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<LoginResult> {
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,
}
}

View File

@@ -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
}