import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useEffect, useState } from 'react' import { render, act, cleanup } from '@testing-library/react' import { createSSE, setToken } from '../src/api' import { createFakeEventSource, type FakeEventSource } from './helpers/sse-mock' // AZ-458 — SSE lifecycle + bearer-rotation reconnect. // // FT-P-09 — annotation-status SSE opens on mount (QUARANTINE) // FT-P-10 — annotation-status SSE closes on unmount (QUARANTINE) // FT-P-18 — live-GPS SSE opens within 5 s of flight select (fast) // FT-P-19 — live-GPS SSE closes within 1 s of deselect (fast) // NFT-PERF-03 — SSE bearer-rotation reconnect ≤ 5 s (e2e — see e2e/tests/sse_lifecycle.e2e.ts) // NFT-PERF-04 — live-GPS SSE opens within 5 s of flight select (fast — same as FT-P-18) // NFT-PERF-05 — live-GPS SSE closes within 1 s of deselect (fast — same as FT-P-19) // NFT-PERF-06 — annotation-status SSE unsubscribes within 1 s on unmount (QUARANTINE) // NFT-RES-02 — SSE bearer rotation — both streams reconnect within 5 s (e2e — see e2e companion) // // Black-box discipline: per AZ-458 AC-3 we do NOT stub `src/api/sse.ts`. We // patch `globalThis.EventSource` so we observe what URLs the production // module passes to the platform `new EventSource(url)` and when it calls // `.close()`. The consumer pattern (`useEffect` + `createSSE` + cleanup) is // reproduced by a small `` test harness that mirrors the shape // in `src/features/flights/FlightsPage.tsx:65-68`. // // Production status notes (drift documentation): // - AnnotationsPage today opens NO SSE — there is no annotation-status // subscription in `src/features/annotations/AnnotationsPage.tsx`. The // annotation-status scenarios are QUARANTINEd until the production path // lands; the assertions below describe what the test will look like. // - createSSE reads the bearer via `getToken()` at construction time but // the FlightsPage `useEffect` deps are `[selectedFlight, mode]` only — // the effect does NOT re-run when the bearer rotates. Bearer rotation // therefore does NOT reconnect today; this is the AC-2 drift, captured // via `it.fails()` against a `` that uses the same deps // shape as the production consumer. type EventSourceCtor = new (url: string) => EventSource let constructed: Array = [] let originalEventSource: EventSourceCtor | undefined function installFakeEventSource() { constructed = [] originalEventSource = (globalThis as { EventSource?: EventSourceCtor }).EventSource class StubEventSource extends EventTarget { public url: string public readyState: 0 | 1 | 2 = 0 public closed = false constructor(url: string) { super() this.url = url const fake = createFakeEventSource(url) as FakeEventSource & { closed: boolean } fake.closed = false const origClose = fake.close.bind(fake) fake.close = () => { fake.closed = true origClose() } constructed.push(fake) // Patch this instance to forward dispatch/close to the fake so // production code's `source.close()` flows through. this.close = () => fake.close() const inst = this as unknown as { onmessage?: (e: MessageEvent) => void; onerror?: (e: Event) => void; readyState: number } inst.readyState = 1 fake.addEventListener('message', (e) => inst.onmessage?.(e as MessageEvent)) fake.addEventListener('error', (e) => inst.onerror?.(e)) } close() { /* replaced in constructor */ } } ;(globalThis as { EventSource?: EventSourceCtor }).EventSource = StubEventSource as unknown as EventSourceCtor } function restoreEventSource() { if (originalEventSource === undefined) { delete (globalThis as { EventSource?: EventSourceCtor }).EventSource } else { ;(globalThis as { EventSource?: EventSourceCtor }).EventSource = originalEventSource } } beforeEach(() => { installFakeEventSource() }) afterEach(() => { cleanup() restoreEventSource() setToken(null) }) // Consumer pattern mirror — same deps shape as FlightsPage.tsx:65-68. function SseConsumer({ active, flightId, mode }: { active: boolean; flightId: string | null; mode: 'gps' | 'params' }) { const [received, setReceived] = useState([]) useEffect(() => { if (!active || !flightId || mode !== 'gps') return return createSSE<{ lat: number; lon: number }>( `/api/flights/${flightId}/live-gps`, (data) => setReceived((prev) => [...prev, data]), ) }, [active, flightId, mode]) return
{received.length}
} // Bearer-rotation consumer mirror — same deps shape (no token dep). This // reproduces the production drift: rotating the bearer does NOT cause a // reconnect because the effect dep array doesn't include the token. function SseConsumerNoTokenDep({ flightId }: { flightId: string | null }) { useEffect(() => { if (!flightId) return return createSSE(`/api/flights/${flightId}/live-gps`, () => { /* drop */ }) }, [flightId]) return null } describe('AZ-458 / createSSE — open/close lifecycle (FT-P-18/19, NFT-PERF-04/05)', () => { describe('FT-P-18 / NFT-PERF-04 — open on flight select', () => { it('opens exactly one EventSource when a flight is selected in gps mode', () => { // Arrange setToken('rot-token-A') // Act — mount with selectedFlight=flight-1 + mode=gps render() // Assert AC-1: exactly one EventSource constructed; URL targets the // selected flight's live-gps endpoint and carries the bearer. expect(constructed).toHaveLength(1) expect(constructed[0].url).toContain('/api/flights/flight-1/live-gps') expect(constructed[0].url).toContain('access_token=rot-token-A') }) it('does NOT open an EventSource when mode != gps (negative control)', () => { setToken('rot-token-A') render() expect(constructed).toHaveLength(0) }) }) describe('FT-P-19 / NFT-PERF-05 — close on deselect', () => { it('closes the EventSource when the flight is deselected', () => { setToken('rot-token-A') const { rerender } = render() expect(constructed).toHaveLength(1) const opened = constructed[0] expect(opened.closed).toBe(false) // Act — deselect flight (flightId → null). The useEffect cleanup runs // synchronously on the effect re-run, which is well under the 1 s budget. rerender() // Assert AC-1: EventSource closed. expect(opened.closed).toBe(true) // No new construction (the effect early-returns when flightId is null). expect(constructed).toHaveLength(1) }) it('closes on unmount (cleanup runs as part of teardown)', () => { setToken('rot-token-A') const { unmount } = render() expect(constructed).toHaveLength(1) const opened = constructed[0] unmount() expect(opened.closed).toBe(true) }) }) }) describe('AZ-458 / createSSE — bearer rotation (AC-2, NFT-PERF-03, NFT-RES-02)', () => { it('captures the bearer that was current at construction time (sanity check)', () => { setToken('boot-token') render() expect(constructed[0].url).toContain('access_token=boot-token') }) it.fails( 'AC-2 drift — when the bearer rotates AFTER the SSE is open, a new EventSource is created with the new token within 5 s (today the effect deps do not include the token, so this does NOT happen)', async () => { // Arrange — open the SSE with the bootstrap token. setToken('boot-token') render() expect(constructed).toHaveLength(1) expect(constructed[0].url).toContain('access_token=boot-token') // Act — rotate the bearer (as would after a successful // refresh). await act(async () => { setToken('rotated-token-B') // Yield to the React scheduler so any token-dependent effect could fire. await Promise.resolve() }) // Assert AC-2: a second EventSource is opened with the new token. // Today this assertion fails because the consumer's useEffect doesn't // depend on the token — the old EventSource stays connected with the // stale `access_token=boot-token`. expect(constructed).toHaveLength(2) expect(constructed[1].url).toContain('access_token=rotated-token-B') }, ) it('control — bearer rotation today does NOT reconnect the live-GPS SSE (drift seen)', async () => { setToken('boot-token') render() expect(constructed).toHaveLength(1) const stale = constructed[0] await act(async () => { setToken('rotated-token-B') await Promise.resolve() }) // QUARANTINE evidence: still only one EventSource; it still carries the // stale token. The e2e companion exercises the real wire and will FAIL // (correctly) once the spec is enforced suite-side. expect(constructed).toHaveLength(1) expect(stale.url).toContain('access_token=boot-token') expect(stale.closed).toBe(false) }) }) describe('AZ-458 / AnnotationsPage SSE (FT-P-09, FT-P-10, NFT-PERF-06)', () => { it.skip( 'QUARANTINE (no production behavior): annotation-status SSE opens on mount and closes on unmount within 1 s', () => { // When AnnotationsPage gains an annotation-status subscription, the // assertion shape (using the same EventSource stub as the live-GPS // tests above) is: // 1. mount // 2. expect(constructed).toHaveLength(1) — one annotation-status SSE // 3. expect(constructed[0].url).toContain('/api/annotations/.../status') // 4. unmount; expect(constructed[0].closed).toBe(true) // The test is skipped today because src/features/annotations/AnnotationsPage.tsx // does not open any SSE; asserting against absent behavior would be noise. expect(true).toBe(false) /* placeholder */ }, ) it('control — AnnotationsPage opens NO SSE today (drift evidence; the source does not call createSSE)', () => { // We don't mount AnnotationsPage here (it pulls Leaflet-free but heavy // canvas / video setup that has no bearing on the SSE assertion). The // observable proof is structural: the only `createSSE` consumer today is // FlightsPage. This test exists so the QUARANTINE state is visible in // the test report rather than only in comments. expect(constructed).toHaveLength(0) }) })