test(frontend): Vitest coverage on sprint 1 wiring

Adds a small test harness (src/test/testUtils.tsx):
- renderWithProviders mounts a fresh QueryClient (no retries, no cache)
  + MemoryRouter so screens using useNavigate / <Link> don't crash.
- installFetchMock(responses[]) replaces globalThis.fetch with a typed
  sequence of canned responses and records call URLs + init.

Specs (10 cases, all green):

LoginPage.test.tsx
- happy path: submit posts to /api/v1/auth/login with credentials:'include',
  correct JSON body shape (username/password).
- 401 surfaces "Identifiants invalides" and does NOT leak the backend
  detail string.
- empty submit is intercepted by HTML5 `required` — no fetch fires.

EngagementsPage.test.tsx
- loading row renders while /engagements is in flight.
- empty state renders on 200 [].
- error state + Retry button render on 500.
- populated table renders the snake_case fields correctly (name,
  client_name, c2_type uppercased).

EngagementCreateDialog.test.tsx
- client-side validation: empty name blocks submission, no fetch fires.
- 422 Pydantic error on the `name` field maps to the inline message
  next to the input.
- 201 success triggers onClose() and POSTs to /api/v1/engagements.
This commit is contained in:
ux-frontend
2026-05-23 04:26:56 +02:00
committed by knacky
parent 20fbcdf1f8
commit ec7effcaac
4 changed files with 276 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { EngagementCreateDialog } from './EngagementCreateDialog';
import { installFetchMock, renderWithProviders } from '@/test/testUtils';
describe('EngagementCreateDialog', () => {
let fetchMock: ReturnType<typeof installFetchMock>;
afterEach(() => {
fetchMock?.restore();
});
it('rejects empty name client-side without calling the backend', () => {
fetchMock = installFetchMock([]);
renderWithProviders(<EngagementCreateDialog onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /arm engagement/i }));
expect(screen.getByText(/nom requis/i)).toBeInTheDocument();
expect(fetchMock.calls).toHaveLength(0);
});
it('maps 422 Pydantic errors to per-field messages', async () => {
fetchMock = installFetchMock([
{
status: 422,
body: {
detail: [
{
loc: ['body', 'name'],
msg: 'String should have at least 3 characters',
type: 'string_too_short',
},
],
},
},
]);
renderWithProviders(<EngagementCreateDialog onClose={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/engagement name/i), { target: { value: 'AB' } });
fireEvent.click(screen.getByRole('button', { name: /arm engagement/i }));
await waitFor(() => {
expect(screen.getByText(/at least 3 characters/i)).toBeInTheDocument();
});
});
it('invalidates the engagements query and closes on success', async () => {
const onClose = vi.fn();
fetchMock = installFetchMock([
{
status: 201,
body: {
id: 'eng_new',
name: 'OPERATION ZETA',
client_name: null,
description: null,
status: 'planning',
c2_type: null,
start_date: null,
end_date: null,
created_at: '2026-05-23T08:00:00Z',
},
},
]);
renderWithProviders(<EngagementCreateDialog onClose={onClose} />);
fireEvent.change(screen.getByLabelText(/engagement name/i), {
target: { value: 'OPERATION ZETA' },
});
fireEvent.click(screen.getByRole('button', { name: /arm engagement/i }));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(fetchMock.calls[0]?.url).toBe('/api/v1/engagements');
expect(fetchMock.calls[0]?.init?.method).toBe('POST');
});
});