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 (``'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 } 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, }) 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.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })), ) return { saves } } async function selectMediaAndAnnotation(): Promise { // Wait for media to load and click it. (`findByText` returns the inner // ; 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( , ) // 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( , ) // 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( , ) 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( , ) 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. }, ) }) })