Files
crewli/apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts
bert.hausmans 2dfb1e8bae 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>
2026-05-10 15:24:33 +02:00

113 lines
4.5 KiB
TypeScript

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