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:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
61
frontend/src/screens/engagements/EngagementsPage.test.tsx
Normal file
61
frontend/src/screens/engagements/EngagementsPage.test.tsx
Normal file
@@ -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<typeof installFetchMock>;
|
||||||
|
|
||||||
|
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(<EngagementsPage />);
|
||||||
|
expect(screen.getByText(/fetching engagements/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the empty state when the list is empty', async () => {
|
||||||
|
fetchMock = installFetchMock([{ status: 200, body: [] }]);
|
||||||
|
renderWithProviders(<EngagementsPage />);
|
||||||
|
await screen.findByText(/no engagements yet/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the error state on 500', async () => {
|
||||||
|
fetchMock = installFetchMock([{ status: 500, body: { detail: 'internal_error' } }]);
|
||||||
|
renderWithProviders(<EngagementsPage />);
|
||||||
|
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(<EngagementsPage />);
|
||||||
|
await screen.findByText('OPERATION ALPHA');
|
||||||
|
expect(screen.getByText('Acme')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('MYTHIC')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
frontend/src/screens/login/LoginPage.test.tsx
Normal file
63
frontend/src/screens/login/LoginPage.test.tsx
Normal file
@@ -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<typeof installFetchMock>;
|
||||||
|
|
||||||
|
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(<LoginPage />);
|
||||||
|
|
||||||
|
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(<LoginPage />);
|
||||||
|
|
||||||
|
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(<LoginPage />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /enter mimic/i }));
|
||||||
|
expect(fetchMock.calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
frontend/src/test/testUtils.tsx
Normal file
75
frontend/src/test/testUtils.tsx
Normal file
@@ -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` / `<Link>` 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 (
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MemoryRouter initialEntries={[options?.route ?? '/']}>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user