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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -47,6 +47,10 @@ apps/app/playwright-report/
|
|||||||
apps/app/blob-report/
|
apps/app/blob-report/
|
||||||
apps/app/playwright/.cache/
|
apps/app/playwright/.cache/
|
||||||
|
|
||||||
|
# Playwright e2e seed-data fixture file — written by E2EBaselineSeeder
|
||||||
|
# during globalSetup, never source-of-truth.
|
||||||
|
api/storage/app/e2e-fixtures.json
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
.cache/
|
.cache/
|
||||||
|
|||||||
112
api/database/seeders/E2EBaselineSeeder.php
Normal file
112
api/database/seeders/E2EBaselineSeeder.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Artist;
|
||||||
|
use App\Models\ArtistEngagement;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\Performance;
|
||||||
|
use App\Models\Stage;
|
||||||
|
use App\Models\StageDay;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds a deterministic baseline for Playwright e2e tests.
|
||||||
|
*
|
||||||
|
* Creates:
|
||||||
|
* - Roles (org_admin etc. via RoleSeeder)
|
||||||
|
* - One user: e2e@test.local / password ("password")
|
||||||
|
* - One organisation, attached as org_admin
|
||||||
|
* - One event spanning today..+30d
|
||||||
|
* - One stage with one StageDay
|
||||||
|
* - One artist + engagement + performance (version=0)
|
||||||
|
*
|
||||||
|
* Used by tests/playwright-e2e/. Idempotency: this seeder assumes a
|
||||||
|
* `migrate:fresh` was just run, so it creates without checking for
|
||||||
|
* existing rows. Re-running on a non-empty DB would create duplicates.
|
||||||
|
*
|
||||||
|
* NOT used by PHPUnit — PHPUnit uses factories per test class with
|
||||||
|
* RefreshDatabase. This is e2e-specific.
|
||||||
|
*/
|
||||||
|
final class E2EBaselineSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->call(RoleSeeder::class);
|
||||||
|
|
||||||
|
$org = Organisation::factory()->create([
|
||||||
|
'name' => 'E2E Test Organisation',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'e2e@test.local',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
$org->users()->attach($user, ['role' => 'org_admin']);
|
||||||
|
|
||||||
|
$event = Event::factory()->create([
|
||||||
|
'organisation_id' => $org->id,
|
||||||
|
'name' => 'E2E Test Festival',
|
||||||
|
'start_date' => CarbonImmutable::now()->subDay(),
|
||||||
|
'end_date' => CarbonImmutable::now()->addDays(30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stage = Stage::factory()->create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'name' => 'E2E Stage',
|
||||||
|
]);
|
||||||
|
|
||||||
|
StageDay::query()->create([
|
||||||
|
'stage_id' => $stage->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$artist = Artist::factory()->create([
|
||||||
|
'organisation_id' => $org->id,
|
||||||
|
'name' => 'E2E Artist',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$engagement = ArtistEngagement::factory()->create([
|
||||||
|
'artist_id' => $artist->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$start = CarbonImmutable::now()->addDays(2)->setTime(20, 0);
|
||||||
|
Performance::factory()->create([
|
||||||
|
'engagement_id' => $engagement->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'stage_id' => $stage->id,
|
||||||
|
'lane' => 0,
|
||||||
|
'start_at' => $start,
|
||||||
|
'end_at' => $start->addHour(),
|
||||||
|
'version' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$performance = Performance::query()
|
||||||
|
->where('event_id', $event->id)
|
||||||
|
->where('stage_id', $stage->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Write seeded IDs to a known location the Playwright e2e
|
||||||
|
// fixture reads. Avoids artisan-stdout-parsing fragility.
|
||||||
|
$fixturePath = storage_path('app/e2e-fixtures.json');
|
||||||
|
@mkdir(dirname($fixturePath), 0755, true);
|
||||||
|
file_put_contents($fixturePath, json_encode([
|
||||||
|
'user_email' => 'e2e@test.local',
|
||||||
|
'user_password' => 'password',
|
||||||
|
'organisation_id' => $org->id,
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'stage_id' => $stage->id,
|
||||||
|
'performance_id' => $performance?->id,
|
||||||
|
], JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
$this->command?->info("E2E fixtures written to {$fixturePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,32 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
// E2E config — drives a real Vite dev server + a real Laravel test
|
// E2E config — drives a real Laravel test server. Used by `pnpm test:e2e`.
|
||||||
// server. Used by `pnpm test:e2e`. Component tests live in
|
// Component tests live in playwright-ct.config.ts (different runner).
|
||||||
// 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({
|
export default defineConfig({
|
||||||
testDir: './tests/playwright-e2e',
|
testDir: './tests/playwright-e2e',
|
||||||
@@ -11,13 +35,17 @@ export default defineConfig({
|
|||||||
retries: 0,
|
retries: 0,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: process.env.CI ? 'github' : 'list',
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
|
globalSetup: './tests/playwright-e2e/global-setup.ts',
|
||||||
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.E2E_FRONTEND_URL ?? 'http://localhost:5173',
|
baseURL: E2E_API_URL,
|
||||||
trace: 'off',
|
trace: 'off',
|
||||||
video: 'off',
|
video: 'off',
|
||||||
screenshot: 'off',
|
screenshot: 'off',
|
||||||
viewport: { width: 1440, height: 900 },
|
viewport: { width: 1440, height: 900 },
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
projects: [
|
projects: [
|
||||||
@@ -27,14 +55,14 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Auto-start the SPA dev server. Laravel's test server is started
|
// Auto-start the Laravel test server. globalSetup runs first
|
||||||
// by the per-test fixture in tests/playwright-e2e/fixtures/laravel.ts
|
// (migrate:fresh + seed), then this command spawns artisan serve
|
||||||
// because its lifecycle requires per-run seed control.
|
// against the now-seeded crewli_test DB.
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'pnpm dev',
|
command: 'cd ../../api && DB_DATABASE=crewli_test php artisan --env=testing serve --port=8001',
|
||||||
url: process.env.E2E_FRONTEND_URL ?? 'http://localhost:5173',
|
url: `${E2E_API_URL}/up`,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 60_000,
|
||||||
stdout: 'ignore',
|
stdout: 'ignore',
|
||||||
stderr: 'pipe',
|
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