Files
ui/tests/destructive_ux.test.tsx
Oleksandr Bezdieniezhnykh ecacfa8b43 [AZ-512] Admin class inline edit form + PATCH wiring (cy4 batch 16)
Implements the cycle-3-deferred AZ-512 task under the user-authorized
Option B path (MSW-stubbed; live deploy gates at Step 16 on AZ-513).

Code:
- src/features/admin/AdminPage.tsx — inline edit affordance:
  editingId/editForm state, handleStartEdit/Cancel/Update, Enter+Escape
  keyboard handling, colspan row swap when editing, pencil (✎) button
  per row. Full-body PATCH (Risk 2). Single editingId enforces the
  one-row-at-a-time invariant (Risk 3). Disabled buttons during the
  in-flight PATCH (Risk 4). Inline role="alert" on validation/server
  errors (no alert() per Finding B4 anti-pattern).
- src/i18n/{en,ua}.json — `admin.classes` flat → nested with `title`
  + 6 new keys (edit, save, cancel, nameRequired,
  maxSizeMustBePositive, updateFailed). Parity gate FT-P-22 PASS.

Test infrastructure:
- tests/msw/handlers/admin.ts — PATCH /api/admin/classes/:id
  partial-merge handler.
- tests/admin_class_edit.test.tsx — 12 tests covering AC-1..AC-6
  + AC-8 (AC-7 satisfied by static FT-P-22 gate).
- tests/destructive_ux.test.tsx — adjacent-hygiene selector fix
  at 3 call sites: the new ✎ button moved the first-button
  position; targeting × explicitly preserves the existing
  it.fails()/control semantics.

Docs:
- _docs/02_document/components/08_admin/description.md — recorded
  edit affordance + PATCH wiring + AZ-513 cross-workspace note.
- _docs/03_implementation/batch_16_cycle4_report.md
- _docs/03_implementation/implementation_report_admin_class_edit_cycle4.md
- _docs/02_tasks/todo → done — AZ-512 archived.

Quality gates: 32 files / 243 tests / 13 quarantined skips PASS;
all 35 static checks PASS (FT-P-22/23, STC-ARCH-01/02, STC-SEC*,
banned-deps incl. SEC1B/C/D).

Cross-workspace dependency: admin/ AZ-513 (POST + PATCH + DELETE
/classes routes) NOT yet shipped. Step 11 (Run Tests) passes on
stubs; Step 16 (Deploy) holds until AZ-513 lands live. Leftover
record at _docs/_process_leftovers/2026-05-13_az-512-admin-
classes-prereq.md stays open.

Discovered pre-existing bug (NOT bundled): tests/msw/handlers/
admin.ts returns paginate(seedUsers) for GET /api/admin/users,
but AdminPage consumes as flat User[] → users.map crash. Test
files use the same flat-array workaround
destructive_ux.test.tsx documented. Flagged in batch + impl
reports for separate triage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 04:35:13 +03:00

170 lines
7.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.post('/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. AZ-512 added
// an edit (✎) button alongside the delete (×); select by text.
const rows = screen.getAllByText(/^class-/i)
const firstRow = rows[0].closest('tr')!
const deleteBtn = Array.from(firstRow.querySelectorAll('button')).find(b => b.textContent === '×')!
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 = Array.from(firstRow.querySelectorAll('button')).find(b => b.textContent === '×')!
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. AZ-512 added an
// edit (✎) button alongside the delete (×); select by text.
const rows = screen.getAllByText(/^class-/i)
const firstRow = rows[0].closest('tr')!
const deleteBtn = Array.from(firstRow.querySelectorAll('button')).find(b => b.textContent === '×')!
await userEvent.click(deleteBtn)
// 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',
() => {},
)
})
})