From b47ade507da274ad7ccb202aebe87d2a3986b93d Mon Sep 17 00:00:00 2001 From: ux-frontend Date: Sat, 23 May 2026 04:26:56 +0200 Subject: [PATCH] test(frontend): Vitest coverage on sprint 1 wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small test harness (src/test/testUtils.tsx): - renderWithProviders mounts a fresh QueryClient (no retries, no cache) + MemoryRouter so screens using useNavigate / 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. --- .../EngagementCreateDialog.test.tsx | 77 +++++++++++++++++++ .../engagements/EngagementsPage.test.tsx | 61 +++++++++++++++ frontend/src/screens/login/LoginPage.test.tsx | 63 +++++++++++++++ frontend/src/test/testUtils.tsx | 75 ++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 frontend/src/screens/engagements/EngagementCreateDialog.test.tsx create mode 100644 frontend/src/screens/engagements/EngagementsPage.test.tsx create mode 100644 frontend/src/screens/login/LoginPage.test.tsx create mode 100644 frontend/src/test/testUtils.tsx diff --git a/frontend/src/screens/engagements/EngagementCreateDialog.test.tsx b/frontend/src/screens/engagements/EngagementCreateDialog.test.tsx new file mode 100644 index 0000000..330034f --- /dev/null +++ b/frontend/src/screens/engagements/EngagementCreateDialog.test.tsx @@ -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; + + afterEach(() => { + fetchMock?.restore(); + }); + + it('rejects empty name client-side without calling the backend', () => { + fetchMock = installFetchMock([]); + renderWithProviders(); + 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(); + + 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(); + + 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'); + }); +}); diff --git a/frontend/src/screens/engagements/EngagementsPage.test.tsx b/frontend/src/screens/engagements/EngagementsPage.test.tsx new file mode 100644 index 0000000..983b22c --- /dev/null +++ b/frontend/src/screens/engagements/EngagementsPage.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { EngagementsPage } from './EngagementsPage'; +import { installFetchMock, renderWithProviders } from '@/test/testUtils'; + +describe('EngagementsPage', () => { + let fetchMock: ReturnType; + + afterEach(() => { + fetchMock?.restore(); + }); + + it('shows the loading row while the engagements query is pending', () => { + fetchMock = installFetchMock([]); // never resolve in this test + // Replace with a long-lived promise so the query sits in pending state. + const pending: typeof fetch = () => new Promise(() => null); + globalThis.fetch = pending; + renderWithProviders(); + expect(screen.getByText(/fetching engagements/i)).toBeInTheDocument(); + }); + + it('renders the empty state when the list is empty', async () => { + fetchMock = installFetchMock([{ status: 200, body: [] }]); + renderWithProviders(); + await screen.findByText(/no engagements yet/i); + }); + + it('renders the error state on 500', async () => { + fetchMock = installFetchMock([{ status: 500, body: { detail: 'internal_error' } }]); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText(/fetch failed/i)).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + + it('renders rows when the backend returns engagements', async () => { + fetchMock = installFetchMock([ + { + status: 200, + body: [ + { + id: 'eng_1', + name: 'OPERATION ALPHA', + client_name: 'Acme', + description: null, + status: 'active', + c2_type: 'mythic', + start_date: '2026-05-20', + end_date: '2026-05-30', + created_at: '2026-05-20T10:00:00Z', + }, + ], + }, + ]); + renderWithProviders(); + await screen.findByText('OPERATION ALPHA'); + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(screen.getByText('MYTHIC')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/screens/login/LoginPage.test.tsx b/frontend/src/screens/login/LoginPage.test.tsx new file mode 100644 index 0000000..b2ede5d --- /dev/null +++ b/frontend/src/screens/login/LoginPage.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { LoginPage } from './LoginPage'; +import { installFetchMock, renderWithProviders } from '@/test/testUtils'; + +describe('LoginPage', () => { + let fetchMock: ReturnType; + + afterEach(() => { + fetchMock?.restore(); + }); + + it('submits credentials and seeds session cache on success', async () => { + fetchMock = installFetchMock([ + { + status: 200, + body: { + id: 'usr_1', + username: 'alice', + display_name: 'Alice', + role: 'rt_lead', + }, + }, + ]); + + renderWithProviders(); + + fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'alice' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'hunter2' } }); + fireEvent.click(screen.getByRole('button', { name: /enter mimic/i })); + + await waitFor(() => { + expect(fetchMock.calls).toHaveLength(1); + }); + expect(fetchMock.calls[0]?.url).toBe('/api/v1/auth/login'); + const init = fetchMock.calls[0]?.init; + expect(init?.method).toBe('POST'); + expect(init?.credentials).toBe('include'); + const bodyStr = typeof init?.body === 'string' ? init.body : ''; + expect(JSON.parse(bodyStr)).toEqual({ username: 'alice', password: 'hunter2' }); + }); + + it('shows a generic error on 401 without leaking server detail', async () => { + fetchMock = installFetchMock([{ status: 401, body: { detail: 'user_not_found' } }]); + + renderWithProviders(); + + fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'alice' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } }); + fireEvent.click(screen.getByRole('button', { name: /enter mimic/i })); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/identifiants invalides/i); + expect(alert.textContent).not.toMatch(/user_not_found/i); + }); + + it('does not call the backend on empty submit (HTML5 required intercepts)', () => { + fetchMock = installFetchMock([]); + renderWithProviders(); + fireEvent.click(screen.getByRole('button', { name: /enter mimic/i })); + expect(fetchMock.calls).toHaveLength(0); + }); +}); diff --git a/frontend/src/test/testUtils.tsx b/frontend/src/test/testUtils.tsx new file mode 100644 index 0000000..beaa563 --- /dev/null +++ b/frontend/src/test/testUtils.tsx @@ -0,0 +1,75 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { render, type RenderOptions } from '@testing-library/react'; +import type { ReactElement, ReactNode } from 'react'; + +/** + * Test harness: a fresh QueryClient per render (no retries, no caching) + + * a MemoryRouter so screens that call `useNavigate` / `` don't crash. + * + * Components under test never reach the real network — tests stub + * `globalThis.fetch` before render. + */ +export function renderWithProviders( + ui: ReactElement, + options?: { route?: string; queryClient?: QueryClient } & RenderOptions, +) { + const client = + options?.queryClient ?? + new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + mutations: { retry: false }, + }, + }); + + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + + return { client, ...render(ui, { wrapper: Wrapper, ...options }) }; +} + +/** + * Tiny typed fetch mock. Replaces globalThis.fetch with a sequence of + * recorded responses, in call order. Tests `restore()` in afterEach. + */ +export function installFetchMock(responses: Array<{ status: number; body?: unknown }>) { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const queue = [...responses]; + const original = globalThis.fetch; + + const mock: typeof fetch = (input, init) => { + const url = inputToUrl(input); + calls.push({ url, init }); + const next = queue.shift(); + if (!next) { + return Promise.reject(new Error(`Unexpected extra fetch call to ${url}`)); + } + const body = next.body === undefined ? '' : JSON.stringify(next.body); + return Promise.resolve( + new Response(body, { + status: next.status, + headers: { 'Content-Type': 'application/json' }, + }), + ); + }; + globalThis.fetch = mock; + + return { + calls, + restore: () => { + globalThis.fetch = original; + }, + }; +} + +function inputToUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.toString(); + return input.url; +}