import { describe, it, expect, beforeEach } from 'vitest' import { http } from 'msw' import { server } from './msw/server' import { jsonResponse, paginate, sse } 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 { MediaType, MediaStatus } from '../src/types' import type { Media } from '../src/types' // AZ-461 — Detection endpoints (sync image / async video / long-video header). // // AC-1 (FT-P-11): clicking Detect on an image issues exactly one POST whose // URL matches `^/api/detect/[0-9]+$` (the wire contract). The // production handler in already POSTs // `/api/detect/${media.id}` against the active media — passes // today when the media id is numeric. // AC-2 (FT-P-12): async-video detect endpoint + SSE — TARGET (Phase B). The // async path does not exist in production today (single // Detect button POSTs the same endpoint regardless of media // type; no `/api/detect/video/` route, no `jobId`, no // EventSource on `/api/detect/stream/`). Recorded as // `it.fails()` so the test runs in CI (the spec requires // "test code itself runs (does not just xit)") and emits a // console log "FT-P-12 awaits AC-25 / async video detect impl" // per AC-2 contract. Flips green when AC-25 lands. // AC-3 (FT-P-13): long-video detect carries an `X-Refresh-Token` header — no // such header is added in production (`api.post` only sets // Authorization + Content-Type). `it.fails()` until the // header is wired in Phase B per task spec note. // Production detect URL is `/api/detect/`. The contract regex // `^/api/detect/[0-9]+$` requires a numeric id segment; the seed media for // this test uses a numeric-style string id ('42') so the regex matches the // observed URL today. (Other tests use 'media-1' style ids for unrelated // reasons.) const NUMERIC_MEDIA_ID = '42' const NUMERIC_VIDEO_MEDIA_ID = '57' const seedImageMedia: Media = { id: NUMERIC_MEDIA_ID, name: 'detect-image.jpg', path: '/media/detect-image.jpg', mediaType: MediaType.Image, mediaStatus: MediaStatus.New, duration: null, annotationCount: 0, waypointId: null, userId: 'user-az461', } const seedVideoMedia: Media = { id: NUMERIC_VIDEO_MEDIA_ID, name: 'detect-video.mp4', path: '/media/detect-video.mp4', mediaType: MediaType.Video, mediaStatus: MediaStatus.New, duration: '00:01:30', annotationCount: 0, waypointId: null, userId: 'user-az461', } interface CapturedRequest { url: string method: string pathname: string headers: Record } interface CapturedSSE { url: string } function captureDetectAndBootstrap(opts?: { mediaItems?: Media[] detectStatus?: number detectResponse?: Record registerVideoEndpoints?: boolean }): { detectCalls: CapturedRequest[]; sseOpens: CapturedSSE[] } { const detectCalls: CapturedRequest[] = [] const sseOpens: CapturedSSE[] = [] const items = opts?.mediaItems ?? [seedImageMedia] const detectStatus = opts?.detectStatus ?? 200 const detectResponse = opts?.detectResponse ?? { detections: [] } const handlers = [ // Wide-net detect catcher — production POSTs `/api/detect/` for any // media id today. The handler captures URL + headers so AC-1 + AC-3 can // assert against the same request log. http.post('/api/detect/:rest*', async ({ request, params }) => { const url = new URL(request.url) const headers: Record = {} request.headers.forEach((v, k) => { headers[k] = v }) detectCalls.push({ url: request.url, method: request.method, pathname: url.pathname, headers, }) // Synthesize an async-video shape if the URL matches the future Phase B // contract `^/api/detect/video/[0-9]+$`. Today no such request fires; // when AC-25 lands and production routes here, this responder makes the // jobId assertion in AC-2 stop being a "wholly absent" failure. if (typeof params.rest === 'string' && params.rest.startsWith('video/')) { return jsonResponse({ jobId: 12345 }) } if (detectStatus >= 400) { return new Response(JSON.stringify({ error: 'simulated' }), { status: detectStatus }) } return jsonResponse(detectResponse) }), // Phase B — async video detect SSE. Today no production code opens this // EventSource; the handler exists only so AC-2's `it.fails()` body can // run end-to-end without MSW unhandled-request errors when the path // eventually lands. ...(opts?.registerVideoEndpoints ? [ http.get('/api/detect/stream/:jobId', ({ request }) => { sseOpens.push({ url: new URL(request.url).pathname }) return sse([ { event: 'progress', data: { pct: 50 }, id: '1' }, { event: 'done', data: { detections: [] }, id: '2' }, ]) }), ] : []), // Bootstrap — minimal handlers so mounts cleanly and // shows the seeded media item. http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })), 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 })), 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(items, page, pageSize)) }), http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))), http.get('/api/annotations/classes', () => jsonResponse([])), http.get('/api/annotations/dataset/info', () => jsonResponse({ totalCount: 0, statusCounts: {} })), ] server.use(...handlers) return { detectCalls, sseOpens } } // The Detect button label comes from i18n key `annotations.detect`, which // resolves to `'AI Detect'` in the en bundle (see `src/i18n/en.json`). Match // the localized string rather than the i18n key so the test stays robust // against future copy tweaks while still asserting on the rendered DOM. const DETECT_BUTTON_NAME = /AI Detect/i async function selectMediaAndClickDetect(mediaName: string): Promise { const mediaItem = await screen.findByText(mediaName) await userEvent.click(mediaItem) // The Detect button lives in 's header. It is rendered // unconditionally but is `disabled` until selectedMedia is non-null — // userEvent.click on a disabled element is a no-op, so wait for it to // enable first. await waitFor(() => { const btn = screen.getByRole('button', { name: DETECT_BUTTON_NAME }) expect(btn).not.toBeDisabled() }) await userEvent.click(screen.getByRole('button', { name: DETECT_BUTTON_NAME })) } describe('AZ-461 — detection endpoints (sync / async / long-video header)', () => { beforeEach(() => { seedBearer() }) describe('AC-1 (FT-P-11) — sync image detect URL canary', () => { it('clicks Detect on an image and observes exactly one POST whose URL matches /api/detect/', async () => { // Arrange const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedImageMedia] }) renderWithProviders( , ) // Act await selectMediaAndClickDetect(seedImageMedia.name) // Assert — exactly one POST fired against the contract URL. await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 }) expect(detectCalls[0].method).toBe('POST') // FT-P-11 contract regex: `^/api/detect/[0-9]+$`. Numeric media id makes // production's `/api/detect/${media.id}` satisfy this regex today. expect(detectCalls[0].pathname).toMatch(/^\/api\/detect\/[0-9]+$/) expect(detectCalls[0].pathname).toBe(`/api/detect/${NUMERIC_MEDIA_ID}`) clearBearer() }) }) describe('AC-2 (FT-P-12) — async video detect endpoint + SSE (Phase B target — QUARANTINE)', () => { it.fails( 'POSTs `/api/detect/video/`, response carries jobId, EventSource opens on `/api/detect/stream/`', async () => { // Per task-spec AC-2: "FT-P-12 is implemented and registered, but // marked Result: QUARANTINE in the CSV report until AC-25 (Phase B) // lands. The test code itself runs (does not just `xit`) and produces // a clear log entry." Today's production code POSTs // `/api/detect/${media.id}` regardless of mediaType (single endpoint // shape), so the assertion below fails. When AC-25 introduces a // separate `/api/detect/video/` POST + SSE pair, this test flips // to PASS automatically. // // eslint-disable-next-line no-console console.log('FT-P-12 awaits AC-25 / async video detect impl') const { detectCalls, sseOpens } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia], registerVideoEndpoints: true, detectResponse: { jobId: 12345 }, }) renderWithProviders( , ) // Act await selectMediaAndClickDetect(seedVideoMedia.name) // Assert — the video-routed POST shape (Phase B) and the SSE handshake. await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 }) expect(detectCalls[0].pathname).toMatch(/^\/api\/detect\/video\/[0-9]+$/) // The SSE branch — production today does not call EventSource at all // for detect, so the polling assertion here also fails until AC-25. await waitFor(() => expect(sseOpens.length).toBeGreaterThan(0), { timeout: 2000 }) expect(sseOpens[0].url).toMatch(/^\/api\/detect\/stream\/[0-9]+$/) clearBearer() }, ) it('control: production posts to /api/detect/ regardless of mediaType (single-endpoint drift)', async () => { // Pin the CURRENT (drift) behavior so a regression that, e.g., stops // sending the request at all is caught even before AC-25 lifts the // QUARANTINE. When AC-25 introduces a separate video endpoint, this // control test will need to be adjusted (the pinned URL will change). const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] }) renderWithProviders( , ) await selectMediaAndClickDetect(seedVideoMedia.name) await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 }) // Today: single endpoint, same shape for image and video. expect(detectCalls[0].pathname).toBe(`/api/detect/${NUMERIC_VIDEO_MEDIA_ID}`) clearBearer() }) }) describe('AC-3 (FT-P-13) — long-video detect carries `X-Refresh-Token` header', () => { it.fails( 'every long-video detect request carries an `X-Refresh-Token` header (drift — production sets only Authorization)', async () => { // Production's `api.post` chain (`src/api/client.ts` request fn) sets // only `Authorization: Bearer ` and `Content-Type` for JSON // bodies. `X-Refresh-Token` is NOT added today. This is the documented // Step-4-style drift the task spec calls out ("until F7 lands and // the header is added per Step 4"). const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] }) renderWithProviders( , ) await selectMediaAndClickDetect(seedVideoMedia.name) await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 }) // Headers are normalised lower-case via the Headers iterator above. const xRefresh = detectCalls[0].headers['x-refresh-token'] expect(xRefresh).toBeDefined() expect(xRefresh).not.toBe('') clearBearer() }, ) it('control: production sets only Authorization header on detect (current behavior)', async () => { // This control proves the static check + the spy machinery work today // and would catch a regression that drops Authorization entirely. When // AC-3 flips green via Phase B, this control becomes redundant; the // `it.fails()` above flips and this test still passes (since // Authorization is also expected to remain). const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] }) renderWithProviders( , ) await selectMediaAndClickDetect(seedVideoMedia.name) await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 }) const auth = detectCalls[0].headers['authorization'] expect(auth).toBeDefined() expect(auth).toMatch(/^Bearer /) clearBearer() }) }) })