mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 14:31:10 +00:00
70fb452805
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>
261 lines
10 KiB
TypeScript
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.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/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()
|
|
})
|
|
})
|
|
})
|