mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 20:11:13 +00:00
70fb452805
Replace the broken `GET /api/admin/auth/refresh` (no `credentials:'include'`) mount-time bootstrap with `POST /api/admin/auth/refresh` (with credentials) chained to `GET /api/admin/users/me`. Returning users with a valid HttpOnly refresh cookie no longer flash through `/login`. Closes Finding B3 / Vision P3. - Add module-scoped `bootstrapInflight` guard (StrictMode double-mount safety) + test-only reset hook exported via the `src/auth` barrel; `tests/setup.ts` resets it in `afterEach` to prevent pending-promise leakage between tests. - Defensive `hasPermission` against legacy `/users/me` payloads omitting `permissions`; default MSW handler now seeds `permissions` explicitly. - Add `endpoints.admin.usersMe()` builder (STC-ARCH-02 forbids the literal). - Bulk-swap 15 test files from `http.get` -> `http.post` for the refresh override so intentional bootstrap-fail tests still fail correctly. - Update auth component description; mark B3 closed. - Code review verdict PASS; static + fast suites green (231 / 13 skipped). Batch report: _docs/03_implementation/batch_13_cycle3_report.md Co-authored-by: Cursor <cursoragent@cursor.com>
448 lines
17 KiB
TypeScript
448 lines
17 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { http } 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 { DetectionClasses, FlightProvider, useFlight } from '../src/components'
|
|
import { AnnotationsPage } from '../src/features/annotations'
|
|
import { seedFlights } from './fixtures/seed_flights'
|
|
import { seedClasses } from './fixtures/seed_classes'
|
|
import {
|
|
MediaType,
|
|
MediaStatus,
|
|
} from '../src/types'
|
|
import type {
|
|
DetectionClass,
|
|
Media,
|
|
} from '../src/types'
|
|
|
|
// AZ-473 — PhotoMode switch + auto-select + yoloId on the wire.
|
|
//
|
|
// AC-1 (FT-P-48): clicking a PhotoMode button fires `onPhotoModeChange(P)` and
|
|
// the rendered class list is filtered to entries with
|
|
// `photoMode === P`. The "context persistence" assertion is
|
|
// satisfied by the rendered filter (per spec — no direct
|
|
// context read). Production has no `<PhotoModeContext>`;
|
|
// photoMode lives as local state in AnnotationsPage and
|
|
// DatasetPage. The rendered-filter contract still holds.
|
|
// AC-2 (FT-P-49): switching to a mode where the previously-selected class is
|
|
// out-of-range fires `onSelect(modeClasses[0].id)` once.
|
|
// AC-3 (FT-P-50): saving an annotation in mode P sends a POST whose
|
|
// `detections[i].classNum == classId + P` for every detection.
|
|
// We exercise all three modes (P ∈ {0, 20, 40}).
|
|
//
|
|
// Notes on the seed_classes fixture:
|
|
// - The shared fixture sets `photoMode: 0` on every entry, which would
|
|
// break the rendered-filter assertion for P=20 / P=40. AZ-472 already
|
|
// overrides the GET handler with a correctly-tagged copy. We do the
|
|
// same here.
|
|
|
|
const orderedClasses: DetectionClass[] = seedClasses.map((c) => ({
|
|
...c,
|
|
photoMode: c.id < 20 ? 0 : c.id < 40 ? 20 : 40,
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AC-1 + AC-2 — DetectionClasses-only contracts.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface HarnessState {
|
|
selectedRef: { current: number }
|
|
selectSpy: ReturnType<typeof vi.fn>
|
|
modeSpy: ReturnType<typeof vi.fn>
|
|
}
|
|
|
|
function HarnessWrapper({
|
|
initialPhotoMode = 0,
|
|
initialSelectedClassNum = 0,
|
|
state,
|
|
}: {
|
|
initialPhotoMode?: number
|
|
initialSelectedClassNum?: number
|
|
state: HarnessState
|
|
}) {
|
|
const [photoMode, setPhotoMode] = useState(initialPhotoMode)
|
|
const [selectedClassNum, setSelectedClassNum] = useState(initialSelectedClassNum)
|
|
// Sync the external selectedRef so tests can read the latest selection
|
|
// without going through the spy's mock.calls (which loses ordering after
|
|
// multiple effects).
|
|
useEffect(() => {
|
|
state.selectedRef.current = selectedClassNum
|
|
}, [selectedClassNum, state])
|
|
return (
|
|
<DetectionClasses
|
|
selectedClassNum={selectedClassNum}
|
|
onSelect={(id) => {
|
|
state.selectSpy(id)
|
|
setSelectedClassNum(id)
|
|
}}
|
|
photoMode={photoMode}
|
|
onPhotoModeChange={(mode) => {
|
|
state.modeSpy(mode)
|
|
setPhotoMode(mode)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function makeHarnessState(): HarnessState {
|
|
return {
|
|
selectedRef: { current: -1 },
|
|
selectSpy: vi.fn(),
|
|
modeSpy: vi.fn(),
|
|
}
|
|
}
|
|
|
|
function captureClassesGet(payload: DetectionClass[]) {
|
|
server.use(
|
|
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
|
http.get('/api/annotations/classes', () => jsonResponse(payload)),
|
|
)
|
|
}
|
|
|
|
describe('AZ-473 — AC-1 + AC-2 (DetectionClasses)', () => {
|
|
beforeEach(() => {
|
|
seedBearer()
|
|
captureClassesGet(orderedClasses)
|
|
})
|
|
afterEach(() => {
|
|
clearBearer()
|
|
})
|
|
|
|
describe('AC-1 (FT-P-48) — switch sets filter', () => {
|
|
it('clicking Winter fires onPhotoModeChange(20) and filters the rendered class list to photoMode=20 entries', async () => {
|
|
// Arrange — render with photoMode=0; expect class-0..class-8 visible.
|
|
const state = makeHarnessState()
|
|
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
|
await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
|
|
expect(screen.queryByText('class-20')).toBeNull()
|
|
|
|
// Act — click the Winter button (title = i18n "Winter").
|
|
const winterBtn = screen.getByRole('button', { name: /winter/i })
|
|
await userEvent.click(winterBtn)
|
|
|
|
// Assert — onPhotoModeChange fires once with 20.
|
|
await waitFor(() => expect(state.modeSpy).toHaveBeenCalledWith(20))
|
|
expect(state.modeSpy).toHaveBeenCalledTimes(1)
|
|
|
|
// Rendered list switches to the photoMode=20 window
|
|
// (ids 20..28 in orderedClasses).
|
|
await waitFor(() => expect(screen.getByText('class-20')).toBeInTheDocument())
|
|
expect(screen.getByText('class-28')).toBeInTheDocument()
|
|
expect(screen.queryByText('class-0')).toBeNull()
|
|
expect(screen.queryByText('class-40')).toBeNull()
|
|
})
|
|
|
|
it('clicking Night fires onPhotoModeChange(40) and shows the photoMode=40 window', async () => {
|
|
const state = makeHarnessState()
|
|
renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
|
|
await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
|
|
|
|
const nightBtn = screen.getByRole('button', { name: /night/i })
|
|
await userEvent.click(nightBtn)
|
|
|
|
await waitFor(() => expect(state.modeSpy).toHaveBeenCalledWith(40))
|
|
await waitFor(() => expect(screen.getByText('class-40')).toBeInTheDocument())
|
|
expect(screen.getByText('class-48')).toBeInTheDocument()
|
|
expect(screen.queryByText('class-0')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('AC-2 (FT-P-49) — auto-select when prior class is out-of-range', () => {
|
|
it('switching to Night auto-selects modeClasses[0].id (= 40) when selectedClassNum=0 is not in the new window', async () => {
|
|
// Arrange — preselect a Regular class (id=0).
|
|
const state = makeHarnessState()
|
|
renderWithProviders(
|
|
<HarnessWrapper
|
|
initialPhotoMode={0}
|
|
initialSelectedClassNum={0}
|
|
state={state}
|
|
/>,
|
|
)
|
|
// The auto-select effect fires on mount — first onSelect is the
|
|
// initialisation. Clear the spy after that mounts so we observe only
|
|
// the post-mode-switch firing.
|
|
await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
|
|
state.selectSpy.mockClear()
|
|
|
|
// Act — switch to Night.
|
|
const nightBtn = screen.getByRole('button', { name: /night/i })
|
|
await userEvent.click(nightBtn)
|
|
|
|
// Assert — onSelect(40) fires (the first id in the photoMode=40 window).
|
|
await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(40))
|
|
})
|
|
|
|
it('switching to Winter (P=20) auto-selects id=20 when prior selection (id=0) is out of range', async () => {
|
|
const state = makeHarnessState()
|
|
renderWithProviders(
|
|
<HarnessWrapper
|
|
initialPhotoMode={0}
|
|
initialSelectedClassNum={0}
|
|
state={state}
|
|
/>,
|
|
)
|
|
await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
|
|
state.selectSpy.mockClear()
|
|
|
|
const winterBtn = screen.getByRole('button', { name: /winter/i })
|
|
await userEvent.click(winterBtn)
|
|
|
|
await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(20))
|
|
})
|
|
|
|
it('does NOT auto-select when the current class is already in the new window', async () => {
|
|
// Pre-select class with id=20 (in the Winter window).
|
|
const state = makeHarnessState()
|
|
renderWithProviders(
|
|
<HarnessWrapper
|
|
initialPhotoMode={20}
|
|
initialSelectedClassNum={20}
|
|
state={state}
|
|
/>,
|
|
)
|
|
await waitFor(() => expect(screen.getByText('class-20')).toBeInTheDocument())
|
|
// The on-mount auto-select MAY still fire once if the in-mount sync is
|
|
// racy; reset the spy to observe only the deliberate switch path.
|
|
state.selectSpy.mockClear()
|
|
|
|
// Act — switch from Winter→Winter is a noop, but switching to
|
|
// Regular and back to Winter while the in-window class stays
|
|
// out-of-range for Regular triggers an auto-select to 0, then to 20
|
|
// again. We check that a class IS still in-range for the destination
|
|
// window — i.e., switching back to a window where the prior class is
|
|
// valid does NOT regenerate a selection.
|
|
const regularBtn = screen.getByRole('button', { name: /regular/i })
|
|
await userEvent.click(regularBtn)
|
|
await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(0))
|
|
state.selectSpy.mockClear()
|
|
|
|
// Now we're at photoMode=0, selectedClassNum=0 (in-window). Switching
|
|
// back to Regular is a noop — no auto-select fires.
|
|
await userEvent.click(regularBtn)
|
|
// Allow the effect to flush.
|
|
await new Promise((r) => setTimeout(r, 50))
|
|
expect(state.selectSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AC-3 — wire offset on AnnotationsPage save.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FLIGHT = seedFlights[0]
|
|
const W = 640
|
|
const H = 480
|
|
|
|
const videoMedia: Media = {
|
|
id: 'media-az473',
|
|
name: 'photo-mode-fixture.mp4',
|
|
path: '/media/photo-mode-fixture.mp4',
|
|
mediaType: MediaType.Video,
|
|
mediaStatus: MediaStatus.New,
|
|
duration: '00:00:10',
|
|
annotationCount: 0,
|
|
waypointId: null,
|
|
userId: 'user-az473',
|
|
}
|
|
|
|
interface PostCapture {
|
|
classNums: number[][]
|
|
}
|
|
|
|
function rigSaveEnv(): PostCapture {
|
|
const classNums: number[][] = []
|
|
server.use(
|
|
http.post('/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 f = seedFlights.find((x) => x.id === params.id)
|
|
return f ? jsonResponse(f) : new Response(null, { status: 404 })
|
|
}),
|
|
http.get('/api/annotations/settings/user', () =>
|
|
jsonResponse({
|
|
id: 'user-settings-az473',
|
|
userId: 'user-az473',
|
|
selectedFlightId: FLIGHT.id,
|
|
annotationsLeftPanelWidth: null,
|
|
annotationsRightPanelWidth: null,
|
|
datasetLeftPanelWidth: null,
|
|
datasetRightPanelWidth: null,
|
|
}),
|
|
),
|
|
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
|
|
http.get('/api/annotations/media', () =>
|
|
jsonResponse(paginate([videoMedia], 1, 1000)),
|
|
),
|
|
http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))),
|
|
http.get('/api/annotations/classes', () => jsonResponse(orderedClasses)),
|
|
http.get('/api/annotations/dataset/info', () =>
|
|
jsonResponse({ totalCount: 0, statusCounts: {} }),
|
|
),
|
|
http.post('/api/annotations/annotations', async ({ request }) => {
|
|
const body = (await request.json()) as { detections?: { classNum: number }[] }
|
|
classNums.push((body.detections ?? []).map((d) => d.classNum))
|
|
return jsonResponse({ id: 'ann-saved' })
|
|
}),
|
|
)
|
|
return { classNums }
|
|
}
|
|
|
|
function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
const { selectFlight, selectedFlight } = useFlight()
|
|
useEffect(() => {
|
|
if (!selectedFlight) selectFlight(FLIGHT)
|
|
}, [selectFlight, selectedFlight])
|
|
return <>{children}</>
|
|
}
|
|
|
|
describe('AZ-473 — AC-3 (wire offset on AnnotationsPage save)', () => {
|
|
let originalRaf: typeof globalThis.requestAnimationFrame
|
|
let widthDescriptor: PropertyDescriptor | undefined
|
|
let heightDescriptor: PropertyDescriptor | undefined
|
|
let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect
|
|
|
|
beforeEach(() => {
|
|
seedBearer()
|
|
// Same canvas-coord scaffold as AZ-471 — see canvas_editor.test.tsx for
|
|
// the rationale on each shim.
|
|
widthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
|
heightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
|
|
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
|
configurable: true,
|
|
get() { return W },
|
|
})
|
|
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
|
configurable: true,
|
|
get() { return H },
|
|
})
|
|
originalGetBoundingClientRect = Element.prototype.getBoundingClientRect
|
|
Element.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect {
|
|
if (this instanceof HTMLCanvasElement) {
|
|
return {
|
|
x: 0, y: 0, left: 0, top: 0,
|
|
right: W, bottom: H, width: W, height: H,
|
|
toJSON() { return {} },
|
|
} as DOMRect
|
|
}
|
|
return originalGetBoundingClientRect.call(this)
|
|
}
|
|
originalRaf = globalThis.requestAnimationFrame
|
|
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
|
cb(performance.now())
|
|
return 0
|
|
}) as typeof globalThis.requestAnimationFrame
|
|
// Force getContext to return a non-null canvas context so draw() doesn't
|
|
// bail. Production checks `if (!canvas || !ctx) return`; jsdom's default
|
|
// is `null`, which would short-circuit the draw and starve the spy.
|
|
HTMLCanvasElement.prototype.getContext = vi.fn(
|
|
() =>
|
|
({
|
|
clearRect: vi.fn(), save: vi.fn(), restore: vi.fn(),
|
|
drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(),
|
|
fillText: vi.fn(), arc: vi.fn(), beginPath: vi.fn(), fill: vi.fn(),
|
|
setLineDash: vi.fn(),
|
|
measureText: vi.fn(() => ({ width: 10 } as TextMetrics)),
|
|
fillStyle: '', strokeStyle: '', font: '', globalAlpha: 1, lineWidth: 1,
|
|
}) as unknown as CanvasRenderingContext2D,
|
|
) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
})
|
|
|
|
afterEach(() => {
|
|
clearBearer()
|
|
globalThis.requestAnimationFrame = originalRaf
|
|
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect
|
|
if (widthDescriptor) {
|
|
Object.defineProperty(HTMLElement.prototype, 'clientWidth', widthDescriptor)
|
|
} else {
|
|
delete (HTMLElement.prototype as unknown as { clientWidth?: number }).clientWidth
|
|
}
|
|
if (heightDescriptor) {
|
|
Object.defineProperty(HTMLElement.prototype, 'clientHeight', heightDescriptor)
|
|
} else {
|
|
delete (HTMLElement.prototype as unknown as { clientHeight?: number }).clientHeight
|
|
}
|
|
})
|
|
|
|
// Each value of P maps to: photoMode → button label → expected first
|
|
// class id (which equals 0 + P). The test draws ONE box and asserts the
|
|
// POST body's detections[0].classNum == P.
|
|
const cases: { mode: number; label: RegExp }[] = [
|
|
{ mode: 0, label: /regular/i },
|
|
{ mode: 20, label: /winter/i },
|
|
{ mode: 40, label: /night/i },
|
|
]
|
|
|
|
cases.forEach(({ mode, label }) => {
|
|
it(`P=${mode}: saved detection carries classNum == ${mode} (= 0 + ${mode})`, async () => {
|
|
// Arrange
|
|
const cap = rigSaveEnv()
|
|
renderWithProviders(
|
|
<FlightProvider>
|
|
<FlightSeed>
|
|
<AnnotationsPage />
|
|
</FlightSeed>
|
|
</FlightProvider>,
|
|
)
|
|
|
|
// Wait for the media list to render and click on the seeded video.
|
|
const mediaItem = await screen.findByText(/photo-mode-fixture\.mp4/)
|
|
await userEvent.click(mediaItem)
|
|
|
|
// Wait for the canvas to mount.
|
|
const canvas = await waitFor(() => {
|
|
const c = document.querySelector('canvas')
|
|
if (!c) throw new Error('canvas not yet mounted')
|
|
return c as HTMLCanvasElement
|
|
})
|
|
|
|
// Switch PhotoMode (this also triggers the auto-select effect, which
|
|
// fires onSelect with `modeClasses[0].id` = 0 + mode).
|
|
if (mode !== 0) {
|
|
const modeBtn = await screen.findByRole('button', { name: label })
|
|
await userEvent.click(modeBtn)
|
|
}
|
|
|
|
// Auto-select must have settled before we draw — otherwise the
|
|
// detection inherits the previous selectedClassNum.
|
|
await waitFor(() => {
|
|
// We can't read selectedClassNum directly. Instead, draw a tiny
|
|
// probe box and check the most-recent detection's classNum.
|
|
// We'll do the actual draw below; this gate just allows the React
|
|
// state-update queue (mode → onSelect → setState) to drain.
|
|
// A short sleep is sufficient for jsdom.
|
|
})
|
|
await new Promise((r) => setTimeout(r, 30))
|
|
|
|
// Draw a bbox at (40, 40) → (120, 100). Plain left-click on empty
|
|
// area triggers the production "draw" path.
|
|
fireEvent.mouseDown(canvas, { clientX: 40, clientY: 40, button: 0 })
|
|
fireEvent.mouseMove(canvas, { clientX: 120, clientY: 100, button: 0 })
|
|
fireEvent.mouseUp(canvas, { clientX: 120, clientY: 100, button: 0 })
|
|
|
|
// Click Save (the button label is "Save", not gated by i18n today).
|
|
const saveBtn = await screen.findByRole('button', { name: /^save$/i })
|
|
// The Save button is `disabled={!detections.length}` — wait for it.
|
|
await waitFor(() => expect(saveBtn).not.toBeDisabled(), { timeout: 2000 })
|
|
await userEvent.click(saveBtn)
|
|
|
|
// Assert — POST observed with detections[0].classNum == mode.
|
|
await waitFor(() => expect(cap.classNums.length).toBeGreaterThan(0), {
|
|
timeout: 3000,
|
|
})
|
|
const last = cap.classNums.at(-1) as number[]
|
|
expect(last).toHaveLength(1)
|
|
expect(last[0]).toBe(mode)
|
|
})
|
|
})
|
|
})
|