mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 16:41:11 +00:00
23746ec61d
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>
291 lines
13 KiB
TypeScript
291 lines
13 KiB
TypeScript
import { useEffect } from 'react'
|
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
import { http } from 'msw'
|
|
import { server } from './msw/server'
|
|
import { jsonResponse, paginate } from './msw/helpers'
|
|
import { renderWithProviders, screen, fireEvent, waitFor, userEvent } from './helpers/render'
|
|
import { seedBearer, clearBearer } from './helpers/auth'
|
|
import { FlightProvider, useFlight } from '../src/components'
|
|
import { AnnotationsPage } from '../src/features/annotations'
|
|
import { seedFlights } from './fixtures/seed_flights'
|
|
|
|
// AZ-476 — Upload >500 MB → 413 → user-visible error (no alert).
|
|
//
|
|
// AC-1 (FT-N-06 + NFT-RES-07): When nginx returns 413 on an oversized upload,
|
|
// the SPA shows an in-DOM error (toast or inline)
|
|
// carrying an i18n-keyed message.
|
|
// Production today (`MediaList.uploadFiles`)
|
|
// catches the upload failure silently and falls
|
|
// through to local mode — no error region, no
|
|
// i18n key. `it.fails()` + control.
|
|
// AC-2 (no alert): The 413 path does NOT invoke `alert()`.
|
|
// Today the type-rejection path DOES invoke
|
|
// alert() (line 111 of MediaList.tsx) — but ONLY
|
|
// for unsupported file types, not for size. For
|
|
// the 413 path this is PASS today (vacuous —
|
|
// the failure is silently swallowed). The test
|
|
// asserts the spy count for both alert and the
|
|
// MSW POST so the contract is pinned.
|
|
|
|
const FLIGHT = seedFlights[0]
|
|
|
|
interface UploadRig {
|
|
posts: { url: string; pathname: string; status: number }[]
|
|
}
|
|
|
|
function rigUploadEnv(opts: { uploadStatus: number }): UploadRig {
|
|
const posts: { url: string; pathname: string; status: number }[] = []
|
|
|
|
server.use(
|
|
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
|
http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
|
|
http.get('/api/flights/:id', ({ params }) => {
|
|
const f = seedFlights.find((x) => x.id === params.id)
|
|
return f ? jsonResponse(f) : new Response(null, { status: 404 })
|
|
}),
|
|
http.get('/api/annotations/settings/user', () =>
|
|
jsonResponse({
|
|
id: 'user-settings-az476',
|
|
userId: 'user-az476',
|
|
selectedFlightId: FLIGHT.id,
|
|
annotationsLeftPanelWidth: null,
|
|
annotationsRightPanelWidth: null,
|
|
datasetLeftPanelWidth: null,
|
|
datasetRightPanelWidth: null,
|
|
}),
|
|
),
|
|
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
|
|
http.get('/api/annotations/media', () => jsonResponse(paginate([], 1, 1000))),
|
|
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: {} }),
|
|
),
|
|
|
|
// Batch upload endpoint — return the configured status (413 for the
|
|
// contract test; 200 for the happy-path control).
|
|
http.post('/api/annotations/media/batch', ({ request }) => {
|
|
const url = new URL(request.url)
|
|
posts.push({
|
|
url: request.url,
|
|
pathname: url.pathname,
|
|
status: opts.uploadStatus,
|
|
})
|
|
if (opts.uploadStatus === 413) {
|
|
return new Response('Request entity too large', { status: 413 })
|
|
}
|
|
return jsonResponse([])
|
|
}),
|
|
)
|
|
|
|
return { posts }
|
|
}
|
|
|
|
// Tiny helper component: synchronously seeds the FlightContext's selectedFlight
|
|
// on mount. This sidesteps the async user-settings → flights/<id> rehydration
|
|
// chain so the test's `if (selectedFlight)` branch in MediaList.uploadFiles is
|
|
// guaranteed live by the time we trigger the upload. The chain is exercised
|
|
// elsewhere (AZ-463); duplicating it here only adds flake to the AZ-476 contract.
|
|
function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
const { selectFlight, selectedFlight } = useFlight()
|
|
useEffect(() => {
|
|
if (!selectedFlight) selectFlight(FLIGHT)
|
|
}, [selectFlight, selectedFlight])
|
|
return <>{children}</>
|
|
}
|
|
|
|
function buildOversizedFile(): File {
|
|
// Spec: e2e sends a sparse 501 MB file. The fast test does not need 501 MB
|
|
// of bytes — MSW's 413 response is the wire signal under test, not the
|
|
// request payload size. Use a 1 KB placeholder so the harness stays cheap;
|
|
// the size header is what the server (real nginx) gates on, and the SPA
|
|
// observes only the 413 response. Comment kept inline so a future reader
|
|
// does not "fix" this by allocating 501 MB and slowing CI.
|
|
const blob = new Blob([new Uint8Array(1024)], { type: 'video/mp4' })
|
|
return new File([blob], 'huge_recon_video.mp4', { type: 'video/mp4' })
|
|
}
|
|
|
|
async function dropFile(file: File): Promise<void> {
|
|
// MediaList renders three file inputs:
|
|
// inputs[0] — dropzone (react-dropzone, hidden, fed by drag/drop UX).
|
|
// inputs[1] — "Open File" label (direct onChange → uploadFiles).
|
|
// inputs[2] — folder picker (webkitdirectory, direct onChange).
|
|
// We exercise the "Open File" path because its onChange handler is a thin,
|
|
// synchronous wrapper that calls `uploadFiles(files)` directly. This avoids
|
|
// react-dropzone's internal DataTransfer machinery (which requires a real
|
|
// drop event in JSDOM) while still hitting the same `uploadFiles` code
|
|
// path under test for the 413 contract.
|
|
//
|
|
// The "Open File" input has `className="hidden"` (Tailwind `display: none`).
|
|
// userEvent.upload attempts to click the input first; on a hidden node this
|
|
// fails silently in some JSDOM versions. We bypass the click by setting
|
|
// the file list directly and dispatching `change` — equivalent to React's
|
|
// onChange wiring path.
|
|
const inputs = document.querySelectorAll<HTMLInputElement>('input[type="file"]')
|
|
const target = inputs[1]
|
|
Object.defineProperty(target, 'files', {
|
|
value: [file],
|
|
configurable: true,
|
|
})
|
|
fireEvent.change(target)
|
|
// Yield once so React processes the change → uploadFiles() → api.upload().
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
}
|
|
|
|
describe('AZ-476 — upload 501 MB → 413 → user-visible error (no alert)', () => {
|
|
let createObjectURLSpy: ReturnType<typeof vi.fn> | null = null
|
|
let revokeObjectURLSpy: ReturnType<typeof vi.fn> | null = null
|
|
|
|
let originalCreateObjectURL: typeof URL.createObjectURL | undefined
|
|
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined
|
|
|
|
beforeEach(() => {
|
|
seedBearer()
|
|
// JSDOM lacks URL.createObjectURL / revokeObjectURL; production's
|
|
// local-mode fallback in MediaList.tsx calls them on the dropped File.
|
|
//
|
|
// CRITICAL: patch the methods on the URL constructor directly. Do NOT use
|
|
// `vi.stubGlobal('URL', { ...URL, createObjectURL })` — that replaces the
|
|
// global URL with a plain object, which silently breaks every `new URL(...)`
|
|
// call downstream (the SPA's API helper, MSW's request matching, etc.) and
|
|
// the resulting fetches never reach the test's MSW handler.
|
|
originalCreateObjectURL = (URL as unknown as { createObjectURL?: typeof URL.createObjectURL })
|
|
.createObjectURL
|
|
originalRevokeObjectURL = (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL })
|
|
.revokeObjectURL
|
|
createObjectURLSpy = vi.fn(
|
|
(file: Blob | File) => `blob:az476-${(file as File).name ?? 'unknown'}`,
|
|
)
|
|
revokeObjectURLSpy = vi.fn()
|
|
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
|
|
createObjectURLSpy as unknown as typeof URL.createObjectURL
|
|
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
|
|
revokeObjectURLSpy as unknown as typeof URL.revokeObjectURL
|
|
})
|
|
|
|
afterEach(() => {
|
|
clearBearer()
|
|
if (originalCreateObjectURL === undefined) {
|
|
delete (URL as unknown as { createObjectURL?: typeof URL.createObjectURL }).createObjectURL
|
|
} else {
|
|
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
|
|
originalCreateObjectURL
|
|
}
|
|
if (originalRevokeObjectURL === undefined) {
|
|
delete (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL }).revokeObjectURL
|
|
} else {
|
|
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
|
|
originalRevokeObjectURL
|
|
}
|
|
vi.unstubAllGlobals()
|
|
vi.restoreAllMocks()
|
|
createObjectURLSpy = null
|
|
revokeObjectURLSpy = null
|
|
})
|
|
|
|
describe('AC-1 (FT-N-06 + NFT-RES-07) — user-visible 413 error', () => {
|
|
it.fails(
|
|
'a 413 from /api/annotations/media/batch surfaces an in-DOM error region with an i18n-keyed message',
|
|
async () => {
|
|
// Drift: production catches the upload failure silently in
|
|
// `MediaList.uploadFiles`'s try/catch and falls through to local mode
|
|
// (creating blob URLs). No error region is rendered, no i18n key
|
|
// exists for the 413 case. The assertion below requires an element
|
|
// with role="alert" carrying an upload-size message; both are absent
|
|
// today, so the test fails until production wires the toast and the
|
|
// i18n string.
|
|
const { posts } = rigUploadEnv({ uploadStatus: 413 })
|
|
renderWithProviders(
|
|
<FlightProvider>
|
|
<FlightSeed>
|
|
<AnnotationsPage />
|
|
</FlightSeed>
|
|
</FlightProvider>,
|
|
)
|
|
await waitFor(() => {
|
|
expect(document.querySelector('input[type="file"]')).not.toBeNull()
|
|
})
|
|
|
|
// Act
|
|
const file = buildOversizedFile()
|
|
await dropFile(file)
|
|
|
|
// The POST fires, MSW returns 413.
|
|
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
|
|
expect(posts[0].pathname).toBe('/api/annotations/media/batch')
|
|
expect(posts[0].status).toBe(413)
|
|
|
|
// Assert — an error region appears in the DOM. The contract is:
|
|
// role="alert" + a message carrying an upload-size phrase. The exact
|
|
// i18n key (e.g., "annotations.uploadTooLarge") is set by the
|
|
// remediation task; the test matches on the rendered text shape.
|
|
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
|
expect(alertEl.textContent ?? '').toMatch(/too large|exceeds|413/i)
|
|
},
|
|
)
|
|
|
|
it('control: production silently falls through to local mode on 413 (current drift)', async () => {
|
|
// Pin the current behavior so a regression that, e.g., starts throwing
|
|
// an unhandled exception (which would leak to the React error boundary
|
|
// and crash the page) is visible immediately. Today: POST fires, 413
|
|
// returns, code falls through, blob URL is created locally, the file
|
|
// appears in the media list under its original name.
|
|
const { posts } = rigUploadEnv({ uploadStatus: 413 })
|
|
renderWithProviders(
|
|
<FlightProvider>
|
|
<FlightSeed>
|
|
<AnnotationsPage />
|
|
</FlightSeed>
|
|
</FlightProvider>,
|
|
)
|
|
await waitFor(() => {
|
|
expect(document.querySelector('input[type="file"]')).not.toBeNull()
|
|
})
|
|
|
|
const file = buildOversizedFile()
|
|
await dropFile(file)
|
|
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
|
|
|
|
// The local-mode side effect: the file appears in the rendered media
|
|
// list with its original name. This pins the silent-fall-through drift.
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/huge_recon_video\.mp4/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('AC-2 — no `alert()` on the 413 path', () => {
|
|
it('the 413 path does NOT invoke window.alert() today (vacuous PASS — failure is silently swallowed)', async () => {
|
|
// The current drift makes this test pass for the wrong reason: there's
|
|
// no alert because there's no error handling at all. When AC-1 lands
|
|
// and an in-DOM error region is wired, this contract still must hold —
|
|
// i.e., even with proper error surfacing, alert() stays out of the
|
|
// path. The static check (STC-SEC7) provides the source-level gate;
|
|
// this runtime test is the defence-in-depth assertion required by the
|
|
// task spec.
|
|
const alertSpy = vi.fn()
|
|
vi.stubGlobal('alert', alertSpy)
|
|
|
|
const { posts } = rigUploadEnv({ uploadStatus: 413 })
|
|
renderWithProviders(
|
|
<FlightProvider>
|
|
<FlightSeed>
|
|
<AnnotationsPage />
|
|
</FlightSeed>
|
|
</FlightProvider>,
|
|
)
|
|
await waitFor(() => {
|
|
expect(document.querySelector('input[type="file"]')).not.toBeNull()
|
|
})
|
|
|
|
const file = buildOversizedFile()
|
|
await dropFile(file)
|
|
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
|
|
// Give the error path time to either render an alert (drift) or not.
|
|
await new Promise((r) => setTimeout(r, 100))
|
|
|
|
expect(alertSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|