Files
ui/tests/destructive_ux.test.tsx
T
Oleksandr Bezdieniezhnykh 1dd25edee3 [AZ-460] [AZ-462] [AZ-466] [AZ-475] Batch 4 - destructive UX/forms/overlay/save
AZ-466 — Destructive UX policy + ConfirmDialog a11y + no-alert (4pts):
  src/components/ConfirmDialog.test.tsx (8 fast),
  tests/destructive_ux.test.tsx (4 fast, AdminPage class-delete drift),
  e2e/tests/destructive_ux.e2e.ts. New static checks STC-SEC7 (alert
  allowlist) + STC-SEC8 (destructive-surfaces gated/drift) wired through
  scripts/check-banned-deps.mjs reading tests/security/banned-deps.json.

AZ-475 — Numeric form input rejection (2pts):
  tests/form_hygiene.test.tsx (3 fast). Documents two SettingsPage drifts:
  silent zero coercion via parseInt(v)||0 and labels missing htmlFor.

AZ-462 — Overlay membership at in-window edges (2pts):
  tests/overlay_membership.test.tsx (6 fast). Documents getTimeWindowDetections
  strict < drift; AC-1 boundary tests are it.fails(); AC-2 / control PASS.
  Mocks HTMLCanvasElement.getContext to capture strokeRect.

AZ-460 — Annotation save URL + payload contract (2pts):
  tests/annotations_endpoint.test.tsx (6 fast),
  e2e/tests/annotations_endpoint.e2e.ts. AC-1 URL canary PASSes; AC-2
  payload missing 4 fields documented as it.fails(); AC-3 manual-draw
  PASS, AI-suggestion-accept + bulk-edit-save QUARANTINE skip.

Test infrastructure:
  - tests/setup.ts: NoopResizeObserver + NoopEventSource JSDOM polyfills.
  - tests/msw/handlers/annotations.ts: doubly-prefixed paths matching
    production calls (e.g. /api/annotations/annotations).
  - tests/msw/handlers/flights.ts: plural /aircrafts paths.

Verification: bun run test:fast → 80 passed, 13 skipped (14 files).
scripts/run-tests.sh --static-only → 24/24 PASS (was 22; +STC-SEC7/SEC8).
Per-batch self-review verdict: PASS_WITH_WARNINGS. Cumulative review
of batches 04-06 due after batch 6 per implement/SKILL.md Step 14.5.
Report: _docs/03_implementation/batch_04_report.md.

Also includes the previously-untracked
_docs/03_implementation/cumulative_review_batches_01-03_report.md
generated at the start of this session before batch 4 began.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 04:15:01 +03:00

