Files
ui/tests/settings_resilience.test.tsx
T
Oleksandr Bezdieniezhnykh bd2b718ddf [AZ-463] [AZ-469] [AZ-476] [AZ-477] Batch 6 - flight/responsive/upload/settings tests
- AZ-463 flight selection persistence (FT-P-16) + rehydration
  on boot (FT-P-17) PASS at the wire; 100-cycle leak guard
  (NFT-RES-LIM-07) and 1h SSE soak (NFT-RES-LIM-06)
  scaffolded as RUN_LONG_RUNNING-gated e2e companions.
- AZ-469 browser-support smoke (FT-P-34) runs in both
  Chromium and Firefox via the existing playwright config;
  responsive variants (FT-P-35 480px / FT-P-36 1024px) PASS
  in fast (Tailwind class shape) and e2e (visibility).
- AZ-476 upload 501 MB -> 413: AC-1 user-visible error is
  drift today (uploadFiles silently falls through to local
  mode); it.fails() + control + e2e test.fail. AC-2 no-alert
  PASS via dialog spy.
- AZ-477 settings save 500 / network drop: AC-1+AC-2+AC-3
  all drift today (no try/finally, no error region, deadline
  unmeasurable); 4 it.fails() + control pinning the stuck-
  disabled drift; e2e companions test.fail mirror it.
- LESSONS.md seeded: vi.stubGlobal('URL', {...URL,...})
  destroys the URL constructor and breaks new URL(...) in
  MSW; patch the methods directly instead.

Code review: PASS (0 findings). Fast: 22/22 files, 120
passed / 13 skipped. Static: 24/24 PASS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 05:19:35 +03:00

247 lines
9.7 KiB
TypeScript

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/SettingsPage'
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.get('/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<HTMLElement> {
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<void> {
renderWithProviders(<SettingsPage />)
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()
},
)
})
})