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