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.')` 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 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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()) }) }) })