Files
ui/tests/destructive_ux.test.tsx
T
Oleksandr Bezdieniezhnykh 23746ec61d [AZ-485] Add Public API barrels + STC-ARCH-01 (F4 close)
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>
2026-05-11 10:33:30 +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'
// 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',
() => {},
)
})
})