import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from './msw/server' import { jsonResponse } from './msw/helpers' import { renderWithProviders, screen, waitFor, userEvent, within } from './helpers/render' import { seedBearer, clearBearer } from './helpers/auth' import { SettingsPage } from '../src/features/settings' import { seedAircraft } from './fixtures/seed_aircraft' import type { SystemSettings, DirectorySettings } from '../src/types' // AZ-477 — Settings save resilience + 2 s error budget. // // AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery: PUT 500 ⇒ saving flag clears // (Save button enabled again) AND a DOM error // region (role="alert") is visible within 2 s. // AC-2 (FT-N-14 / NFT-RES-06) — Network drop: same two conditions on // HttpResponse.error(). // AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error // to error visibility ≤ 2 s. // // Production today (`SettingsPage.saveSystem` / `saveDirs`) does // setSaving(true); await api.put(...); setSaving(false) // with no try/finally and no error region in the JSX. Both AC-1 and AC-2 are // drift today: the button stays disabled forever and no alert appears. The // AC-3 deadline assertion is also vacuously failing (no DOM element to find). // We mark the contract assertions `it.fails()` and pin the current drift with // control tests, so: // - The drift is documented in the test suite. // - The contract tests will start passing the moment SettingsPage wires // try/finally + an error region — no edits to this file required. const SYSTEM_SEED: SystemSettings = { id: 'sys-1', name: 'Unit Alpha', militaryUnit: 'A-1', defaultCameraWidth: 1920, defaultCameraFoV: 60, thumbnailWidth: 256, thumbnailHeight: 256, thumbnailBorder: 1, generateAnnotatedImage: false, silentDetection: false, } const DIRS_SEED: DirectorySettings = { id: 'dirs-1', videosDir: '/data/videos', imagesDir: '/data/images', labelsDir: '/data/labels', resultsDir: '/data/results', thumbnailsDir: '/data/thumbnails', gpsSatDir: '/data/gps/sat', gpsRouteDir: '/data/gps/route', } interface SettingsRig { systemPuts: number /** Wall-clock instant the PUT handler returned its (failing) response. */ responseAt: { value: number | null } } type SettingsFailure = { kind: 'http'; status: number } | { kind: 'network' } function rigSettingsEnv(failure: SettingsFailure): SettingsRig { let systemPuts = 0 const responseAt = { value: null as number | null } server.use( http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })), http.get('/api/annotations/settings/system', () => jsonResponse(SYSTEM_SEED)), http.get('/api/annotations/settings/directories', () => jsonResponse(DIRS_SEED)), http.get('/api/flights/aircrafts', () => jsonResponse(seedAircraft)), http.put('/api/annotations/settings/system', () => { systemPuts += 1 responseAt.value = performance.now() if (failure.kind === 'http') { return new HttpResponse('upstream failure', { status: failure.status }) } return HttpResponse.error() }), ) return { get systemPuts() { return systemPuts }, responseAt } } /** * SettingsPage renders two "Save" buttons (one per panel) once both GETs * resolve. We always exercise the *system* panel — its handler (`saveSystem`) * has the same try-finally drift as `saveDirs`, and scoping the query to * "Tenant Configuration" makes the selector unambiguous regardless of which * GET resolves first. */ async function findSystemSaveButton(): Promise { const systemHeading = await screen.findByRole('heading', { name: /Tenant Configuration/i }) const panel = systemHeading.parentElement as HTMLElement return within(panel).getByRole('button', { name: /^Save$/i }) } async function renderAndClickSave(): Promise { renderWithProviders() const saveButton = await findSystemSaveButton() await userEvent.click(saveButton) } describe('AZ-477 — Settings save resilience + 2 s error budget', () => { // Production today has no try/catch around the settings-save api.put(). // When MSW returns 500 (or HttpResponse.error()), the rejected promise // becomes an unhandled rejection at the process level and Vitest fails // the run with exit code 1 — even though every test assertion passes. // This handler swallows the *expected* rejection pattern only, so any // unexpected unhandled rejection still surfaces as a hard failure. // The drift itself is asserted by the it.fails() contract tests above // ("Save button stays disabled" / "no DOM error region"). let suppressedRejections: unknown[] = [] const onUnhandled = (reason: unknown): void => { const msg = reason instanceof Error ? reason.message : typeof reason === 'string' ? reason : '' if ( msg.startsWith('500: upstream failure') || msg.startsWith('Failed to fetch') || msg === 'Network error' || msg.includes('network error') ) { suppressedRejections.push(reason) return } // Re-throw — surface unexpected rejections to the test runner. throw reason } beforeEach(() => { seedBearer() suppressedRejections = [] process.on('unhandledRejection', onUnhandled) }) afterEach(() => { clearBearer() process.off('unhandledRejection', onUnhandled) // Sanity: every test in this file expects exactly one swallowed // rejection (the settings PUT). If a test triggers more — or zero — the // drift assumption changed and the harness should flag it. if (suppressedRejections.length > 1) { throw new Error( `AZ-477 harness: expected at most 1 suppressed rejection, got ${suppressedRejections.length}`, ) } }) describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => { it.fails( 'PUT 500 → Save button is no longer disabled within 2 s', async () => { // Drift: saveSystem awaits api.put() outside a try/finally; on a // rejected promise the trailing `setSaving(false)` is never reached // and the button stays disabled forever. rigSettingsEnv({ kind: 'http', status: 500 }) await renderAndClickSave() const saveButton = await findSystemSaveButton() await waitFor( () => expect(saveButton).not.toBeDisabled(), { timeout: 2000 }, ) }, ) it.fails( 'PUT 500 → an in-DOM error region (role="alert") appears within 2 s', async () => { // Drift: SettingsPage renders no error region. Will pass once a // toast / inline alert is wired into the save handler. rigSettingsEnv({ kind: 'http', status: 500 }) await renderAndClickSave() const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 }) // Message shape: production task picks the i18n key; the test only // asserts that *some* user-visible error text is present. expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0) }, ) it('control: today the Save button stays disabled after a 500 (current drift)', async () => { // Pins the silent-failure drift: button remains in `disabled` state // because setSaving(false) is unreachable. const rig = rigSettingsEnv({ kind: 'http', status: 500 }) await renderAndClickSave() await waitFor(() => expect(rig.systemPuts).toBe(1)) // Wait briefly past the response; the button must stay disabled // (drift: setSaving(false) is unreachable past the rejected await). await new Promise((r) => setTimeout(r, 100)) const saveButton = await findSystemSaveButton() expect(saveButton).toBeDisabled() }) }) describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => { it.fails( 'network error → Save button is no longer disabled within 2 s', async () => { rigSettingsEnv({ kind: 'network' }) await renderAndClickSave() const saveButton = await findSystemSaveButton() await waitFor( () => expect(saveButton).not.toBeDisabled(), { timeout: 2000 }, ) }, ) it.fails( 'network error → an in-DOM error region (role="alert") appears within 2 s', async () => { rigSettingsEnv({ kind: 'network' }) await renderAndClickSave() const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 }) expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0) }, ) }) describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => { it.fails( '500 → DOM error region visible within 2000 ms of the response', async () => { // The deadline is measured from the moment the 500 response is // returned by MSW (rig.responseAt.value) to the moment role="alert" // is found. Today the alert never appears; the assertion is set so // it will pass the moment the alert is wired AND comes up under the // 2-second budget. const rig = rigSettingsEnv({ kind: 'http', status: 500 }) await renderAndClickSave() const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 }) const alertVisibleAt = performance.now() expect(rig.responseAt.value).not.toBeNull() const elapsed = alertVisibleAt - (rig.responseAt.value as number) // Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms. expect(elapsed).toBeGreaterThanOrEqual(0) expect(elapsed).toBeLessThanOrEqual(2000) expect(alertEl).toBeInTheDocument() }, ) }) })