mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 10:31:10 +00:00
23746ec61d
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>
246 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|