import { useEffect, useState } from 'react' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from './msw/server' import { jsonResponse, paginate } from './msw/helpers' import { renderWithProviders, screen, fireEvent, waitFor, userEvent, } from './helpers/render' import { seedBearer, clearBearer } from './helpers/auth' import { createSSE } from '../src/api' import App from '../src/App' import { AnnotationsPage } from '../src/features/annotations' import { FlightProvider, useFlight } from '../src/components' import { seedFlights } from './fixtures/seed_flights' import { AnnotationSource, AnnotationStatus, Affiliation, CombatReadiness, MediaType, MediaStatus, } from '../src/types' import type { Media, AnnotationListItem } from '../src/types' // AZ-478 — network-resilience contracts. // // AC-1 (NFT-RES-03): all `/api/*` requests fail with network errors at boot. // The SPA must: // a) render an error state (not silently degrade), and // b) NOT register a service worker / offline cache. // Today (drift): the AuthProvider's refresh fails → // `user` stays null → ProtectedRoute redirects to /login; // the LoginPage form renders, NOT a network-error // indicator. (b) is enforced statically (STC-N3) AND // asserted at runtime here for defence in depth. // AC-2 (NFT-RES-09): annotation download via `canvas.toBlob` on a tainted // canvas throws SecurityError. The page must NOT crash; // a user-visible fallback (alternative download path or // an in-DOM error) must be rendered. // Today (drift): `AnnotationsPage.handleDownload` calls // `canvas.toBlob` without a try/catch — the SecurityError // escapes as an unhandled rejection from the async // handleDownload. No fallback UI is rendered. // AC-3 (NFT-RES-10): when an SSE EventSource fires `error` with // `readyState === 2` (CLOSED), within 2 s a // connection-lost indicator must appear in the DOM with // an i18n-keyed text. // Today (drift): `src/api/sse.ts` calls `onError?.(e)` // but no consumer renders any user-visible indicator, // and there is no `connection-lost` i18n key. // --------------------------------------------------------------------------- // AC-1 — offline at boot. // --------------------------------------------------------------------------- describe('AZ-478 — AC-1 (NFT-RES-03): network offline at boot', () => { let originalServiceWorker: PropertyDescriptor | undefined let unhandledHandler: ((reason: unknown) => void) | null = null const swallowed: unknown[] = [] beforeEach(() => { // Every /api/* request errors at the network layer (DNS/conn refused). // This drives AuthProvider's refresh down its `.catch` branch. server.use( http.all('/api/*', () => HttpResponse.error()), ) // Provide a minimal `navigator.serviceWorker` so we can assert // registrations stays empty. JSDOM has no SW by default. originalServiceWorker = Object.getOwnPropertyDescriptor(navigator, 'serviceWorker') Object.defineProperty(navigator, 'serviceWorker', { configurable: true, get() { return { getRegistrations: async () => [], register: vi.fn(), } }, }) // Catch the deliberate refresh failure so vitest doesn't error on the // unhandled rejection. swallowed.length = 0 unhandledHandler = (reason: unknown) => { swallowed.push(reason) } process.on('unhandledRejection', unhandledHandler) }) afterEach(() => { if (unhandledHandler) { process.off('unhandledRejection', unhandledHandler) unhandledHandler = null } if (originalServiceWorker) { Object.defineProperty(navigator, 'serviceWorker', originalServiceWorker) } else { // navigator.serviceWorker is non-configurable in some envs; deletion // may silently no-op — that's fine for cleanup. try { delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker } catch { // ignore } } }) it('SPA does NOT register a service worker (defence in depth, also enforced statically as STC-N3)', async () => { // Arrange / Act — boot the app at "/". renderWithProviders(, { withoutAuth: true, initialEntries: ['/'] }) // Allow the AuthProvider's refresh promise to reject. await new Promise((r) => setTimeout(r, 50)) // Assert — no SW was registered. STC-N3 already gates this at the source // tree, but the runtime check catches a future regression where the // registration is moved to a dynamically-imported module that grep // misses. const regs = await navigator.serviceWorker.getRegistrations() expect(regs).toEqual([]) }) it.fails( 'SPA renders a user-visible network-error indicator when boot APIs are offline', async () => { // Drift: today the fall-through behaviour is "redirect to /login". // The LoginPage form renders; no error banner / offline indicator // exists. Spec requires an in-DOM indicator (e.g., role="alert" with // an i18n-keyed message such as "common.networkError"). renderWithProviders(, { withoutAuth: true, initialEntries: ['/'] }) const banner = await screen.findByRole('alert', {}, { timeout: 2000 }) expect(banner.textContent ?? '').toMatch(/offline|network|connection/i) }, ) it('control: today the SPA falls through to /login (drift snapshot)', async () => { // Pins current behaviour. When AC-1 lands and the SPA shows a network // banner instead, this test becomes flaky — the redirect may not happen // — and the snapshot has to be updated alongside the AC fix. renderWithProviders(, { withoutAuth: true, initialEntries: ['/'] }) // The login form's i18n header text is "AZAION". await waitFor(() => expect(screen.getByText('AZAION')).toBeInTheDocument(), { timeout: 2000, }) // The login form is rendered (Sign In submit button is the i18n key // login.submit). LoginPage doesn't wire htmlFor between