167 lines
7.6 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, noContent } from './msw/helpers'
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import AdminPage from '../src/features/admin/AdminPage'
// AZ-466 — Destructive UX policy (cross-component half)
//
// AC-1 (FT-P-26): clicking Delete on a class → confirming → DELETE fires AFTER confirm.
// AC-2 (FT-N-07): clicking Delete → Cancel → NO DELETE fires.
// AC-4 (FT-P-27 / NFT-SEC-08): static check enumerates every destructive surface
// and asserts each one mounts a `<ConfirmDialog>`.
// The static side lives in `scripts/run-tests.sh` /
// `scripts/check-banned-deps.mjs` (`STC-SEC8`).
// The runtime mirror is one of the cases below.
// AC-5 (NFT-SEC-07): no `alert()` in `src/`. Static side enforces this; runtime
// side here only documents the current allowlist. The runtime
// test would require renderring every component that calls
// alert — out of black-box scope. Static check `STC-SEC7`
// handles enforcement.
//
// Production drift (`src/features/admin/AdminPage.tsx:30-33` and table row
// line 76):
// `handleDeleteClass` directly calls `api.delete` without gating through
// `<ConfirmDialog>`. The class-delete row's `<button onClick=...>` triggers
// the network mutation immediately. FT-P-26 + FT-N-07 are recorded as
// `it.fails()` until production wraps `handleDeleteClass` behind ConfirmDialog
// (Phase B feature task).
const SEED_CLASSES = [
{ id: 1, name: 'class-a', shortName: 'a', color: '#ff0000', maxSizeM: 7 },
{ id: 2, name: 'class-b', shortName: 'b', color: '#00ff00', maxSizeM: 5 },
]
interface CapturedDelete {
url: string
classId: string
}
function captureClassDelete(): { deletes: CapturedDelete[] } {
const deletes: CapturedDelete[] = []
server.use(
http.delete('/api/admin/classes/:id', ({ request, params }) => {
deletes.push({ url: new URL(request.url).pathname, classId: String(params.id) })
return noContent()
}),
// AdminPage bootstrap: classes (annotations service), aircrafts, users.
// NOTE: `AdminPage` reads `/api/admin/users` as a flat User[]
// (`api.get<User[]>` then `users.map`) — but the suite-default MSW
// wraps `seedUsers` in `paginate(...)`. That's a documented
// production-vs-suite drift (admin handler should expose flat in dev).
// For this destructive-UX test we only care about class-delete
// wiring, so the override returns a flat empty array to keep
// AdminPage from crashing on `users.map`.
http.get('/api/annotations/classes', () => jsonResponse(SEED_CLASSES)),
http.get('/api/flights/aircrafts', () => jsonResponse([])),
http.get('/api/admin/users', () => jsonResponse([])),
// AuthContext bootstraps with GET /api/admin/auth/refresh; tests using
// <ProtectedRoute>-less render still mount AuthProvider. Return 401 so
// the unauth path resolves quickly and bootstrap finishes.
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
)
return { deletes }
}
describe('AZ-466 — Destructive UX policy (class-delete cross-component test)', () => {
beforeEach(() => {
seedBearer()
})
describe('AC-1 (FT-P-26) — happy path: Delete → Confirm → DELETE fires', () => {
it.fails(
'class-delete prompts a `<ConfirmDialog>` BEFORE issuing the DELETE',
async () => {
// Arrange
const { deletes } = captureClassDelete()
renderWithProviders(<AdminPage />)
// Wait for the class table to populate.
await screen.findByText('class-a')
// Act — find the delete button on the first class row.
const rows = screen.getAllByText(/^class-/i)
const firstRow = rows[0].closest('tr')!
const deleteBtn = firstRow.querySelector('button')!
await userEvent.click(deleteBtn)
// Assert — a ConfirmDialog must appear before any DELETE fires.
// Drift: AdminPage's `handleDeleteClass` issues api.delete directly
// (no ConfirmDialog wired). The DELETE fires immediately and the
// dialog never appears.
const dialog = await screen.findByRole('dialog', undefined, { timeout: 1000 })
expect(dialog).toBeInTheDocument()
expect(deletes).toHaveLength(0)
// Confirm via the dialog → DELETE fires now.
const confirm = screen.getAllByRole('button').find(b => /confirm/i.test(b.textContent ?? ''))!
await userEvent.click(confirm)
await waitFor(() => expect(deletes).toHaveLength(1), { timeout: 1000 })
clearBearer()
},
)
it('control: production today bypasses ConfirmDialog and deletes immediately', async () => {
// Pin the current (drift) one-click delete behavior. When AC-1 lands,
// this control flips red and is removed.
const { deletes } = captureClassDelete()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
const rows = screen.getAllByText(/^class-/i)
const firstRow = rows[0].closest('tr')!
const deleteBtn = firstRow.querySelector('button')!
await userEvent.click(deleteBtn)
await waitFor(() => expect(deletes).toHaveLength(1), { timeout: 1000 })
expect(deletes[0].url).toMatch(/\/api\/admin\/classes\/\d+/)
clearBearer()
})
})
describe('AC-2 (FT-N-07) — cancel path: Delete → Cancel → NO DELETE fires', () => {
it.fails(
'class-delete with Cancel via the ConfirmDialog suppresses the DELETE entirely',
async () => {
// Arrange
const { deletes } = captureClassDelete()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act — click delete, then Cancel on the dialog.
const rows = screen.getAllByText(/^class-/i)
const firstRow = rows[0].closest('tr')!
await userEvent.click(firstRow.querySelector('button')!)
// Drift: the dialog never appears today. The find call fails first
// (no `role="dialog"` ever mounts), but even if it did, cancel would
// need to suppress a DELETE that today already fired synchronously.
const dialog = await screen.findByRole('dialog', undefined, { timeout: 1000 })
expect(dialog).toBeInTheDocument()
const cancel = screen.getAllByRole('button').find(b => /cancel/i.test(b.textContent ?? ''))!
await userEvent.click(cancel)
// Assert — NO DELETE was issued.
await new Promise(r => setTimeout(r, 50))
expect(deletes).toHaveLength(0)
clearBearer()
},
)
})
describe('AC-4 (FT-P-27 / NFT-SEC-08) — destructive surfaces enumeration', () => {
// The runtime side of FT-P-27 / NFT-SEC-08 is a multi-component static
// walk. Implementing it as a Vitest test would require rendering every
// production page and asserting every destructive surface mounts a
// ConfirmDialog. That is the static check's job (`STC-SEC8` in
// `scripts/run-tests.sh` calling `check-banned-deps.mjs --kind=destructive_unguarded`).
// We pin one runtime example here (AdminPage's class-delete) above to
// catch regressions on a known-current drift surface.
it.skip(
'QUARANTINE — full enumeration is enforced by STC-SEC8 (static check); per-surface runtime tests follow per-feature in Phase B',
() => {},
)
})
})