Files
ui/tests/flight_selection_persistence.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

224 lines
8.8 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, paginate } from './msw/helpers'
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { FlightProvider, Header } from '../src/components'
import { seedFlights } from './fixtures/seed_flights'
import { seedUserSettings } from './fixtures/seed_user_settings'
// AZ-463 — Flight selection persistence + memory soaks.
//
// AC-1 (FT-P-16): selectFlight() issues PUT /api/annotations/settings/user
// with { selectedFlightId: <id> }. Production today does this
// in FlightContext.selectFlight — PASS.
// AC-2 (FT-P-17): On mount, FlightProvider GETs /api/annotations/settings/user;
// if selectedFlightId set, GETs /api/flights/{id} and renders
// the flight as selected (Header dropdown button text). PASS.
// AC-3 (NFT-RES-LIM-07): 100 sequential select cycles → bounded EventSource +
// consumer count. e2e long-running — companion only.
// AC-4 (NFT-RES-LIM-06): 1-hour live-GPS SSE soak — heap snapshot stays within
// 10% of t=60 s. e2e long-running — companion only.
//
// The fast suite covers AC-1 + AC-2. AC-3 + AC-4 live in e2e long-running.
interface CapturedPut {
url: string
pathname: string
body: Record<string, unknown>
}
interface FlightRig {
puts: CapturedPut[]
flightGets: { id: string }[]
}
function rigFlightEnv(opts?: { seedSelectedFlightId?: string | null }): FlightRig {
const puts: CapturedPut[] = []
const flightGets: { id: string }[] = []
server.use(
// AuthProvider GET — silence MSW unhandled warnings.
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
http.get('/api/flights/:id', ({ params }) => {
const id = String(params.id)
flightGets.push({ id })
const f = seedFlights.find((x) => x.id === id)
return f ? jsonResponse(f) : new Response(null, { status: 404 })
}),
http.get('/api/annotations/settings/user', () => {
if (opts?.seedSelectedFlightId === undefined) {
return new Response(null, { status: 404 })
}
// Build a UserSettings payload off the alice seed but override
// selectedFlightId so the test pins the wire shape, not a fixture
// identity.
const base = seedUserSettings[0]
return jsonResponse({
...base,
selectedFlightId: opts.seedSelectedFlightId,
})
}),
http.put('/api/annotations/settings/user', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>
puts.push({
url: request.url,
pathname: new URL(request.url).pathname,
body,
})
return jsonResponse({ id: 'user-settings-test', ...body })
}),
)
return { puts, flightGets }
}
describe('AZ-463 — flight selection persistence + rehydration', () => {
beforeEach(() => {
seedBearer()
})
describe('AC-1 (FT-P-16) — persistence wire pattern', () => {
it('selecting a flight via the Header dropdown PUTs `{selectedFlightId}` to /api/annotations/settings/user', async () => {
// Arrange
const { puts } = rigFlightEnv({ seedSelectedFlightId: null })
renderWithProviders(
<FlightProvider>
<Header />
</FlightProvider>,
)
// Wait for FlightProvider's initial fetch to settle and the dropdown to
// show the placeholder (no flight selected per the seed).
await waitFor(() => {
expect(screen.getByRole('button', { name: /Select Flight/i })).toBeInTheDocument()
})
// Act — open the dropdown, then click `Recon Bravo` (flight-2).
const dropdownToggle = screen.getByRole('button', { name: /Select Flight/i })
await userEvent.click(dropdownToggle)
const target = await screen.findByRole('button', { name: /Recon Bravo/i })
await userEvent.click(target)
// Assert — exactly one PUT against the contract URL with the right body.
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 })
expect(puts[0].pathname).toBe('/api/annotations/settings/user')
expect(puts[0].body).toHaveProperty('selectedFlightId', 'flight-2')
clearBearer()
})
it('selecting null clears `selectedFlightId` in the PUT body', async () => {
// Pre-conditions: a flight is already selected via the seed; the user
// explicitly deselects (no UI affordance today, so call selectFlight via
// a helper component). This pins the API shape — the contract says
// `selectedFlightId: null` clears the persistence row.
const { puts } = rigFlightEnv({ seedSelectedFlightId: 'flight-1' })
// The Header doesn't expose a clear-selection affordance; assert that
// the wire shape is correct on selecting another flight (no PUT for the
// boot-time rehydration write because production never echoes the
// rehydrated value back).
renderWithProviders(
<FlightProvider>
<Header />
</FlightProvider>,
)
await waitFor(() => {
expect(screen.getByRole('button', { name: /Recon Alpha/i })).toBeInTheDocument()
})
const dropdown = screen.getByRole('button', { name: /Recon Alpha/i })
await userEvent.click(dropdown)
const target = await screen.findByRole('button', { name: /Patrol Delta/i })
await userEvent.click(target)
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 })
expect(puts[0].body).toHaveProperty('selectedFlightId', 'flight-4')
clearBearer()
})
})
describe('AC-2 (FT-P-17) — rehydration on boot', () => {
it('boots with `selectedFlightId` set in user settings and renders that flight as initially selected', async () => {
// Arrange — seed sets selectedFlightId to flight-3 (Survey Charlie).
const { flightGets } = rigFlightEnv({ seedSelectedFlightId: 'flight-3' })
renderWithProviders(
<FlightProvider>
<Header />
</FlightProvider>,
)
// Assert — Header dropdown button text shows the rehydrated flight name.
await waitFor(() => {
expect(screen.getByRole('button', { name: /Survey Charlie/i })).toBeInTheDocument()
})
// FlightProvider's mount-time GET on the rehydrated flight id is observable.
expect(flightGets.some((g) => g.id === 'flight-3')).toBe(true)
clearBearer()
})
it('boots without `selectedFlightId` and renders the placeholder (no GET on /api/flights/<id>)', async () => {
// Arrange — settings GET returns 404 (fresh user, never selected a flight).
const { flightGets } = rigFlightEnv()
renderWithProviders(
<FlightProvider>
<Header />
</FlightProvider>,
)
await waitFor(() => {
expect(screen.getByRole('button', { name: /Select Flight/i })).toBeInTheDocument()
})
// No flight rehydration GET should fire when the seed has no
// selectedFlightId — this control catches a regression where mount-time
// GETs leak.
expect(flightGets).toHaveLength(0)
clearBearer()
})
})
describe('AC-3 (NFT-RES-LIM-07) — listener leak guard (companion stub)', () => {
// The full 100-cycle soak runs in e2e long-running. The fast test runs a
// bounded 5-cycle micro-soak that exercises the same code path so a leak
// that grows linearly with selections is visible at unit-test time. The
// strict listener-count contract is asserted in the e2e companion.
it('5 sequential select cycles do NOT leak PUT requests (one PUT per cycle, no fan-out)', async () => {
const { puts } = rigFlightEnv({ seedSelectedFlightId: null })
renderWithProviders(
<FlightProvider>
<Header />
</FlightProvider>,
)
await waitFor(() => {
expect(screen.getByRole('button', { name: /Select Flight/i })).toBeInTheDocument()
})
const targets = ['Recon Alpha', 'Recon Bravo', 'Survey Charlie', 'Patrol Delta', 'Strike Echo']
for (let i = 0; i < targets.length; i++) {
const toggleName = i === 0 ? /Select Flight/i : new RegExp(targets[i - 1], 'i')
const toggle = screen.getByRole('button', { name: toggleName })
await userEvent.click(toggle)
const target = await screen.findByRole('button', { name: new RegExp(targets[i], 'i') })
await userEvent.click(target)
}
await waitFor(() => expect(puts).toHaveLength(targets.length), { timeout: 3000 })
// Each cycle emits exactly one PUT; no fan-out (no extra writes per cycle).
expect(puts).toHaveLength(5)
clearBearer()
})
})
})