Files
ui/tests/annotations_endpoint.test.tsx
T
Oleksandr Bezdieniezhnykh 70fb452805 [AZ-510] Auth bootstrap: POST refresh + chained /users/me
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>
2026-05-13 02:59:31 +03:00

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.
},
)
})
})