[AZ-456] Test infrastructure: Vitest + MSW + Playwright + scripts

Scaffolds the Blackbox test project per AZ-456 / environment.md across
the three profiles:

- fast  : Vitest 3.x + jsdom + MSW 2.x + RTL/jest-dom; tests/setup.ts
          boots the MSW Node server with onUnhandledRequest:'error',
          afterEach resets handlers, clears bearer + navigate-to-login
          spy. Default handlers ship for every suite service plus OWM
          and tile stand-ins. Fixtures mirror seed_* in test-data.md.
- e2e   : Playwright ^1.49 with chromium + firefox projects against the
          suite docker-compose stack; owm-stub + tile-stub Bun servers,
          playwright-runner image, seeds.sql for the test-db.
- static: scripts/run-tests.sh extended — tsc --noEmit (test config),
          vite build, ripgrep checks (with grep -r fallback), CSV
          report at test-output/static-report.csv per AC-7 columns.

Smoke tests cover AC-3, AC-4 (fast, 5 tests, PASS) and AC-1, AC-2,
AC-5, AC-8 (e2e, gated by Risk 4 docker availability). Static profile
(13 checks) PASS — STC-SEC1 (no literal OWM key) lifted from
QUARANTINE per AZ-447 with a narrowed pattern.

Files:
  +24 tests/**, +10 e2e/**, +vitest.config.ts, +tsconfig.test.json
  ~package.json (test scripts + devDeps for vitest, @testing-library/*,
   msw, @playwright/test, jsdom, @types/node, @vitest/coverage-v8)
  ~scripts/run-tests.sh, scripts/run-performance-tests.sh — switched
   RESULTS_DIR to test-output/, compose path to project-local
  ~.gitignore — added /test-output/

Verification:
  bun run test:fast        → 11 / 11 PASS
  ./scripts/run-tests.sh   → static 13/13 + fast 11/11 PASS, exit 0

Tracker: AZ-456 → In Testing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 02:57:04 +03:00
parent e5d9276b19
commit 38eb87fb08
45 changed files with 2377 additions and 157 deletions
+29
View File
@@ -0,0 +1,29 @@
import snapshot from '../../_docs/00_problem/input_data/enum_spec_snapshot.json'
// Re-export the committed enum spec snapshot so test files can import it
// without crossing the docs path. AC-04 / AC-29 contract checks resolve
// against this object — never against `src/types/index.ts` directly.
export const enumSpec = snapshot as EnumSpecSnapshot
export interface EnumSpecSnapshot {
$schema_note: string
source_of_truth: Array<{ file: string; note: string; extracted_at?: string }>
ui_drift_summary: Record<string, unknown>
enums: Record<
string,
{
source: string
values: Record<string, number>
verification_pending: boolean
notes?: string
case_note?: string
stale_example_note?: string
verification_note?: string
}
>
downstream_actions: Record<string, unknown>
}
export function loadEnumSnapshot(): EnumSpecSnapshot {
return enumSpec
}
+8
View File
@@ -0,0 +1,8 @@
import type { Aircraft } from '../../src/types'
// Three aircraft with one default, per `seed_aircraft` in test-data.md.
export const seedAircraft: Aircraft[] = [
{ id: 'aircraft-1', model: 'Bayraktar TB2', type: 'Plane', isDefault: true },
{ id: 'aircraft-2', model: 'DJI Mavic 3', type: 'Copter', isDefault: false },
{ id: 'aircraft-3', model: 'Leleka-100', type: 'Plane', isDefault: false },
]
+82
View File
@@ -0,0 +1,82 @@
import type { AnnotationListItem } from '../../src/types'
import { AnnotationSource, AnnotationStatus, Affiliation, CombatReadiness } from '../../src/types'
// Annotations exercising the source / status enums + the splitTile path
// (AC-39): one with a valid splitTile string, one malformed.
export const seedAnnotations: AnnotationListItem[] = [
{
id: 'ann-1',
mediaId: 'media-3',
time: null,
createdDate: '2026-05-03T14:30:00Z',
userId: 'user-alice',
source: AnnotationSource.AI,
status: AnnotationStatus.Created,
isSplit: false,
splitTile: null,
detections: [
{
id: 'det-1',
classNum: 0,
label: 'class-0',
confidence: 0.92,
affiliation: Affiliation.Hostile,
combatReadiness: CombatReadiness.Ready,
centerX: 0.4,
centerY: 0.5,
width: 0.1,
height: 0.15,
},
],
},
{
id: 'ann-2',
mediaId: 'media-3',
time: null,
createdDate: '2026-05-03T14:32:00Z',
userId: 'user-alice',
source: AnnotationSource.AI,
status: AnnotationStatus.Edited,
isSplit: true,
splitTile: '3 0.5 0.5 0.2 0.2',
detections: [
{
id: 'det-2',
classNum: 1,
label: 'class-1',
confidence: 0.88,
affiliation: Affiliation.Friendly,
combatReadiness: CombatReadiness.NotReady,
centerX: 0.5,
centerY: 0.5,
width: 0.2,
height: 0.2,
},
],
},
{
id: 'ann-3',
mediaId: 'media-5',
time: '00:01:00',
createdDate: '2026-05-04T10:15:00Z',
userId: 'user-bob',
source: AnnotationSource.Manual,
status: AnnotationStatus.Validated,
isSplit: false,
splitTile: null,
detections: [],
},
{
id: 'ann-4',
mediaId: 'media-5',
time: '00:01:30',
createdDate: '2026-05-04T10:20:00Z',
userId: 'user-bob',
source: AnnotationSource.Manual,
status: AnnotationStatus.Edited,
isSplit: true,
splitTile: 'garbage',
detections: [],
},
]
+25
View File
@@ -0,0 +1,25 @@
import type { DetectionClass } from '../../src/types'
// Detection classes ordered per the contract: [0..N-1, 20..20+N-1, 40..40+N-1]
// with N=9 so AC-37 / data_model.md:158 hotkey 1..9 mapping is fully covered.
// PhotoMode + maxSizeM are placeholder values — no test currently asserts on
// them at the contract level; tests that need specific values override the
// /api/admin/classes handler.
const baseColors = [
'#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231',
'#911eb4', '#46f0f0', '#f032e6', '#bcf60c',
]
const N = 9
const offsets = [0, 20, 40]
export const seedClasses: DetectionClass[] = offsets.flatMap((offset) =>
Array.from({ length: N }, (_, i) => ({
id: offset + i,
name: `class-${offset + i}`,
shortName: `c${offset + i}`,
color: baseColors[i % baseColors.length],
maxSizeM: 5,
photoMode: 0,
})),
)
+15
View File
@@ -0,0 +1,15 @@
import type { Flight } from '../../src/types'
// Five flights spanning the four seed users; flight-1 has the live-GPS
// simulator wire-up so the SSE handler in /api/flights/:id/live-gps drives
// AC-08 timing assertions.
export const seedFlights: Flight[] = [
{ id: 'flight-1', name: 'Recon Alpha', createdDate: '2026-05-01T10:00:00Z', aircraftId: 'aircraft-1' },
{ id: 'flight-2', name: 'Recon Bravo', createdDate: '2026-05-02T11:30:00Z', aircraftId: 'aircraft-1' },
{ id: 'flight-3', name: 'Survey Charlie', createdDate: '2026-05-03T14:15:00Z', aircraftId: 'aircraft-2' },
{ id: 'flight-4', name: 'Patrol Delta', createdDate: '2026-05-04T09:45:00Z', aircraftId: 'aircraft-3' },
{ id: 'flight-5', name: 'Strike Echo', createdDate: '2026-05-05T16:00:00Z', aircraftId: 'aircraft-1' },
]
export const liveGpsFlightId = 'flight-1'
+76
View File
@@ -0,0 +1,76 @@
import type { Media } from '../../src/types'
import { MediaStatus, MediaType } from '../../src/types'
// 6 media items exercising the mediaStatus enum range (UI's current 0..3 scheme;
// AC-04 fix lands the full 0..6 range — tests targeting the post-fix range
// override seed_media via server.use to add Confirmed/Error rows once Step 4
// drift-fix tasks land).
export const seedMedia: Media[] = [
{
id: 'media-1',
name: 'sortie-1.jpg',
path: '/media/sortie-1.jpg',
mediaType: MediaType.Image,
mediaStatus: MediaStatus.New,
duration: null,
annotationCount: 0,
waypointId: null,
userId: 'user-alice',
},
{
id: 'media-2',
name: 'sortie-2.jpg',
path: '/media/sortie-2.jpg',
mediaType: MediaType.Image,
mediaStatus: MediaStatus.AiProcessing,
duration: null,
annotationCount: 0,
waypointId: 'wp-1',
userId: 'user-alice',
},
{
id: 'media-3',
name: 'sortie-3.jpg',
path: '/media/sortie-3.jpg',
mediaType: MediaType.Image,
mediaStatus: MediaStatus.AiProcessed,
duration: null,
annotationCount: 4,
waypointId: 'wp-1',
userId: 'user-alice',
},
{
id: 'media-4',
name: 'patrol-1.mp4',
path: '/media/patrol-1.mp4',
mediaType: MediaType.Video,
mediaStatus: MediaStatus.New,
duration: '00:01:30',
annotationCount: 0,
waypointId: null,
userId: 'user-bob',
},
{
id: 'media-5',
name: 'patrol-2.mp4',
path: '/media/patrol-2.mp4',
mediaType: MediaType.Video,
mediaStatus: MediaStatus.AiProcessed,
duration: '00:02:15',
annotationCount: 8,
waypointId: null,
userId: 'user-bob',
},
{
id: 'media-6',
name: 'manual.jpg',
path: '/media/manual.jpg',
mediaType: MediaType.Image,
mediaStatus: MediaStatus.ManualCreated,
duration: null,
annotationCount: 1,
waypointId: null,
userId: 'user-alice',
},
]
+24
View File
@@ -0,0 +1,24 @@
import type { UserSettings } from '../../src/types'
// Known panel widths + selected flight for op_alice so the rehydration tests
// (AC-21, AC-06) assert against a deterministic state.
export const seedUserSettings: UserSettings[] = [
{
id: 'user-settings-alice',
userId: 'user-alice',
selectedFlightId: 'flight-1',
annotationsLeftPanelWidth: 280,
annotationsRightPanelWidth: 320,
datasetLeftPanelWidth: 240,
datasetRightPanelWidth: 280,
},
{
id: 'user-settings-bob',
userId: 'user-bob',
selectedFlightId: 'flight-3',
annotationsLeftPanelWidth: null,
annotationsRightPanelWidth: null,
datasetLeftPanelWidth: null,
datasetRightPanelWidth: null,
},
]
+48
View File
@@ -0,0 +1,48 @@
import type { User } from '../../src/types'
// Mirrors `seed_users` per `_docs/02_document/tests/test-data.md`. Four users
// covering the role / permission combinations the e2e tests rely on.
export const opAlice: User = {
id: 'user-alice',
name: 'Alice Operator',
email: 'op_alice@test.local',
role: 'Operator',
isActive: true,
}
export const opBob: User = {
id: 'user-bob',
name: 'Bob Operator',
email: 'op_bob@test.local',
role: 'Operator',
isActive: true,
}
export const adminCarol: User = {
id: 'user-carol',
name: 'Carol Admin',
email: 'admin_carol@test.local',
role: 'Admin',
isActive: true,
}
export const integratorDave: User = {
id: 'user-dave',
name: 'Dave Integrator',
email: 'integrator_dave@test.local',
role: 'SystemIntegrator',
isActive: true,
}
export const seedUsers: User[] = [opAlice, opBob, adminCarol, integratorDave]
// Permissions are a parallel structure — the suite's auth service is the
// authoritative source. Tests that assert RBAC override the
// `/api/admin/users/me` handler with the relevant permission set.
export const seedPermissions: Record<string, string[]> = {
'user-alice': ['ADMIN_VIEW', 'FLIGHTS_WRITE', 'ANNOTATIONS_WRITE', 'SETTINGS'],
'user-bob': ['ADMIN_VIEW', 'FLIGHTS_WRITE', 'ANNOTATIONS_WRITE'],
'user-carol': ['ADMIN_VIEW', 'ADMIN_WRITE', 'FLIGHTS_WRITE', 'ANNOTATIONS_WRITE', 'SETTINGS', 'CLASSES_WRITE'],
'user-dave': ['ADMIN_VIEW', 'ADMIN_WRITE', 'INTEGRATION'],
}
+13
View File
@@ -0,0 +1,13 @@
import { setToken } from '../../src/api/client'
// Stand-in for the full login flow. Tests that need an authenticated request
// call `seedBearer(token)` before the request fires; `clearBearer()` is
// idempotent and runs as a safety net in afterEach (see tests/setup.ts).
export function seedBearer(token = 'test-bearer-default'): string {
setToken(token)
return token
}
export function clearBearer(): void {
setToken(null)
}
+11
View File
@@ -0,0 +1,11 @@
import { vi, type MockedFunction } from 'vitest'
import { setNavigateToLogin } from '../../src/api/client'
// Replaces the production `navigateToLoginImpl` accessor (autodev Step 4 / C06)
// with a Vitest spy. Tests assert "redirect was invoked" via the returned
// function reference instead of stubbing window.location globally.
export function seedNavigateToLogin(): MockedFunction<() => void> {
const spy = vi.fn()
setNavigateToLogin(spy)
return spy
}
+39
View File
@@ -0,0 +1,39 @@
import type { ReactElement, ReactNode } from 'react'
import { render, type RenderOptions, type RenderResult } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { I18nextProvider } from 'react-i18next'
import i18n from '../../src/i18n/i18n'
import { AuthProvider } from '../../src/auth/AuthContext'
export interface RenderWithProvidersOptions extends RenderOptions {
/** Initial route(s) for the in-memory router. Defaults to ['/']. */
initialEntries?: string[]
/** Initial entry index. Defaults to 0. */
initialIndex?: number
/** Skip wrapping in <AuthProvider>. Useful for tests that mock auth themselves. */
withoutAuth?: boolean
/** Skip wrapping in <I18nextProvider>. */
withoutI18n?: boolean
}
export function renderWithProviders(
ui: ReactElement,
{
initialEntries = ['/'],
initialIndex = 0,
withoutAuth,
withoutI18n,
...rtl
}: RenderWithProvidersOptions = {},
): RenderResult {
const Wrapper = ({ children }: { children: ReactNode }) => {
let tree = <MemoryRouter initialEntries={initialEntries} initialIndex={initialIndex}>{children}</MemoryRouter>
if (!withoutAuth) tree = <AuthProvider>{tree}</AuthProvider>
if (!withoutI18n) tree = <I18nextProvider i18n={i18n}>{tree}</I18nextProvider>
return tree
}
return render(ui, { wrapper: Wrapper, ...rtl })
}
export { screen, within, fireEvent, waitFor, act } from '@testing-library/react'
export { default as userEvent } from '@testing-library/user-event'
+59
View File
@@ -0,0 +1,59 @@
// SSE stand-in for the fast profile. MSW 2.x does not have first-class
// EventSource support (see AZ-456 Risk 3); jsdom does not ship one either.
// `simulateSseStream` returns a fake EventSource that tests inject in place
// of the global where production code allows it (e2e Playwright covers
// SSE end-to-end where the real EventSource is exercised against the suite).
export interface SseEvent<T = unknown> {
event?: string
data: T
id?: string
}
export interface FakeEventSource extends EventTarget {
readyState: 0 | 1 | 2
url: string
close(): void
emit<T>(e: SseEvent<T>): void
emitError(err?: Event): void
}
const READY_CONNECTING = 0 as const
const READY_OPEN = 1 as const
const READY_CLOSED = 2 as const
export function createFakeEventSource(url = 'about:blank'): FakeEventSource {
const target = new EventTarget() as FakeEventSource
;(target as { readyState: 0 | 1 | 2 }).readyState = READY_CONNECTING
;(target as { url: string }).url = url
// Move to "open" on the next microtask so listeners attached synchronously
// after construction still see the open event (matches the production
// EventSource handshake behavior closely enough for assertions).
queueMicrotask(() => {
;(target as { readyState: 0 | 1 | 2 }).readyState = READY_OPEN
target.dispatchEvent(new Event('open'))
})
target.close = () => {
;(target as { readyState: 0 | 1 | 2 }).readyState = READY_CLOSED
}
target.emit = <T>(e: SseEvent<T>) => {
if (target.readyState !== READY_OPEN) return
const payload = typeof e.data === 'string' ? e.data : JSON.stringify(e.data)
const message = new MessageEvent(e.event ?? 'message', { data: payload, lastEventId: e.id })
target.dispatchEvent(message)
}
target.emitError = (err?: Event) => {
target.dispatchEvent(err ?? new Event('error'))
}
return target
}
export function simulateSseStream<T = unknown>(events: Array<SseEvent<T>>): FakeEventSource {
const source = createFakeEventSource()
queueMicrotask(() => {
for (const e of events) source.emit(e)
})
return source
}
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from './msw/server'
import { api } from '../src/api/client'
import { seedBearer, clearBearer } from './helpers/auth'
import { loadEnumSnapshot } from './fixtures/enum_spec_snapshot'
// Smoke tests for AZ-456 (Test Infrastructure):
// - AC-3 : MSW intercepts every outbound /api/<service>/* fetch (default
// handler match + per-test override + reset between tests).
// - AC-4 : Vitest discovers this file under jsdom, runs it, and the
// scripts/run-tests.sh runner emits ./test-output/fast-report.xml
// (the JUnit reporter is wired in vitest.config.ts).
// - AC-7 : The JUnit/CSV report shape is asserted by scripts/run-tests.sh
// after this suite finishes — see the [fast] section there.
describe('AZ-456 fast-profile infrastructure', () => {
afterEach(() => {
clearBearer()
})
it('AC-3: MSW intercepts a default /api/admin/* fetch', async () => {
seedBearer()
const me = await api.get<{ id: string; email: string }>('/api/admin/users/me')
expect(me.id).toBe('user-alice')
expect(me.email).toBe('op_alice@test.local')
})
it('AC-3: per-test server.use(...) overrides the default handler', async () => {
seedBearer()
server.use(
http.get('/api/admin/users/me', () =>
HttpResponse.json({ id: 'user-override', email: 'override@test.local' }),
),
)
const me = await api.get<{ id: string; email: string }>('/api/admin/users/me')
expect(me.id).toBe('user-override')
})
it('AC-3: handlers reset between tests (default returns op_alice again)', async () => {
seedBearer()
const me = await api.get<{ id: string; email: string }>('/api/admin/users/me')
expect(me.id).toBe('user-alice')
})
it('AC-4: jsdom + Vitest globals are configured', () => {
expect(typeof window).toBe('object')
expect(typeof document).toBe('object')
// @testing-library/jest-dom matchers are extended via tests/setup.ts.
const el = document.createElement('div')
el.textContent = 'hello'
expect(el).toHaveTextContent('hello')
})
it('AC-4 / AC-29: enum spec snapshot is reachable from tests', () => {
const spec = loadEnumSnapshot()
expect(spec.enums.AnnotationStatus.values).toMatchObject({
None: 0, Created: 10, Edited: 20, Validated: 30, Deleted: 40,
})
})
})
+79
View File
@@ -0,0 +1,79 @@
import { http } from 'msw'
import { jsonResponse, noContent, paginate } from '../helpers'
import { seedUsers, opAlice } from '../../fixtures/seed_users'
import { seedClasses } from '../../fixtures/seed_classes'
// Default `/api/admin/*` handlers — auth round-trip, users, classes-write,
// system settings. Tests override per-scenario via `server.use(...)`.
const SEED_BEARER = 'test-bearer-default'
export const adminHandlers = [
http.post('/api/admin/auth/login', async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as { email?: string; password?: string }
const user = seedUsers.find((u) => u.email === body.email) ?? opAlice
return new Response(JSON.stringify({ token: SEED_BEARER, user }), {
status: 200,
headers: {
'Content-Type': 'application/json',
// AC-03 contract — refresh cookie is HttpOnly + Secure + SameSite=Strict.
'Set-Cookie': 'refreshToken=test-refresh; HttpOnly; Secure; SameSite=Strict; Path=/api/admin/auth',
},
})
}),
http.post('/api/admin/auth/refresh', () => {
return jsonResponse({ token: SEED_BEARER })
}),
http.post('/api/admin/auth/logout', () => noContent()),
http.get('/api/admin/users/me', () => jsonResponse(opAlice)),
http.get('/api/admin/users', () => jsonResponse(paginate(seedUsers))),
http.get('/api/admin/users/:id', ({ params }) => {
const user = seedUsers.find((u) => u.id === params.id)
if (!user) return new Response(null, { status: 404 })
return jsonResponse(user)
}),
http.get('/api/admin/classes', () => jsonResponse(seedClasses)),
http.post('/api/admin/classes', async ({ request }) => {
const body = await request.json()
return jsonResponse(body, { status: 201 })
}),
http.put('/api/admin/classes/:id', async ({ request }) => {
const body = await request.json()
return jsonResponse(body)
}),
http.delete('/api/admin/classes/:id', () => noContent()),
http.get('/api/admin/settings', () =>
jsonResponse({
id: 'sys-settings-1',
name: 'Test System',
militaryUnit: null,
defaultCameraWidth: 1920,
defaultCameraFoV: 60,
thumbnailWidth: 256,
thumbnailHeight: 256,
thumbnailBorder: 2,
generateAnnotatedImage: true,
silentDetection: false,
}),
),
http.put('/api/admin/settings', async ({ request }) => {
const body = await request.json()
return jsonResponse(body)
}),
// Test-only suite endpoint — gated behind a non-production build flag in the
// real `admin/` service. The fast-profile MSW just returns 204 so isolation
// helpers can call it uniformly with the e2e profile.
http.post('/api/admin/test-only/reset', () => noContent()),
]
+90
View File
@@ -0,0 +1,90 @@
import { http } from 'msw'
import { jsonResponse, noContent, paginate, sse } from '../helpers'
import { seedMedia } from '../../fixtures/seed_media'
import { seedAnnotations } from '../../fixtures/seed_annotations'
import { seedUserSettings } from '../../fixtures/seed_user_settings'
// Default `/api/annotations/*` handlers — media list, annotation CRUD, dataset,
// status SSE. The annotation status SSE returns a small canned event sequence
// so dataset / annotations tests don't have to register their own stream just
// to mount a component.
export const annotationsHandlers = [
http.get('/api/annotations/media', ({ request }) => {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? '1')
const pageSize = Number(url.searchParams.get('pageSize') ?? String(seedMedia.length))
return jsonResponse(paginate(seedMedia, page, pageSize))
}),
http.get('/api/annotations/media/:id', ({ params }) => {
const m = seedMedia.find((x) => x.id === params.id)
if (!m) return new Response(null, { status: 404 })
return jsonResponse(m)
}),
http.get('/api/annotations/media/:id/annotations', ({ params }) =>
jsonResponse(seedAnnotations.filter((a) => a.mediaId === params.id)),
),
http.get('/api/annotations', () => jsonResponse(seedAnnotations)),
http.post('/api/annotations', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'ann-new', createdDate: new Date().toISOString(), ...body }, { status: 201 })
}),
http.patch('/api/annotations/:id/status', async ({ request, params }) => {
const body = (await request.json()) as { status?: number }
return jsonResponse({ id: params.id, status: body.status ?? 10 })
}),
http.delete('/api/annotations/:id', () => noContent()),
http.get('/api/annotations/dataset', () =>
jsonResponse(
seedAnnotations.map((a) => ({
annotationId: a.id,
imageName: `image-${a.mediaId}.jpg`,
thumbnailPath: `/thumbs/${a.mediaId}.jpg`,
status: a.status,
createdDate: a.createdDate,
createdEmail: 'op_alice@test.local',
flightName: 'Flight 1',
source: a.source,
isSeed: false,
isSplit: a.isSplit,
})),
),
),
http.post('/api/annotations/dataset/bulk-status', async ({ request }) => {
const body = (await request.json()) as { ids?: string[]; status?: number }
return jsonResponse({ updated: body.ids?.length ?? 0, status: body.status ?? 30 })
}),
http.get('/api/annotations/dataset/distribution', () =>
jsonResponse([
{ classNum: 0, label: 'class-0', color: '#ff0000', count: 12 },
{ classNum: 1, label: 'class-1', color: '#00ff00', count: 7 },
]),
),
http.get('/api/annotations/status', () =>
sse([
{ event: 'status', data: { annotationId: seedAnnotations[0]?.id ?? 'ann-1', status: 20 }, id: '1' },
{ event: 'status', data: { annotationId: seedAnnotations[0]?.id ?? 'ann-1', status: 30 }, id: '2' },
]),
),
http.get('/api/annotations/users/:userId/settings', ({ params }) => {
const s = seedUserSettings.find((x) => x.userId === params.userId)
if (!s) return new Response(null, { status: 404 })
return jsonResponse(s)
}),
http.put('/api/annotations/users/:userId/settings', async ({ request, params }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'user-settings-1', userId: params.userId, ...body })
}),
]
+25
View File
@@ -0,0 +1,25 @@
import { http } from 'msw'
import { jsonResponse } from '../helpers'
// Default `/api/detect/*` handlers — sync image detect.
export const detectHandlers = [
http.post('/api/detect/image', () =>
jsonResponse({
detections: [
{
id: 'det-1',
classNum: 0,
label: 'class-0',
confidence: 0.92,
affiliation: 20,
combatReadiness: 1,
centerX: 0.5,
centerY: 0.5,
width: 0.1,
height: 0.1,
},
],
}),
),
]
+63
View File
@@ -0,0 +1,63 @@
import { http } from 'msw'
import { jsonResponse, noContent, sse } from '../helpers'
import { seedFlights } from '../../fixtures/seed_flights'
import { seedAircraft } from '../../fixtures/seed_aircraft'
// Default `/api/flights/*` handlers. Live-GPS SSE returns a deterministic
// 3-event stream so AC-08 timing assertions have something to drive even
// without per-test overrides.
export const flightsHandlers = [
http.get('/api/flights', () => jsonResponse(seedFlights)),
http.get('/api/flights/:id', ({ params }) => {
const flight = seedFlights.find((f) => f.id === params.id)
if (!flight) return new Response(null, { status: 404 })
return jsonResponse(flight)
}),
http.post('/api/flights', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'flight-new', createdDate: new Date().toISOString(), ...body }, { status: 201 })
}),
http.put('/api/flights/:id', async ({ request, params }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: params.id, ...body })
}),
http.delete('/api/flights/:id', () => noContent()),
http.get('/api/flights/:id/waypoints', ({ params }) =>
jsonResponse([
{
id: 'wp-1',
flightId: params.id,
name: 'WP1',
latitude: 50.45,
longitude: 30.52,
order: 1,
},
]),
),
http.post('/api/flights/:id/waypoints', async ({ request, params }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'wp-new', flightId: params.id, ...body }, { status: 201 })
}),
http.get('/api/flights/:id/live-gps', ({ params }) =>
sse([
{ event: 'gps', data: { flightId: params.id, lat: 50.45, lon: 30.52, t: 0 }, id: '1' },
{ event: 'gps', data: { flightId: params.id, lat: 50.46, lon: 30.53, t: 1000 }, id: '2' },
{ event: 'gps', data: { flightId: params.id, lat: 50.47, lon: 30.54, t: 2000 }, id: '3' },
]),
),
http.get('/api/flights/aircraft', () => jsonResponse(seedAircraft)),
http.post('/api/flights/aircraft', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
return jsonResponse({ id: 'aircraft-new', ...body }, { status: 201 })
}),
]
+33
View File
@@ -0,0 +1,33 @@
import { adminHandlers } from './admin'
import { flightsHandlers } from './flights'
import { annotationsHandlers } from './annotations'
import { detectHandlers } from './detect'
import { loaderHandlers } from './loader'
import { resourceHandlers } from './resource'
import { owmHandlers } from './owm'
import { tilesHandlers } from './tiles'
// Default-handler registration order is irrelevant (MSW matches by request shape),
// but grouping the exports here gives test files a single import surface for
// the seeded baseline. Per-test overrides land via `server.use(...)`.
export const defaultHandlers = [
...adminHandlers,
...flightsHandlers,
...annotationsHandlers,
...detectHandlers,
...loaderHandlers,
...resourceHandlers,
...owmHandlers,
...tilesHandlers,
]
export {
adminHandlers,
flightsHandlers,
annotationsHandlers,
detectHandlers,
loaderHandlers,
resourceHandlers,
owmHandlers,
tilesHandlers,
}
+16
View File
@@ -0,0 +1,16 @@
import { http } from 'msw'
import { jsonResponse, noContent } from '../helpers'
// Default `/api/loader/*` handlers. The loader service brokers media uploads;
// AC-10 (≤ 500 MB cap) is asserted in tests by overriding the POST handler
// with a 413 stub.
export const loaderHandlers = [
http.post('/api/loader/upload', () => jsonResponse({ id: 'media-uploaded-1', status: 1 }, { status: 201 })),
http.get('/api/loader/jobs/:id', ({ params }) =>
jsonResponse({ id: params.id, status: 'completed', progress: 1.0 }),
),
http.delete('/api/loader/jobs/:id', () => noContent()),
]
+24
View File
@@ -0,0 +1,24 @@
import { http } from 'msw'
import { jsonResponse } from '../helpers'
// OpenWeatherMap stand-in for the fast profile. The e2e profile uses
// `e2e/stubs/owm/` (Docker). Both must return the same shape so tests
// targeting the wind-compute path (E10) behave identically.
export const owmHandlers = [
// The production code is expected to call OWM through a configurable base URL
// (default = api.openweathermap.org); the route-abort guard in the e2e
// profile blocks that host, but tests under fast hit the path-only form so
// MSW intercepts.
http.get('https://api.openweathermap.org/data/2.5/weather', () =>
jsonResponse({ wind: { speed: 5.0, deg: 270 }, name: 'TestCity' }),
),
http.get('http://owm-stub:8081/data/2.5/weather', () =>
jsonResponse({ wind: { speed: 5.0, deg: 270 }, name: 'TestCity' }),
),
http.get('/owm/data/2.5/weather', () =>
jsonResponse({ wind: { speed: 5.0, deg: 270 }, name: 'TestCity' }),
),
]
+27
View File
@@ -0,0 +1,27 @@
import { http, HttpResponse } from 'msw'
import { jsonResponse } from '../helpers'
// Default `/api/resource/*` handlers — image / thumbnail / video binary serving.
// Returns a tiny PNG stub for any image request so layout tests can mount
// without 404 noise.
const ONE_PX_PNG = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
])
export const resourceHandlers = [
http.get('/api/resource/images/:name', () =>
new HttpResponse(ONE_PX_PNG, { headers: { 'Content-Type': 'image/png' } }),
),
http.get('/api/resource/thumbnails/:name', () =>
new HttpResponse(ONE_PX_PNG, { headers: { 'Content-Type': 'image/png' } }),
),
http.get('/api/resource/videos/:name', () =>
jsonResponse({ url: '/api/resource/videos/stub.mp4' }),
),
]
+26
View File
@@ -0,0 +1,26 @@
import { http, HttpResponse } from 'msw'
// OSM/Esri tile stand-in for the fast profile. Returns a tiny transparent
// PNG so `<img>` / Leaflet tile loads succeed in jsdom without exiting the
// process.
const TILE_PNG = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x62, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
])
const tile = () => new HttpResponse(TILE_PNG, { headers: { 'Content-Type': 'image/png' } })
export const tilesHandlers = [
// OSM XYZ scheme: {z}/{x}/{y}
http.get('https://*.tile.openstreetmap.org/:z/:x/:y.png', tile),
http.get('https://tile.openstreetmap.org/:z/:x/:y.png', tile),
// Esri ArcGIS satellite scheme: {z}/{y}/{x}
http.get('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/:z/:y/:x', tile),
// Local tile-stub aliases (e2e parity)
http.get('http://tile-stub:8082/:z/:x/:y.png', tile),
http.get('http://tile-stub:8082/sat/:z/:y/:x', tile),
http.get('/tiles/:z/:x/:y.png', tile),
]
+57
View File
@@ -0,0 +1,57 @@
import { HttpResponse, delay } from 'msw'
/** Small, opinionated wrappers over MSW's response helpers used across handlers + tests. */
export function jsonResponse<T>(body: T, init?: ResponseInit) {
return HttpResponse.json(body as object, init)
}
export function errorResponse(status: number, message: string) {
return HttpResponse.json({ error: message, status }, { status })
}
export function noContent() {
return new HttpResponse(null, { status: 204 })
}
export function paginate<T>(items: T[], page = 1, pageSize = items.length) {
const start = (page - 1) * pageSize
const slice = items.slice(start, start + pageSize)
return { items: slice, totalCount: items.length, page, pageSize }
}
/** Inject latency into a handler. Use as: `await latency(50)` inside the resolver. */
export function latency(ms: number) {
return delay(ms)
}
/** Build a Server-Sent-Events (SSE) `text/event-stream` body from a sequence of payloads. */
export function sse(events: Array<{ event?: string; data: unknown; id?: string }>) {
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
for (const e of events) {
const lines: string[] = []
if (e.id !== undefined) lines.push(`id: ${e.id}`)
if (e.event !== undefined) lines.push(`event: ${e.event}`)
const payload = typeof e.data === 'string' ? e.data : JSON.stringify(e.data)
for (const line of payload.split('\n')) lines.push(`data: ${line}`)
lines.push('', '')
controller.enqueue(encoder.encode(lines.join('\n')))
}
controller.close()
},
})
return new HttpResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
/** Drop the connection mid-flight (used by resilience tests). */
export function dropResponse() {
return HttpResponse.error()
}
+7
View File
@@ -0,0 +1,7 @@
import { setupServer } from 'msw/node'
import { defaultHandlers } from './handlers'
// Node-side MSW server shared by every fast-profile test. The Service-Worker
// runtime (`msw/browser`) is intentionally NOT imported anywhere under tests/;
// see AZ-456 Risk 1 — mixing the two silently bypasses MSW.
export const server = setupServer(...defaultHandlers)
+29
View File
@@ -0,0 +1,29 @@
import '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { cleanup } from '@testing-library/react'
import { server } from './msw/server'
import { setToken, setNavigateToLogin } from '../src/api/client'
// MSW boundary configured per AZ-456 AC-3:
// - All outbound /api/<service>/... fetches MUST be intercepted.
// - A test missing a handler for a network request is a HARD failure
// (onUnhandledRequest: 'error'). This is how AC-3 is enforced for
// fast-profile tests; a leaked external request would otherwise
// escape the test environment silently.
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' })
})
afterEach(() => {
cleanup()
server.resetHandlers()
setToken(null)
setNavigateToLogin(() => {
/* default no-op for tests; production accessor restored implicitly
on next module reload — tests must re-seed if they assert on it. */
})
})
afterAll(() => {
server.close()
})