Files
ui/tests/bulk_validate.test.tsx
T
Oleksandr Bezdieniezhnykh 23746ec61d [AZ-485] Add Public API barrels + STC-ARCH-01 (F4 close)
Closes architecture baseline finding F4. Every component now exposes
its Public API through `src/<component>/index.ts`; cross-component
imports go through the barrel. `scripts/check-arch-imports.mjs` plus
`STC-ARCH-01` in the static profile enforce the rule; tests in
`tests/architecture_imports.test.ts` cover AC-4/AC-5 + 2 exemption
cases. One F3-pending exemption (`classColors`) is documented in 5
places (barrel, consumer, script, doc, test) to avoid a circular
import.

Phase B cycle 1 batch 1 of 2 (epic AZ-447). Batch 2 is AZ-486
(endpoint builders) — blocked on this commit landing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 10:33:30 +03:00

261 lines
10 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, 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<string, unknown>
}
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<string, unknown>
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<void> {
// 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 `<div>` 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(
<FlightProvider>
<DatasetPage />
</FlightProvider>,
)
// 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: <N>, targetStatus: 30}` per contract',
async () => {
// Production today sends `{annotationIds: <N>, 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(
<FlightProvider>
<DatasetPage />
</FlightProvider>,
)
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(
<FlightProvider>
<DatasetPage />
</FlightProvider>,
)
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(
<FlightProvider>
<DatasetPage />
</FlightProvider>,
)
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 `<span>` 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()
})
})
})