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.post('/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/ 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 { // 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('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 | null = null let revokeObjectURLSpy: ReturnType | 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( , ) 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( , ) 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( , ) 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() }) }) })