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, fireEvent, waitFor } from './helpers/render' import { seedBearer, clearBearer } from './helpers/auth' import { FlightProvider } from '../src/components' import { DatasetPage } from '../src/features/dataset' import { AnnotationStatus, AnnotationSource } from '../src/types' import type { DatasetItem } from '../src/types' // AZ-464 — Bulk-validate URL + body + UI sync within 2 s. // // AC-1 (FT-P-20 URL): outbound POST URL is `/api/annotations/dataset/bulk-status`. // AC-2 (FT-P-20 body): outbound body carries the media-id set + the target // status. Spec contract is `{ids, targetStatus: 30}` // (post-AC-04 enum scheme); production today emits // `{annotationIds, status: 2}`. Two `it.fails()` tests // pin the documented drifts (field names + status value) // and a control pins the current behavior. // AC-3 (FT-P-21 + NFT-PERF-07): after a 200 from the POST, every selected // row's DOM badge reads `Validated` within 2 s. The // production handler awaits the POST response then calls // fetchItems() — the second GET returns updated items. const seedItems: DatasetItem[] = [ { annotationId: 'ann-az464-1', imageName: 'az464-1.jpg', thumbnailPath: '/thumbs/az464-1.jpg', status: AnnotationStatus.Created, createdDate: '2026-05-11T10:00:00Z', createdEmail: 'op_alice@test.local', flightName: 'Flight A', source: AnnotationSource.Manual, isSeed: false, isSplit: false, }, { annotationId: 'ann-az464-2', imageName: 'az464-2.jpg', thumbnailPath: '/thumbs/az464-2.jpg', status: AnnotationStatus.Created, createdDate: '2026-05-11T10:01:00Z', createdEmail: 'op_alice@test.local', flightName: 'Flight A', source: AnnotationSource.Manual, isSeed: false, isSplit: false, }, { annotationId: 'ann-az464-3', imageName: 'az464-3.jpg', thumbnailPath: '/thumbs/az464-3.jpg', status: AnnotationStatus.Created, createdDate: '2026-05-11T10:02:00Z', createdEmail: 'op_alice@test.local', flightName: 'Flight A', source: AnnotationSource.Manual, isSeed: false, isSplit: false, }, ] interface CapturedBulk { url: string pathname: string body: Record } interface SyncRig { posts: CapturedBulk[] validatedAfterPost: { current: boolean } } function rigDatasetAndBulk(): SyncRig { const posts: CapturedBulk[] = [] const validatedAfterPost = { current: false } server.use( http.get('/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/classes', () => jsonResponse([])), // Dataset list — returns the seeded items, paginated. After the bulk POST // fires, this handler flips its `status` field to Validated for the // entire seed so the second GET delivers the updated payload. http.get('/api/annotations/dataset', () => { const items = seedItems.map((it) => validatedAfterPost.current ? { ...it, status: AnnotationStatus.Validated } : { ...it }, ) return jsonResponse(paginate(items, 1, items.length)) }), http.post('/api/annotations/dataset/bulk-status', async ({ request }) => { const body = (await request.json()) as Record const url = new URL(request.url) posts.push({ url: request.url, pathname: url.pathname, body, }) // Flip the GET handler so the next fetchItems() returns updated rows. validatedAfterPost.current = true return jsonResponse({ updated: 3, status: 30 }) }), ) return { posts, validatedAfterPost } } async function selectItemsWithCtrlClick(annotationIds: string[]): Promise { // The DatasetPage doesn't expose row test-ids; row identity lives in // imageName + annotationId. Locate each row by its image name. for (const id of annotationIds) { const item = seedItems.find((s) => s.annotationId === id)! const cell = await screen.findByText(item.imageName) // Walk to the parent row that owns the onClick handler. The row is the // outer `
` rendered for each item; its className contains // `cursor-pointer`. Use `closest(...)` against a stable structural // selector to be resilient to copy edits. const row = cell.closest('div.cursor-pointer') expect(row).toBeTruthy() fireEvent.click(row!, { ctrlKey: true }) } } describe('AZ-464 — bulk-validate URL + body + UI sync', () => { beforeEach(() => { seedBearer() }) describe('AC-1 (FT-P-20) — URL canary', () => { it('clicking Validate fires exactly one POST against `/api/annotations/dataset/bulk-status`', async () => { // Arrange const { posts } = rigDatasetAndBulk() renderWithProviders( , ) // Wait for items to render. await screen.findByText(seedItems[0].imageName) // Act — Ctrl+click the 3 seed items, then click Validate. await selectItemsWithCtrlClick(seedItems.map((it) => it.annotationId)) const validateBtn = await screen.findByRole('button', { name: /Validate \(\d+\)/i }) fireEvent.click(validateBtn) // Assert — exactly one POST observed; URL matches contract. await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 }) expect(posts[0].pathname).toBe('/api/annotations/dataset/bulk-status') clearBearer() }) }) describe('AC-2 (FT-P-20) — body shape', () => { it.fails( 'body carries `{ids: , targetStatus: 30}` per contract', async () => { // Production today sends `{annotationIds: , status: 2}` — both // field names AND the status value differ from the contract. The // assertion below fails on either drift; flips green when production // aligns with the AC-04 wire enum scheme. const { posts } = rigDatasetAndBulk() renderWithProviders( , ) await screen.findByText(seedItems[0].imageName) await selectItemsWithCtrlClick(seedItems.map((it) => it.annotationId)) const validateBtn = await screen.findByRole('button', { name: /Validate \(\d+\)/i }) fireEvent.click(validateBtn) await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 }) const body = posts[0].body expect(body).toHaveProperty('ids') expect(Array.isArray(body.ids)).toBe(true) expect((body.ids as unknown[])).toHaveLength(seedItems.length) expect(body).toHaveProperty('targetStatus', 30) clearBearer() }, ) it('control: production sends `{annotationIds, status: AnnotationStatus.Validated}` (current drift shape)', async () => { // Pin the CURRENT shape so a regression that drops `annotationIds` or // changes `status` to a non-enum value is caught even before AC-2 flips // green. When AC-04 lands the wire enum scheme, this control needs to // be adjusted alongside production. const { posts } = rigDatasetAndBulk() renderWithProviders( , ) await screen.findByText(seedItems[0].imageName) await selectItemsWithCtrlClick(seedItems.map((it) => it.annotationId)) const validateBtn = await screen.findByRole('button', { name: /Validate \(\d+\)/i }) fireEvent.click(validateBtn) await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 }) const body = posts[0].body expect(body).toHaveProperty('annotationIds') expect(Array.isArray(body.annotationIds)).toBe(true) expect((body.annotationIds as unknown[])).toHaveLength(seedItems.length) expect(body).toHaveProperty('status', AnnotationStatus.Validated) clearBearer() }) }) describe('AC-3 (FT-P-21 + NFT-PERF-07) — UI sync within 2 s', () => { it('every selected row badge reads `Validated` ≤ 2 000 ms after the POST resolves', async () => { // Arrange const { posts } = rigDatasetAndBulk() renderWithProviders( , ) await screen.findByText(seedItems[0].imageName) await selectItemsWithCtrlClick(seedItems.map((it) => it.annotationId)) const validateBtn = await screen.findByRole('button', { name: /Validate \(\d+\)/i }) // Act — record wall-clock at click time so the perf budget is observed. const t0 = Date.now() fireEvent.click(validateBtn) // Assert — POST observed, then all rows show the Validated badge. await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 }) // The Validated badge text comes from i18n key `dataset.status.validated`, // resolving to 'Validated' in the en bundle. Every seedItem row has // exactly one badge `` inside the row card. await waitFor( () => { const validatedBadges = screen.getAllByText('Validated') // The status-filter button bar also contains a 'Validated' button — // filter to the badge spans (size class `px-1 rounded` is unique to // the badge in DatasetPage's row template). const rowBadges = validatedBadges.filter((el) => (el.className ?? '').includes('px-1 rounded'), ) expect(rowBadges).toHaveLength(seedItems.length) }, { timeout: 2000 }, ) const elapsed = Date.now() - t0 expect(elapsed).toBeLessThanOrEqual(2000) clearBearer() }) }) })