mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 13:21:11 +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>
268 lines
11 KiB
TypeScript
268 lines
11 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 } from '../src/components'
|
|
import { AnnotationsPage } from '../src/features/annotations'
|
|
import { AnnotationSource, AnnotationStatus, MediaType, MediaStatus, Affiliation, CombatReadiness } from '../src/types'
|
|
import type { Media, AnnotationListItem, Detection } from '../src/types'
|
|
|
|
// AZ-460 — Annotation save URL + payload contract
|
|
//
|
|
// AC-1 (FT-P-07): outbound URL is the doubly-prefixed canary
|
|
// `/api/annotations/annotations` (gateway prefix + service base).
|
|
// AC-2 (FT-P-08): outbound body contains all required fields:
|
|
// Source, WaypointId, videoTime, mediaId, detections, status.
|
|
// AC-3: the required-fields check runs for at least three save entry
|
|
// points (AI suggestion accept, manual draw, bulk-edit save).
|
|
// Production today only exposes ONE save path (`<AnnotationsPage>`'s
|
|
// Save button); AI-suggestion-accept and bulk-edit-save are not yet
|
|
// wired in production. Those two scenarios are recorded as
|
|
// `it.skip` QUARANTINE entries until Phase B lands them.
|
|
|
|
const seedDetection: Detection = {
|
|
id: 'det-existing',
|
|
classNum: 0,
|
|
label: 'class-0',
|
|
confidence: 0.92,
|
|
affiliation: Affiliation.Hostile,
|
|
combatReadiness: CombatReadiness.Ready,
|
|
centerX: 0.4,
|
|
centerY: 0.5,
|
|
width: 0.1,
|
|
height: 0.15,
|
|
}
|
|
|
|
const seedMediaItem: Media = {
|
|
id: 'media-az460',
|
|
name: 'az460.jpg',
|
|
path: '/media/az460.jpg',
|
|
mediaType: MediaType.Image,
|
|
mediaStatus: MediaStatus.New,
|
|
duration: null,
|
|
annotationCount: 1,
|
|
waypointId: 'wp-az460',
|
|
userId: 'user-az460',
|
|
}
|
|
|
|
const seedAnn: AnnotationListItem = {
|
|
id: 'ann-az460',
|
|
mediaId: seedMediaItem.id,
|
|
time: null,
|
|
createdDate: '2026-05-11T00:00:00Z',
|
|
userId: seedMediaItem.userId,
|
|
source: AnnotationSource.Manual,
|
|
status: AnnotationStatus.Created,
|
|
isSplit: false,
|
|
splitTile: null,
|
|
detections: [seedDetection],
|
|
}
|
|
|
|
interface CapturedSave {
|
|
url: string
|
|
body: Record<string, unknown>
|
|
}
|
|
|
|
function captureSavePost(): { saves: CapturedSave[] } {
|
|
// Arrange — capture every POST to the doubly-prefixed annotation save endpoint.
|
|
const saves: CapturedSave[] = []
|
|
server.use(
|
|
http.post('/api/annotations/annotations', async ({ request }) => {
|
|
saves.push({
|
|
url: new URL(request.url).pathname,
|
|
body: (await request.json()) as Record<string, unknown>,
|
|
})
|
|
return jsonResponse(
|
|
{ id: 'ann-saved', createdDate: new Date().toISOString() },
|
|
{ status: 201 },
|
|
)
|
|
}),
|
|
// Background bootstrap — FlightContext + DetectionClasses + initial AnnotationsPage mount.
|
|
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
|
|
http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
|
|
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
|
|
// MediaList fetch + per-selection annotation list reload.
|
|
http.get('/api/annotations/media', ({ request }) => {
|
|
const url = new URL(request.url)
|
|
const page = Number(url.searchParams.get('page') ?? '1')
|
|
const pageSize = Number(url.searchParams.get('pageSize') ?? '1000')
|
|
return jsonResponse(paginate([seedMediaItem], page, pageSize))
|
|
}),
|
|
http.get('/api/annotations/annotations', ({ request }) => {
|
|
const url = new URL(request.url)
|
|
const mediaId = url.searchParams.get('mediaId')
|
|
const items = mediaId === seedMediaItem.id ? [seedAnn] : []
|
|
const page = Number(url.searchParams.get('page') ?? '1')
|
|
const pageSize = Number(url.searchParams.get('pageSize') ?? '1000')
|
|
return jsonResponse(paginate(items, page, pageSize))
|
|
}),
|
|
http.get('/api/annotations/classes', () => jsonResponse([])),
|
|
http.get('/api/annotations/dataset/info', () => jsonResponse({ totalCount: 1, statusCounts: {} })),
|
|
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
|
)
|
|
return { saves }
|
|
}
|
|
|
|
async function selectMediaAndAnnotation(): Promise<void> {
|
|
// Wait for media to load and click it. (`findByText` returns the inner
|
|
// <span>; click bubbles to the row's onClick handler.)
|
|
const mediaItem = await screen.findByText('az460.jpg')
|
|
await userEvent.click(mediaItem)
|
|
// After click, MediaList GETs `/api/annotations/annotations?mediaId=...` and
|
|
// calls `onAnnotationsLoaded`. AnnotationsSidebar then renders an annotation
|
|
// row showing `'—'` (no time) and the first detection's label (`class-0`).
|
|
// Clicking that label fires `handleAnnotationSelect`, which seeds detections
|
|
// and enables the Save button. Wait up to 3 s for the row to appear.
|
|
const detectionLabel = await screen.findByText('class-0', undefined, { timeout: 3000 })
|
|
await userEvent.click(detectionLabel)
|
|
}
|
|
|
|
describe('AZ-460 — annotation save URL + payload contract', () => {
|
|
beforeEach(() => {
|
|
seedBearer()
|
|
})
|
|
|
|
describe('AC-1 (FT-P-07) — URL canary', () => {
|
|
it('issues the save POST against the doubly-prefixed `/api/annotations/annotations` path', async () => {
|
|
// Arrange
|
|
const { saves } = captureSavePost()
|
|
renderWithProviders(
|
|
<FlightProvider>
|
|
<AnnotationsPage />
|
|
</FlightProvider>,
|
|
)
|
|
|
|
// Act
|
|
await selectMediaAndAnnotation()
|
|
// Wait for the side-effect: clicking annotation populates detections.
|
|
await waitFor(() => {
|
|
const saveBtn = screen.getByRole('button', { name: /^Save$/i })
|
|
expect(saveBtn).not.toBeDisabled()
|
|
}, { timeout: 3000 })
|
|
const saveBtn = screen.getByRole('button', { name: /^Save$/i })
|
|
await userEvent.click(saveBtn)
|
|
|
|
// Assert
|
|
await waitFor(() => expect(saves).toHaveLength(1), { timeout: 2000 })
|
|
expect(saves[0].url).toBe('/api/annotations/annotations')
|
|
// Negative canary — single-prefix would silently match if the URL
|
|
// regressed; the equality above is the gating assertion.
|
|
expect(saves[0].url).not.toBe('/api/annotations')
|
|
clearBearer()
|
|
})
|
|
})
|
|
|
|
describe('AC-2 (FT-P-08) — required-fields presence', () => {
|
|
it.fails(
|
|
'includes ALL of {Source, WaypointId, videoTime, mediaId, detections, status} in the save body',
|
|
async () => {
|
|
// Arrange — production today sends only {mediaId, time, detections}
|
|
// (see `src/features/annotations/AnnotationsPage.tsx:32-44`). The other
|
|
// four fields are missing. This drift is documented as `it.fails()`
|
|
// until Phase B lifts the body shape to match the wire contract.
|
|
const { saves } = captureSavePost()
|
|
renderWithProviders(
|
|
<FlightProvider>
|
|
<AnnotationsPage />
|
|
</FlightProvider>,
|
|
)
|
|
|
|
// Act
|
|
await selectMediaAndAnnotation()
|
|
await waitFor(() => {
|
|
const saveBtn = screen.getByRole('button', { name: /^Save$/i })
|
|
expect(saveBtn).not.toBeDisabled()
|
|
}, { timeout: 3000 })
|
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }))
|
|
|
|
// Assert — every required field present.
|
|
await waitFor(() => expect(saves).toHaveLength(1))
|
|
const body = saves[0].body
|
|
expect(body).toHaveProperty('mediaId', seedMediaItem.id)
|
|
expect(body).toHaveProperty('detections')
|
|
expect(Array.isArray(body.detections)).toBe(true)
|
|
// The four drift fields — the assertion `it.fails()` flips green when these land.
|
|
expect(body).toHaveProperty('Source')
|
|
expect(['AI', 'Manual']).toContain(body.Source)
|
|
expect(body).toHaveProperty('WaypointId')
|
|
expect(body).toHaveProperty('videoTime')
|
|
expect(body).toHaveProperty('status')
|
|
clearBearer()
|
|
},
|
|
)
|
|
|
|
it('asserts the partial body shape that production currently emits (control)', async () => {
|
|
// This control test pins the CURRENT (drift) shape so a regression that
|
|
// drops `mediaId` or `detections` is caught even before AC-2 flips green.
|
|
// Once production lands the full contract, this test stays green; the
|
|
// `it.fails()` above starts passing and the migration is observable.
|
|
const { saves } = captureSavePost()
|
|
renderWithProviders(
|
|
<FlightProvider>
|
|
<AnnotationsPage />
|
|
</FlightProvider>,
|
|
)
|
|
|
|
await selectMediaAndAnnotation()
|
|
await waitFor(() => {
|
|
const saveBtn = screen.getByRole('button', { name: /^Save$/i })
|
|
expect(saveBtn).not.toBeDisabled()
|
|
}, { timeout: 3000 })
|
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }))
|
|
|
|
await waitFor(() => expect(saves).toHaveLength(1))
|
|
const body = saves[0].body
|
|
expect(body.mediaId).toBe(seedMediaItem.id)
|
|
expect(Array.isArray(body.detections)).toBe(true)
|
|
expect((body.detections as unknown[]).length).toBeGreaterThan(0)
|
|
clearBearer()
|
|
})
|
|
})
|
|
|
|
describe('AC-3 — multiple save entry points', () => {
|
|
it('exercises the manual-draw / select-existing save entry point', async () => {
|
|
// The covered case from AC-1 / AC-2 above. Recorded here as a separate
|
|
// assertion so the AC-3 entry-point list is explicit in the report.
|
|
const { saves } = captureSavePost()
|
|
renderWithProviders(
|
|
<FlightProvider>
|
|
<AnnotationsPage />
|
|
</FlightProvider>,
|
|
)
|
|
await selectMediaAndAnnotation()
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /^Save$/i })).not.toBeDisabled()
|
|
}, { timeout: 3000 })
|
|
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }))
|
|
await waitFor(() => expect(saves.length).toBeGreaterThan(0))
|
|
clearBearer()
|
|
})
|
|
|
|
it.skip(
|
|
'QUARANTINE — AI-suggestion-accept save entry point not yet wired in production',
|
|
async () => {
|
|
// Production has no "accept AI suggestion" button that fires a save —
|
|
// AI suggestions arrive via the detect/* services and are merged into
|
|
// detections, but the user accepts via the same `Save` button as manual
|
|
// draw. The intended distinct UX where accepting an AI suggestion
|
|
// issues its own save (with `Source: 'AI'`) lands in Phase B.
|
|
// When the path lands, flip this test to a real assertion that issues
|
|
// the AI-flavored save and captures `Source === 'AI'`.
|
|
},
|
|
)
|
|
|
|
it.skip(
|
|
'QUARANTINE — bulk-edit save entry point not yet wired in production',
|
|
async () => {
|
|
// Production has no bulk-edit save path. Bulk operations exist via the
|
|
// dataset bulk-status endpoint (`/api/annotations/dataset/bulk-status`)
|
|
// but that does not issue an annotation save per item. When a true
|
|
// bulk-edit save lands, this test issues it and asserts each save
|
|
// body matches the AC-2 contract.
|
|
},
|
|
)
|
|
})
|
|
})
|