Files
ui/tests/sse_lifecycle.test.tsx
T
Oleksandr Bezdieniezhnykh 23746ec61d [AZ-485] Add Public API barrels + STC-ARCH-01 (F4 close)
Closes architecture baseline finding F4. Every component now exposes
its Public API through `src/<component>/index.ts`; cross-component
imports go through the barrel. `scripts/check-arch-imports.mjs` plus
`STC-ARCH-01` in the static profile enforce the rule; tests in
`tests/architecture_imports.test.ts` cover AC-4/AC-5 + 2 exemption
cases. One F3-pending exemption (`classColors`) is documented in 5
places (barrel, consumer, script, doc, test) to avoid a circular
import.

Phase B cycle 1 batch 1 of 2 (epic AZ-447). Batch 2 is AZ-486
(endpoint builders) — blocked on this commit landing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:33:30 +03:00

246 lines
11 KiB
TypeScript

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 <AnnotationsPage> 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 `<SseConsumer>` 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 `<SseConsumer>` that uses the same deps
// shape as the production consumer.
type EventSourceCtor = new (url: string) => EventSource
let constructed: Array<FakeEventSource & { closed: boolean }> = []
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<unknown[]>([])
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 <div data-testid="sse-events">{received.length}</div>
}
// 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(<SseConsumer active flightId="flight-1" mode="gps" />)
// 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(<SseConsumer active flightId="flight-1" mode="params" />)
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(<SseConsumer active flightId="flight-1" mode="gps" />)
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(<SseConsumer active flightId={null} mode="gps" />)
// 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(<SseConsumer active flightId="flight-1" mode="gps" />)
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(<SseConsumer active flightId="flight-1" mode="gps" />)
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(<SseConsumerNoTokenDep flightId="flight-1" />)
expect(constructed).toHaveLength(1)
expect(constructed[0].url).toContain('access_token=boot-token')
// Act — rotate the bearer (as <AuthContext> 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(<SseConsumerNoTokenDep flightId="flight-1" />)
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 <AnnotationsPage> 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 <AnnotationsPage>
// 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)
})
})