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 } 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. // // v2 SettingsPage wraps `save()` in try/catch/finally and renders an inline // role="alert" in the sticky footer when the PUT rejects. The three contract // tests below assert that wiring directly. 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 (v2) renders a single sticky-footer "Save Changes" button that * persists whichever panels are dirty in parallel. The footer button is the * only Save affordance; per-panel Save buttons no longer exist. We must mark * the Tenant panel as dirty by editing a field before the footer button * becomes enabled — selecting the Military Unit input by accessible name and * typing a single character is enough to flip the dirty flag. */ async function findSystemSaveButton(): Promise { // Wait until the data has loaded (heading is present immediately, but the // input is rendered only after the GET resolves). await screen.findByRole('heading', { name: /Tenant Configuration/i }) return screen.getByRole('button', { name: /^Save Changes$/i }) } async function makeTenantDirty(): Promise { const militaryUnit = await screen.findByLabelText(/Military Unit/i) await userEvent.type(militaryUnit, '!') } async function renderAndClickSave(): Promise { renderWithProviders() await makeTenantDirty() const saveButton = await findSystemSaveButton() await userEvent.click(saveButton) } describe('AZ-477 — Settings save resilience + 2 s error budget', () => { beforeEach(() => { seedBearer() }) afterEach(() => { clearBearer() }) describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => { it('PUT 500 → Save button is no longer disabled within 2 s', async () => { rigSettingsEnv({ kind: 'http', status: 500 }) await renderAndClickSave() const saveButton = await findSystemSaveButton() await waitFor( () => expect(saveButton).not.toBeDisabled(), { timeout: 2000 }, ) }) it('PUT 500 → an in-DOM error region (role="alert") appears within 2 s', async () => { 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) }) }) describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => { it('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('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('500 → DOM error region visible within 2000 ms of the response', async () => { 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() }) }) })