Files
ui/tests/admin_class_edit.test.tsx
T
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

371 lines
15 KiB
TypeScript
Raw 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, afterEach } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, errorResponse } from './msw/helpers'
import { renderWithProviders, screen, fireEvent, waitFor, userEvent, within } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { AdminPage } from '../src/features/admin'
import type { DetectionClass } from '../src/types'
// AZ-512 — Admin: edit existing detection class (inline form + PATCH wiring).
//
// AC-1 — edit affordance visible on every class row
// AC-2 — clicking edit opens inline form, seeded values, single-row at a time
// AC-3 — Save → exactly one PATCH /api/admin/classes/{id} with full body;
// row re-renders with new values; form closes
// AC-3 — Enter inside form behaves like Save
// AC-4 — Cancel button → no network call; row reverts
// AC-4 — Escape inside form behaves like Cancel
// AC-5 — empty name OR non-positive maxSizeM → no PATCH; inline error visible
// AC-6 — PATCH 500 → form stays open; inline error visible; no alert()
// AC-7 — covered by the static FT-P-22 parity gate (scripts/check-i18n-coverage.mjs)
// which runs in CI; AdminPage uses `t('admin.classes.<key>')` for every
// user-visible new string. No runtime test added here.
// AC-8 — regression guards for add + delete behaviour (no dedicated AdminPage
// test suite predates this file; cover the smallest happy path).
//
// Cross-workspace note: as of AZ-512 ship, the admin/ sibling service does NOT
// expose PATCH /api/admin/classes/{id} (verified 2026-05-13). Tests pass on
// MSW stubs; Step 11 (Run Tests) is therefore passable on stubs; Step 16
// (Deploy) gates on AZ-513 landing on admin/.
const TWO_CLASSES: DetectionClass[] = [
{ id: 1, name: 'class-a', shortName: 'a', color: '#ff0000', maxSizeM: 7, photoMode: 0 },
{ id: 2, name: 'class-b', shortName: 'b', color: '#00ff00', maxSizeM: 5, photoMode: 0 },
]
function setClassesHandler(classes: DetectionClass[]) {
server.use(http.get('/api/annotations/classes', () => jsonResponse(classes)))
}
// Pre-existing bug: the default `/api/admin/users` handler returns
// `paginate(seedUsers)` → `{ items, totalCount, ... }`, but AdminPage does
// `setUsers(response)` expecting `User[]`, then crashes on `users.map`. The
// catch() swallows fetch errors but not the subsequent React render error.
// Sidestep here by returning a plain array — does NOT fix the underlying
// shape mismatch (out of scope for AZ-512; flag in batch report).
function stubUsersAsPlainArray() {
server.use(http.get('/api/admin/users', () => jsonResponse([])))
}
function capturePatchCalls() {
const calls: { url: string; body: unknown }[] = []
server.use(
http.patch('/api/admin/classes/:id', async ({ params, request }) => {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
calls.push({ url: `/api/admin/classes/${String(params.id)}`, body })
return jsonResponse({ id: Number(params.id), ...body })
}),
)
return calls
}
function getRow(idText: string): HTMLElement {
const cell = screen.getByText(idText, { selector: 'td' })
const tr = cell.closest('tr')
if (!tr) throw new Error(`row for "${idText}" not found`)
return tr as HTMLElement
}
async function clickEdit(rowIdText: string) {
// Arrange — find the editable row by its id-cell, then its pencil button.
const row = getRow(rowIdText)
const editBtn = within(row).getByRole('button', { name: /edit|редагувати/i })
await userEvent.click(editBtn)
}
beforeEach(() => {
seedBearer()
setClassesHandler(TWO_CLASSES)
stubUsersAsPlainArray()
})
afterEach(() => {
clearBearer()
})
describe('AZ-512 / AdminPage — inline detection-class edit', () => {
describe('AC-1: edit affordance visible on every class row', () => {
it('renders a pencil button per row', async () => {
// Act
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Assert — one edit button per class row.
const editButtons = await screen.findAllByRole('button', { name: /edit|редагувати/i })
expect(editButtons.length).toBe(TWO_CLASSES.length)
})
})
describe('AC-2: clicking edit opens inline form with seeded values', () => {
it('row 1 enters edit mode with name="class-a"; other rows stay read-only', async () => {
// Arrange
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act
await clickEdit('1')
// Assert — form is visible inside row 1.
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
expect(nameInput).toBeInTheDocument()
const shortInput = within(row1).getByDisplayValue('a') as HTMLInputElement
expect(shortInput).toBeInTheDocument()
const maxSize = within(row1).getByDisplayValue('7') as HTMLInputElement
expect(maxSize).toBeInTheDocument()
// Assert — row 2 stays read-only: the row still shows the plain text name.
const row2 = getRow('2')
expect(within(row2).getByText('class-b')).toBeInTheDocument()
})
it('opening edit on row 2 while row 1 is editing closes row 1 (single-row invariant)', async () => {
// Arrange
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
expect(within(getRow('1')).getByDisplayValue('class-a')).toBeInTheDocument()
// Act
await clickEdit('2')
// Assert — row 1 reverts; row 2 now hosts the form.
expect(within(getRow('1')).getByText('class-a')).toBeInTheDocument()
expect(within(getRow('2')).getByDisplayValue('class-b')).toBeInTheDocument()
})
})
describe('AC-3: Save sends PATCH and refreshes', () => {
it('Save button → one PATCH with full body, row re-renders, form closes', async () => {
// Arrange — capture PATCH; second GET returns the renamed class.
const patchCalls = capturePatchCalls()
let getCount = 0
server.use(
http.get('/api/annotations/classes', () => {
getCount += 1
if (getCount === 1) return jsonResponse(TWO_CLASSES)
return jsonResponse([{ ...TWO_CLASSES[0], name: 'class-a-renamed' }, TWO_CLASSES[1]])
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'class-a-renamed')
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — exactly one PATCH with the complete editable shape.
await waitFor(() => expect(patchCalls.length).toBe(1))
expect(patchCalls[0].url).toBe('/api/admin/classes/1')
expect(patchCalls[0].body).toEqual({
name: 'class-a-renamed',
shortName: 'a',
color: '#ff0000',
maxSizeM: 7,
})
// Assert — row re-renders read-only with the new name.
await waitFor(() => {
expect(screen.getByText('class-a-renamed')).toBeInTheDocument()
})
})
it('Enter key inside form behaves like Save', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'pressed-enter')
// Act
fireEvent.keyDown(nameInput, { key: 'Enter' })
// Assert
await waitFor(() => expect(patchCalls.length).toBe(1))
expect((patchCalls[0].body as { name: string }).name).toBe('pressed-enter')
})
})
describe('AC-4: Cancel discards edits without network', () => {
it('Cancel button → no PATCH; row reverts', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'never-saved')
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^cancel$|^скасувати$/i }))
// Assert — original value back; no PATCH issued.
expect(within(getRow('1')).getByText('class-a')).toBeInTheDocument()
expect(patchCalls.length).toBe(0)
})
it('Escape key inside form behaves like Cancel', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
// Act
fireEvent.keyDown(nameInput, { key: 'Escape' })
// Assert
expect(within(getRow('1')).getByText('class-a')).toBeInTheDocument()
expect(patchCalls.length).toBe(0)
})
})
describe('AC-5: validation prevents invalid submits', () => {
it('empty name → no PATCH; nameRequired error visible', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — no PATCH; error alert rendered.
expect(patchCalls.length).toBe(0)
const alert = within(row1).getByRole('alert')
expect(alert.textContent ?? '').toMatch(/name is required|назва обов/i)
})
it('non-positive maxSizeM → no PATCH; maxSizeMustBePositive error visible', async () => {
// Arrange
const patchCalls = capturePatchCalls()
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const maxInput = within(row1).getByDisplayValue('7') as HTMLInputElement
await userEvent.clear(maxInput)
await userEvent.type(maxInput, '0')
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — no PATCH; error alert rendered.
expect(patchCalls.length).toBe(0)
const alert = within(row1).getByRole('alert')
expect(alert.textContent ?? '').toMatch(/positive|додатнім/i)
})
})
describe('AC-6: backend error is surfaced inline', () => {
it('PATCH 500 → form stays open; updateFailed error visible; no alert() called', async () => {
// Arrange — install a stub that 500s on PATCH; spy on window.alert.
let patchCount = 0
server.use(
http.patch('/api/admin/classes/:id', () => {
patchCount += 1
return errorResponse(500, 'simulated server error')
}),
)
const alertSpy = window.alert
let alertCalls = 0
window.alert = () => { alertCalls += 1 }
try {
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
await clickEdit('1')
const row1 = getRow('1')
const nameInput = within(row1).getByDisplayValue('class-a') as HTMLInputElement
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'will-fail')
// Act
await userEvent.click(within(row1).getByRole('button', { name: /^save$|^зберегти$/i }))
// Assert — PATCH happened, error rendered, form still open, no alert().
await waitFor(() => expect(patchCount).toBe(1))
const row1After = getRow('1')
const alert = await within(row1After).findByRole('alert')
expect(alert.textContent ?? '').toMatch(/update failed|не вдалося оновити/i)
expect(within(row1After).getByDisplayValue('will-fail')).toBeInTheDocument()
expect(alertCalls).toBe(0)
} finally {
window.alert = alertSpy
}
})
})
describe('AC-8: regression — add + delete unchanged', () => {
it('Add posts to /api/admin/classes and refetches the list', async () => {
// Arrange — capture POST; second GET returns 3 classes.
const postCalls: { body: unknown }[] = []
let getCount = 0
const NEW_CLASS: DetectionClass = { id: 3, name: 'fresh', shortName: '', color: '#FF0000', maxSizeM: 7, photoMode: 0 }
server.use(
http.post('/api/admin/classes', async ({ request }) => {
postCalls.push({ body: await request.json() })
return jsonResponse(NEW_CLASS, { status: 201 })
}),
http.get('/api/annotations/classes', () => {
getCount += 1
if (getCount === 1) return jsonResponse(TWO_CLASSES)
return jsonResponse([...TWO_CLASSES, NEW_CLASS])
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act — scope to the classes table panel (both the class-add row and
// the user-add row use placeholder="Name" + a `+` button; disambiguate
// by walking up from the class-a cell to the enclosing panel).
const classesPanel = (getRow('1').closest('table') as HTMLElement).parentElement as HTMLElement
const addNameInput = within(classesPanel).getByPlaceholderText('Name') as HTMLInputElement
await userEvent.type(addNameInput, 'fresh')
await userEvent.click(within(classesPanel).getByRole('button', { name: '+' }))
// Assert
await waitFor(() => expect(postCalls.length).toBe(1))
expect((postCalls[0].body as { name: string }).name).toBe('fresh')
await waitFor(() => expect(screen.getByText('fresh')).toBeInTheDocument())
})
it('Delete sends DELETE and removes the row optimistically', async () => {
// Arrange
const deleteCalls: string[] = []
server.use(
http.delete('/api/admin/classes/:id', ({ params }) => {
deleteCalls.push(`/api/admin/classes/${String(params.id)}`)
return new Response(null, { status: 204 })
}),
)
renderWithProviders(<AdminPage />)
await screen.findByText('class-a')
// Act
const row1 = getRow('1')
await userEvent.click(within(row1).getByRole('button', { name: '×' }))
// Assert
await waitFor(() => expect(deleteCalls).toEqual(['/api/admin/classes/1']))
await waitFor(() => expect(screen.queryByText('class-a')).not.toBeInTheDocument())
})
})
})