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:
112
apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts
Normal file
112
apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user