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:
@@ -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',
|
||||
},
|
||||
|
||||
72
apps/app/tests/playwright-e2e/global-setup.ts
Normal file
72
apps/app/tests/playwright-e2e/global-setup.ts
Normal 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(', '))
|
||||
}
|
||||
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()
|
||||
})
|
||||
})
|
||||
60
apps/app/tests/playwright-e2e/utils/auth.ts
Normal file
60
apps/app/tests/playwright-e2e/utils/auth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
42
apps/app/tests/playwright-e2e/utils/fixtures.ts
Normal file
42
apps/app/tests/playwright-e2e/utils/fixtures.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user