mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 08:11:10 +00:00
[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:
Vendored
+29
@@ -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
|
||||
}
|
||||
Vendored
+8
@@ -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 },
|
||||
]
|
||||
Vendored
+82
@@ -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: [],
|
||||
},
|
||||
]
|
||||
Vendored
+25
@@ -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,
|
||||
})),
|
||||
)
|
||||
Vendored
+15
@@ -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'
|
||||
Vendored
+76
@@ -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',
|
||||
},
|
||||
]
|
||||
Vendored
+24
@@ -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,
|
||||
},
|
||||
]
|
||||
Vendored
+48
@@ -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'],
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()),
|
||||
]
|
||||
@@ -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 })
|
||||
}),
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
]
|
||||
@@ -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 })
|
||||
}),
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()),
|
||||
]
|
||||
@@ -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' }),
|
||||
),
|
||||
]
|
||||
@@ -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' }),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user