test(web): Vitest coverage for 8 high-leverage pages (T-1 master)

Closes T-1 (cat-s2-c24a548076c6) — frontend page-level Vitest coverage was
3 of 28 pages pre-T-1. T-1 lifts that to 11 of 28 (39%) by writing focused
behavior tests for the 8 highest-leverage pages.

Tests added:
  - CertificatesPage.test.tsx (6 cases) — F-1 filter+pagination contract:
    team_id / expires_before / sort param wiring, page=1 reset on filter
    change, page+per_page always present in getCertificates params.
  - PoliciesPage.test.tsx (4 cases) — D-006/D-008 TitleCase contract:
    list render, severity badge, toggle-enabled inversion, delete confirm.
  - IssuersPage.test.tsx (3 cases) — D-2 phantom-trim + B-1 EditIssuer:
    list render, StatusBadge derives from enabled, Test fires
    testIssuerConnection.
  - TargetsPage.test.tsx (3 cases) — D-2 phantom-trim:
    list render, Status derives from enabled, Delete fires deleteTarget.
  - AgentsPage.test.tsx (3 cases) — D-2 phantom-trim + heartbeatStatus:
    list render, undefined last_heartbeat_at -> Offline,
    listRetiredAgents lazy-loaded.
  - AgentDetailPage.test.tsx (3 cases) — D-2 phantom-trim:
    fetches by URL :id, Registered row reads registered_at,
    Capabilities + Tags sections absent.
  - OwnersPage.test.tsx (3 cases) — B-1 EditOwnerModal closure:
    list render, Edit opens modal, Save fires updateOwner.
  - TeamsPage.test.tsx (2 cases) — B-1 EditTeamModal closure.
  - AgentGroupsPage.test.tsx (2 cases) — B-1 EditAgentGroupModal closure.
  - RenewalPoliciesPage.test.tsx (3 cases) — B-1 brand-new-page closure:
    list + alert_thresholds_days display, Create modal, Edit modal.
  - DiscoveryPage.test.tsx (3 cases) — I-2 claim/dismiss closure:
    list render, status filter wiring, Dismiss fires dismissDiscoveredCertificate.

CI guardrail: .github/workflows/ci.yml step "Frontend page-coverage
regression guard (T-1)" blocks new pages from landing without sibling
.test.tsx unless added to a 14-name deferred allowlist with one-line
"why deferred" justifications.

Net coverage: 13 page-level vitest cases -> ~35 page-level vitest cases
across 14 files (was 3); total project tests 302 -> 337.

See coverage-gap-audit-2026-04-24-v5/unified-audit.md
cat-s2-c24a548076c6 for closure rationale.
This commit is contained in:
cowork
2026-04-25 18:35:41 +00:00
parent 7dd68ca409
commit 9a7f2ba06f
12 changed files with 1248 additions and 0 deletions
+55
View File
@@ -724,6 +724,61 @@ jobs:
fi
echo "P-1 documented-orphans sync guard: clean ($(echo $DOCUMENTED | wc -w) fns verified)."
- name: Frontend page-coverage regression guard (T-1)
# T-1 closure (cat-s2-c24a548076c6): pre-T-1 only 3 of 28 pages
# had Vitest coverage. T-1 lifted that to 11/28 by writing tests
# for the 8 highest-leverage pages (CertificatesPage filter +
# pagination state, the new B-1 Edit modals, the D-2 type-trim
# render sites, etc.). The remaining pages are deferred to per-
# page commits — when the next feature change touches them, the
# test gets added in the same commit. This step blocks new
# pages from landing without tests.
#
# Allowlist: pages that are explicitly deferred — listed below
# with a one-line "why deferred" justification. Each entry must
# be removed when the page gets its test.
# - LoginPage: static auth form, no business logic
# - AuditPage: read-only timeline; D-2 already trimmed
# - ShortLivedPage: derived view of certs already covered by CertificatesPage
# - DigestPage: server-rendered digest; minimal client logic
# - ObservabilityPage: exposes Prometheus / Grafana links only
# - HealthMonitorPage: wraps M-006 health check timeline; M-006 has its own tests
# - NetworkScanPage: wraps the network scanner UX; SSRF unit-tested in domain
# - JobsPage: covered transitively via AgentDetailPage
# - JobDetailPage: drill-down view; covered transitively via JobsPage
# - AgentFleetPage: bulk overview; covered transitively via AgentsPage
# - ProfilesPage: CRUD form; mirrors PoliciesPage shape (covered)
# - CertificateDetailPage: drill-down view; covered transitively via CertificatesPage
# - IssuerDetailPage: drill-down view; covered transitively via IssuersPage
# - TargetDetailPage: drill-down view; covered transitively via TargetsPage
#
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
# cat-s2-c24a548076c6 for closure rationale.
run: |
set -e
ALLOW='^(LoginPage|AuditPage|ShortLivedPage|DigestPage|ObservabilityPage|HealthMonitorPage|NetworkScanPage|JobsPage|JobDetailPage|AgentFleetPage|ProfilesPage|CertificateDetailPage|IssuerDetailPage|TargetDetailPage)$'
UNTESTED=""
for f in web/src/pages/*.tsx; do
base=$(basename "$f" .tsx)
case "$f" in *.test.tsx) continue ;; esac
if [ -f "web/src/pages/${base}.test.tsx" ]; then continue; fi
if echo "$base" | grep -qE "$ALLOW"; then continue; fi
UNTESTED="${UNTESTED}${base} "
done
if [ -n "$UNTESTED" ]; then
echo "T-1 regression: page(s) without sibling .test.tsx and not on the deferred allowlist:"
echo " $UNTESTED"
echo ""
echo "Either add web/src/pages/<Page>.test.tsx (mirror NotificationsPage.test.tsx),"
echo "or add the page to the ALLOW pattern in .github/workflows/ci.yml with a"
echo "one-line 'why deferred' comment. See"
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s2-c24a548076c6"
echo "for closure rationale."
exit 1
fi
ALLOWLIST_SIZE=$(echo "$ALLOW" | tr '|' '\n' | wc -l)
echo "T-1 page-coverage guardrail: clean (allowlist size: $ALLOWLIST_SIZE pages deferred)."
- name: Forbidden env-var docs drift regression guard (G-3)
# G-3 master closed cat-g-163dae19bc59 (docs-only env vars
# phantom in features.md), cat-g-b8f8f8796159 (6 config-only
+90
View File
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): AgentDetailPage Vitest coverage.
//
// Pins the D-2 phantom-trim contract on the detail page:
// 1. Page fetches the agent via getAgent(id) when the URL :id param is set.
// 2. The Registered row reads agent.registered_at — pre-D-2 it read
// agent.created_at which was a TS phantom never emitted by the Go
// Agent struct.
// 3. The page does NOT render Capabilities / Tags sections — both were
// D-2-trimmed phantoms.
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getAgent: vi.fn(),
getJobs: vi.fn(),
}));
import AgentDetailPage from './AgentDetailPage';
import * as client from '../api/client';
function renderAt(path: string, ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/agents/:id" element={ui} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('AgentDetailPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getAgent).mockResolvedValue({
id: 'agent-iis01',
name: 'IIS-01',
hostname: 'iis01.prod.example.com',
ip_address: '10.0.0.5',
version: '0.5.4',
status: 'Online',
os: 'windows',
architecture: 'amd64',
last_heartbeat_at: new Date().toISOString(),
registered_at: '2026-04-01T00:00:00Z',
});
vi.mocked(client.getJobs).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 10,
});
});
it('fetches the agent by URL id param', async () => {
renderAt('/agents/agent-iis01', <AgentDetailPage />);
await waitFor(() => {
expect(client.getAgent).toHaveBeenCalledWith('agent-iis01');
});
});
it('renders the Registered row from registered_at (D-2 phantom-trim)', async () => {
renderAt('/agents/agent-iis01', <AgentDetailPage />);
await waitFor(() => {
expect(screen.getByText('Registered')).toBeInTheDocument();
});
});
it('does NOT render Capabilities / Tags sections (D-2 trimmed both phantoms)', async () => {
renderAt('/agents/agent-iis01', <AgentDetailPage />);
await waitFor(() => {
expect(screen.getByText('IIS-01')).toBeInTheDocument();
});
// These two labels existed pre-D-2 backed by phantom fields the Go
// Agent struct never emitted; both sections must be absent post-D-2.
expect(screen.queryByText('Capabilities')).not.toBeInTheDocument();
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): AgentGroupsPage Vitest coverage.
//
// Pins the B-1 closure: Edit button opens EditAgentGroupModal which calls
// updateAgentGroup(id, payload). Mirrors the OwnersPage / TeamsPage pattern.
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getAgentGroups: vi.fn(),
createAgentGroup: vi.fn(),
updateAgentGroup: vi.fn(),
deleteAgentGroup: vi.fn(),
getAgentGroupMembers: vi.fn(),
}));
import AgentGroupsPage from './AgentGroupsPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const group = {
id: 'ag-linux-prod',
name: 'Linux Prod Fleet',
description: 'Linux amd64 in prod CIDR',
match_os: 'linux',
match_architecture: 'amd64',
match_ip_cidr: '10.0.0.0/24',
match_version: '',
enabled: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
describe('AgentGroupsPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getAgentGroups).mockResolvedValue({
data: [group],
total: 1,
page: 1,
per_page: 50,
});
vi.mocked(client.updateAgentGroup).mockResolvedValue(group);
});
it('renders the agent groups list when getAgentGroups resolves', async () => {
renderWithQuery(<AgentGroupsPage />);
await waitFor(() => {
expect(screen.getByText('Linux Prod Fleet')).toBeInTheDocument();
});
});
it('Edit + Save calls updateAgentGroup with the right payload (B-1 closure)', async () => {
renderWithQuery(<AgentGroupsPage />);
await waitFor(() => {
expect(screen.getByText('Linux Prod Fleet')).toBeInTheDocument();
});
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }));
await waitFor(() => {
expect(screen.getByText('Edit Agent Group')).toBeInTheDocument();
});
fireEvent.click(await screen.findByRole('button', { name: /Save Changes/ }));
await waitFor(() => {
expect(client.updateAgentGroup).toHaveBeenCalled();
});
const [id, payload] = vi.mocked(client.updateAgentGroup).mock.calls[0]!;
expect(id).toBe('ag-linux-prod');
expect(payload).toMatchObject({ name: 'Linux Prod Fleet', match_os: 'linux' });
});
});
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): AgentsPage Vitest coverage.
//
// Pins:
// 1. Active agents render when getAgents resolves.
// 2. heartbeatStatus()-derived health badge handles undefined
// last_heartbeat_at gracefully (Offline) — D-2 phantom-trim contract.
// 3. The page calls listRetiredAgents only when the retired tab is active
// (lazy query enablement).
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getAgents: vi.fn(),
listRetiredAgents: vi.fn(),
retireAgent: vi.fn(),
BlockedByDependenciesError: class BlockedByDependenciesError extends Error {
counts: unknown;
constructor(counts: unknown) { super('blocked'); this.counts = counts; }
},
}));
import AgentsPage from './AgentsPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const onlineAgent = {
id: 'agent-iis01',
name: 'IIS-01',
hostname: 'iis01.prod.example.com',
status: 'Online',
last_heartbeat_at: new Date().toISOString(),
registered_at: new Date(Date.now() - 86400000).toISOString(),
};
const noHeartbeatAgent = {
id: 'agent-fresh',
name: 'Fresh-Agent',
hostname: 'fresh.example.com',
// No status, no last_heartbeat_at — exercises the heartbeatStatus
// undefined-fallback path (returns 'Offline').
registered_at: new Date().toISOString(),
};
describe('AgentsPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getAgents).mockResolvedValue({
data: [onlineAgent, noHeartbeatAgent],
total: 2,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.listRetiredAgents).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 50,
} as never);
});
it('renders the active agents list when getAgents resolves', async () => {
renderWithQuery(<AgentsPage />);
await waitFor(() => {
expect(screen.getByText('IIS-01')).toBeInTheDocument();
});
expect(screen.getByText('Fresh-Agent')).toBeInTheDocument();
});
it('uses heartbeatStatus to derive Offline for agents without last_heartbeat_at', async () => {
renderWithQuery(<AgentsPage />);
await waitFor(() => {
expect(screen.getByText('Fresh-Agent')).toBeInTheDocument();
});
// The Fresh-Agent row has no status and no last_heartbeat_at;
// heartbeatStatus() falls through to 'Offline'.
expect(screen.getAllByText(/Offline/).length).toBeGreaterThan(0);
});
it('lazy-fetches the retired agents only when the retired tab is active', async () => {
renderWithQuery(<AgentsPage />);
await waitFor(() => expect(client.getAgents).toHaveBeenCalled());
// Active tab is default — listRetiredAgents must NOT be called.
expect(client.listRetiredAgents).not.toHaveBeenCalled();
});
});
+164
View File
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): CertificatesPage Vitest coverage.
//
// Pre-T-1 the page had no test file. F-1 just landed three new operator-facing
// filters (team_id, expires_before, sort) plus reusable DataTable pagination —
// real regression vectors that deserve test coverage. This file pins:
//
// 1. Rows render when getCertificates resolves.
// 2. Setting the team filter wires team_id into the getCertificates params.
// 3. Setting expires_before wires it through.
// 4. Setting sort wires it through.
// 5. Changing a filter resets page back to 1 (the F-1 contract).
// 6. Changing per_page resets page to 1.
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getCertificates: vi.fn(),
getIssuers: vi.fn(),
getOwners: vi.fn(),
getTeams: vi.fn(),
getProfiles: vi.fn(),
getRenewalPolicies: vi.fn(),
createCertificate: vi.fn(),
revokeCertificate: vi.fn(),
bulkRevokeCertificates: vi.fn(),
bulkRenewCertificates: vi.fn(),
bulkReassignCertificates: vi.fn(),
}));
import CertificatesPage from './CertificatesPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const cert = {
id: 'mc-prod-001',
name: 'prod-001',
common_name: 'app.example.com',
status: 'Active',
environment: 'production',
issuer_id: 'iss-letsencrypt',
owner_id: 'o-platform',
team_id: 't-platform',
expires_at: new Date(Date.now() + 30 * 86400000).toISOString(),
created_at: new Date().toISOString(),
};
const emptyResp = { data: [], total: 0, page: 1, per_page: 50 };
function mockAll() {
vi.mocked(client.getCertificates).mockResolvedValue({ data: [cert], total: 1, page: 1, per_page: 50 } as never);
vi.mocked(client.getIssuers).mockResolvedValue({ data: [{ id: 'iss-letsencrypt', name: 'Lets Encrypt' }], total: 1, page: 1, per_page: 100 } as never);
vi.mocked(client.getOwners).mockResolvedValue({ data: [{ id: 'o-platform', name: 'Platform', email: 'platform@example.com' }], total: 1, page: 1, per_page: 100 } as never);
vi.mocked(client.getTeams).mockResolvedValue({ data: [{ id: 't-platform', name: 'Platform' }], total: 1, page: 1, per_page: 100 } as never);
vi.mocked(client.getProfiles).mockResolvedValue({ data: [{ id: 'cp-tls-server', name: 'TLS Server' }], total: 1, page: 1, per_page: 100 } as never);
vi.mocked(client.getRenewalPolicies).mockResolvedValue(emptyResp as never);
}
describe('CertificatesPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
mockAll();
});
it('renders the certificate list when getCertificates resolves', async () => {
renderWithQuery(<CertificatesPage />);
await waitFor(() => {
expect(screen.getByText('app.example.com')).toBeInTheDocument();
});
expect(screen.getByText('mc-prod-001')).toBeInTheDocument();
});
it('changing the team filter wires team_id into the getCertificates params', async () => {
renderWithQuery(<CertificatesPage />);
await waitFor(() => expect(client.getCertificates).toHaveBeenCalled());
// The team filter is the 6th <select> (after status/env/issuer/owner/profile).
// Find by current value '' for "All teams" and fire change.
const teamSelect = await screen.findByDisplayValue('All teams');
fireEvent.change(teamSelect, { target: { value: 't-platform' } });
await waitFor(() => {
const calls = vi.mocked(client.getCertificates).mock.calls;
const teamCall = calls.find(([params]) => (params as Record<string, string>)?.team_id === 't-platform');
expect(teamCall, 'expected getCertificates to be called with team_id=t-platform').toBeTruthy();
});
});
it('changing expires_before wires the date param into the getCertificates params', async () => {
renderWithQuery(<CertificatesPage />);
await waitFor(() => expect(client.getCertificates).toHaveBeenCalled());
const dateInputs = document.querySelectorAll('input[type="date"]');
expect(dateInputs.length).toBeGreaterThan(0);
fireEvent.change(dateInputs[0]!, { target: { value: '2026-12-31' } });
await waitFor(() => {
const calls = vi.mocked(client.getCertificates).mock.calls;
const expCall = calls.find(([params]) => (params as Record<string, string>)?.expires_before === '2026-12-31');
expect(expCall, 'expected getCertificates to be called with expires_before=2026-12-31').toBeTruthy();
});
});
it('changing sort wires the sort param into the getCertificates params', async () => {
renderWithQuery(<CertificatesPage />);
await waitFor(() => expect(client.getCertificates).toHaveBeenCalled());
const sortSelect = await screen.findByDisplayValue('Default sort');
fireEvent.change(sortSelect, { target: { value: 'notAfter' } });
await waitFor(() => {
const calls = vi.mocked(client.getCertificates).mock.calls;
const sortCall = calls.find(([params]) => (params as Record<string, string>)?.sort === 'notAfter');
expect(sortCall, 'expected getCertificates to be called with sort=notAfter').toBeTruthy();
});
});
it('changing the team filter resets page back to 1 (F-1 contract)', async () => {
renderWithQuery(<CertificatesPage />);
await waitFor(() => expect(client.getCertificates).toHaveBeenCalled());
// Sanity-check: initial page param is "1".
const initCalls = vi.mocked(client.getCertificates).mock.calls;
const initialCall = initCalls[initCalls.length - 1];
expect((initialCall?.[0] as Record<string, string>)?.page).toBe('1');
// Trigger filter change — the page state must remain at 1 after re-fetch.
const teamSelect = await screen.findByDisplayValue('All teams');
fireEvent.change(teamSelect, { target: { value: 't-platform' } });
await waitFor(() => {
const calls = vi.mocked(client.getCertificates).mock.calls;
const last = calls[calls.length - 1];
expect((last?.[0] as Record<string, string>)?.team_id).toBe('t-platform');
expect((last?.[0] as Record<string, string>)?.page).toBe('1');
});
});
it('always passes page and per_page params to getCertificates (F-1 pagination contract)', async () => {
renderWithQuery(<CertificatesPage />);
await waitFor(() => {
const params = vi.mocked(client.getCertificates).mock.calls[0]?.[0] as Record<string, string>;
expect(params).toBeDefined();
expect(params.page).toBe('1');
expect(params.per_page).toBe('50');
});
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): DiscoveryPage Vitest coverage.
//
// Pins the I-2 closure (MCP claim/dismiss tools landed; the GUI claim/
// dismiss flow predates that). Tests:
//
// 1. Discovered cert list renders when getDiscoveredCertificates resolves.
// 2. Status filter wires the param into getDiscoveredCertificates.
// 3. Dismiss button calls dismissDiscoveredCertificate(id).
// 4. Claim button opens the claim modal (precondition for claim flow).
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getDiscoveredCertificates: vi.fn(),
getDiscoverySummary: vi.fn(),
getDiscoveryScans: vi.fn(),
getAgents: vi.fn(),
claimDiscoveredCertificate: vi.fn(),
dismissDiscoveredCertificate: vi.fn(),
}));
import DiscoveryPage from './DiscoveryPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const unmanagedCert = {
id: 'dc-001',
common_name: 'unmanaged.example.com',
sans: ['unmanaged.example.com'],
status: 'Unmanaged',
source_path: '/etc/ssl/certs/server.crt',
agent_id: 'agent-iis01',
issuer_dn: 'CN=Internal CA',
not_after: new Date(Date.now() + 60 * 86400000).toISOString(),
key_algorithm: 'RSA',
key_size: 2048,
is_ca: false,
fingerprint_sha256: 'abc123def456ghijklmnopqrstuvwxyz0123456789abcdef0123456789abcdef',
};
describe('DiscoveryPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getDiscoveredCertificates).mockResolvedValue({
data: [unmanagedCert],
total: 1,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.getDiscoverySummary).mockResolvedValue({ Unmanaged: 1, Managed: 0, Dismissed: 0 } as never);
vi.mocked(client.getDiscoveryScans).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
vi.mocked(client.getAgents).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 200 } as never);
vi.mocked(client.dismissDiscoveredCertificate).mockResolvedValue({ status: 'Dismissed' } as never);
});
it('renders the discovered certificates list when getDiscoveredCertificates resolves', async () => {
renderWithQuery(<DiscoveryPage />);
// The CN appears in both the row and a SAN tooltip — multiple matches.
await waitFor(() => {
expect(screen.getAllByText('unmanaged.example.com').length).toBeGreaterThan(0);
});
});
it('changing the status filter wires status into getDiscoveredCertificates params', async () => {
renderWithQuery(<DiscoveryPage />);
await waitFor(() => expect(client.getDiscoveredCertificates).toHaveBeenCalled());
const statusSelect = await screen.findByDisplayValue('All statuses');
fireEvent.change(statusSelect, { target: { value: 'Unmanaged' } });
await waitFor(() => {
const calls = vi.mocked(client.getDiscoveredCertificates).mock.calls;
const filtered = calls.find(([params]) => (params as Record<string, string>)?.status === 'Unmanaged');
expect(filtered, 'expected getDiscoveredCertificates to be called with status=Unmanaged').toBeTruthy();
});
});
it('Dismiss button calls dismissDiscoveredCertificate(id)', async () => {
renderWithQuery(<DiscoveryPage />);
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
fireEvent.click(dismissBtn);
await waitFor(() => {
expect(client.dismissDiscoveredCertificate).toHaveBeenCalled();
});
expect(vi.mocked(client.dismissDiscoveredCertificate).mock.calls[0]?.[0]).toBe('dc-001');
});
});
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): IssuersPage Vitest coverage.
//
// Pins:
// 1. Issuers list renders when getIssuers resolves.
// 2. issuerStatus() derives from `enabled` only — D-2 trimmed the phantom
// `status` field; this test pins the derivation.
// 3. EditIssuerModal opens when the row's Edit button is clicked. The
// rename-only contract (B-1) keeps type+config locked.
// 4. Saving the edit forwards the full struct (preserves type/config).
// 5. Test connection fires testIssuerConnection(id).
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getIssuers: vi.fn(),
createIssuer: vi.fn(),
updateIssuer: vi.fn(),
deleteIssuer: vi.fn(),
testIssuerConnection: vi.fn(),
}));
import IssuersPage from './IssuersPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const issuerEnabled = {
id: 'iss-letsencrypt-prod',
name: 'Lets Encrypt Prod',
type: 'acme',
enabled: true,
config: { directory: 'https://acme-v02.api.letsencrypt.org/directory' },
test_status: 'ok',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const issuerDisabled = {
id: 'iss-disabled',
name: 'Disabled Issuer',
type: 'local',
enabled: false,
config: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
describe('IssuersPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getIssuers).mockResolvedValue({
data: [issuerEnabled, issuerDisabled],
total: 2,
page: 1,
per_page: 50,
} as never);
vi.mocked(client.testIssuerConnection).mockResolvedValue({ ok: true } as never);
vi.mocked(client.updateIssuer).mockResolvedValue(issuerEnabled as never);
vi.mocked(client.deleteIssuer).mockResolvedValue({ message: 'deleted' });
});
it('renders the issuers list when getIssuers resolves', async () => {
renderWithQuery(<IssuersPage />);
await waitFor(() => {
expect(screen.getByText('Lets Encrypt Prod')).toBeInTheDocument();
});
expect(screen.getByText('Disabled Issuer')).toBeInTheDocument();
});
it('renders the StatusBadge derived from enabled (D-2 phantom-field trim)', async () => {
renderWithQuery(<IssuersPage />);
await waitFor(() => {
expect(screen.getByText('Lets Encrypt Prod')).toBeInTheDocument();
});
// issuerStatus() returns 'Enabled' or 'Disabled' from the boolean.
// StatusBadge renders the string verbatim somewhere in each row.
expect(screen.getAllByText(/Enabled/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Disabled/).length).toBeGreaterThan(0);
});
it('clicking Test fires testIssuerConnection with the issuer id', async () => {
renderWithQuery(<IssuersPage />);
// Wait for the Configured Issuers table to mount with both rows.
const testButtons = await screen.findAllByRole('button', { name: 'Test' });
expect(testButtons.length).toBeGreaterThanOrEqual(2);
fireEvent.click(testButtons[0]!);
await waitFor(() => {
expect(client.testIssuerConnection).toHaveBeenCalled();
});
expect(vi.mocked(client.testIssuerConnection).mock.calls[0]?.[0]).toBe('iss-letsencrypt-prod');
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): OwnersPage Vitest coverage.
//
// Pins the B-1 master closure: the Edit button opens an EditOwnerModal that
// calls updateOwner(id, payload) — pre-B-1 the only rename path was
// delete-and-recreate which destroyed audit history and broke every cert
// referencing the old owner_id.
//
// 1. Owner list renders when getOwners resolves.
// 2. Edit button opens the EditOwnerModal (B-1 closure).
// 3. Submitting the edit calls updateOwner with the right payload.
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getOwners: vi.fn(),
getTeams: vi.fn(),
createOwner: vi.fn(),
updateOwner: vi.fn(),
deleteOwner: vi.fn(),
}));
import OwnersPage from './OwnersPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const owner = {
id: 'o-platform',
name: 'Platform Team Lead',
email: 'platform@example.com',
team_id: 't-platform',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const team = { id: 't-platform', name: 'Platform', description: '' };
describe('OwnersPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getOwners).mockResolvedValue({ data: [owner], total: 1, page: 1, per_page: 50 } as never);
vi.mocked(client.getTeams).mockResolvedValue({ data: [team], total: 1, page: 1, per_page: 50 } as never);
vi.mocked(client.updateOwner).mockResolvedValue(owner as never);
});
it('renders the owners list when getOwners resolves', async () => {
renderWithQuery(<OwnersPage />);
await waitFor(() => {
expect(screen.getByText('Platform Team Lead')).toBeInTheDocument();
});
expect(screen.getByText('platform@example.com')).toBeInTheDocument();
});
it('Edit button opens the EditOwnerModal (B-1 closure)', async () => {
renderWithQuery(<OwnersPage />);
await waitFor(() => {
expect(screen.getByText('Platform Team Lead')).toBeInTheDocument();
});
const editBtn = await screen.findByRole('button', { name: 'Edit' });
fireEvent.click(editBtn);
await waitFor(() => {
expect(screen.getByText('Edit Owner')).toBeInTheDocument();
});
});
it('submitting the edit form calls updateOwner with the new payload', async () => {
renderWithQuery(<OwnersPage />);
await waitFor(() => {
expect(screen.getByText('Platform Team Lead')).toBeInTheDocument();
});
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }));
await waitFor(() => {
expect(screen.getByText('Edit Owner')).toBeInTheDocument();
});
const saveBtn = await screen.findByRole('button', { name: /Save Changes/ });
fireEvent.click(saveBtn);
await waitFor(() => {
expect(client.updateOwner).toHaveBeenCalled();
});
const [id, payload] = vi.mocked(client.updateOwner).mock.calls[0]!;
expect(id).toBe('o-platform');
expect(payload).toMatchObject({ name: 'Platform Team Lead', email: 'platform@example.com' });
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): PoliciesPage Vitest coverage.
//
// The page renders the D-006/D-008 TitleCase PolicyType + PolicySeverity
// contract. It owns the create / toggle-enabled / delete CRUD surface for
// pol-* compliance rules. This file pins:
//
// 1. Rule list renders when getPolicies resolves.
// 2. Severity badge is keyed on the TitleCase enum (Warning/Error/Critical).
// 3. Toggling enabled calls updatePolicy(id, { enabled: !current }).
// 4. Delete calls deletePolicy(id) when the confirm dialog returns true.
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getPolicies: vi.fn(),
createPolicy: vi.fn(),
updatePolicy: vi.fn(),
deletePolicy: vi.fn(),
}));
import PoliciesPage from './PoliciesPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const policyEnabled = {
id: 'pol-key-length',
name: 'Key Length Enforcement',
type: 'CertificateLifetime' as const,
severity: 'Critical' as const,
config: { min_bits: 2048 },
enabled: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const policyWarning = {
id: 'pol-allowed-issuers',
name: 'Approved CA Issuers',
type: 'AllowedIssuers' as const,
severity: 'Warning' as const,
config: { allowed: ['iss-letsencrypt'] },
enabled: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
describe('PoliciesPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getPolicies).mockResolvedValue({
data: [policyEnabled, policyWarning],
total: 2,
page: 1,
per_page: 50,
});
vi.mocked(client.updatePolicy).mockResolvedValue(policyEnabled);
vi.mocked(client.deletePolicy).mockResolvedValue({ message: 'deleted' });
});
it('renders the policy list when getPolicies resolves', async () => {
renderWithQuery(<PoliciesPage />);
await waitFor(() => {
expect(screen.getByText('Key Length Enforcement')).toBeInTheDocument();
});
expect(screen.getByText('Approved CA Issuers')).toBeInTheDocument();
});
it('renders the TitleCase severity (D-006/D-008 contract)', async () => {
renderWithQuery(<PoliciesPage />);
await waitFor(() => {
expect(screen.getByText('Key Length Enforcement')).toBeInTheDocument();
});
// Critical badge text appears in both the column cell and the severity
// count chip — at least one match. Pre-D-006 the severity dropdown was
// keyed on lowercase strings that never matched the backend's TitleCase
// enum; this assertion pins the post-D-006 contract.
await waitFor(() => {
expect(screen.getAllByText('Critical').length).toBeGreaterThan(0);
expect(screen.getAllByText('Warning').length).toBeGreaterThan(0);
});
});
it('toggling Enabled calls updatePolicy with the inverted enabled flag', async () => {
renderWithQuery(<PoliciesPage />);
await waitFor(() => expect(client.getPolicies).toHaveBeenCalled());
const enabledBtn = (await screen.findAllByRole('button', { name: /^Enabled$/ }))[0]!;
fireEvent.click(enabledBtn);
await waitFor(() => {
expect(client.updatePolicy).toHaveBeenCalledWith('pol-key-length', { enabled: false });
});
});
it('Delete calls deletePolicy(id) when confirm returns true', async () => {
const origConfirm = globalThis.confirm;
const confirmFn = vi.fn(() => true);
globalThis.confirm = confirmFn;
try {
renderWithQuery(<PoliciesPage />);
await waitFor(() => {
expect(screen.getByText('Key Length Enforcement')).toBeInTheDocument();
});
// Click the first row's Delete button (pol-key-length renders first).
// The button is rendered as a <button> with className text-red-*; query
// by accessible role + name. There are two rows so two Delete buttons.
const deleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
fireEvent.click(deleteButtons[0]!);
// The confirm prompt is fired synchronously inside the onClick. If the
// user-presented prompt returns true, the deletePolicy mutation fires.
await waitFor(() => {
expect(confirmFn).toHaveBeenCalled();
});
// The mutation invalidates the policies query on success; that's enough
// proof the delete path executed end-to-end. The exact id is the first
// row in the mocked dataset.
await waitFor(() => {
expect(client.deletePolicy).toHaveBeenCalled();
});
expect(vi.mocked(client.deletePolicy).mock.calls[0]?.[0]).toBe('pol-key-length');
} finally {
globalThis.confirm = origConfirm;
}
});
});
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): RenewalPoliciesPage Vitest coverage.
//
// Pins the B-1 closure that added this entire page from scratch (the
// `rp-*` records were CRUD-orphaned pre-B-1). Tests:
//
// 1. Renders the policy list when getRenewalPolicies resolves.
// 2. Create button opens the PolicyFormModal.
// 3. Edit button opens the PolicyFormModal pre-populated for an edit.
// 4. Delete confirm flow surfaces ErrRenewalPolicyInUse 409 via alert().
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getRenewalPolicies: vi.fn(),
createRenewalPolicy: vi.fn(),
updateRenewalPolicy: vi.fn(),
deleteRenewalPolicy: vi.fn(),
}));
import RenewalPoliciesPage from './RenewalPoliciesPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const policy = {
id: 'rp-standard-30d',
name: 'Standard 30-day',
renewal_window_days: 30,
auto_renew: true,
max_retries: 3,
retry_interval_seconds: 600,
alert_thresholds_days: [30, 14, 7, 0],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
describe('RenewalPoliciesPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getRenewalPolicies).mockResolvedValue({
data: [policy],
total: 1,
page: 1,
per_page: 50,
});
});
it('renders the renewal policies list when getRenewalPolicies resolves', async () => {
renderWithQuery(<RenewalPoliciesPage />);
await waitFor(() => {
expect(screen.getByText('Standard 30-day')).toBeInTheDocument();
});
// alert_thresholds_days renders comma-separated.
expect(screen.getByText('30, 14, 7, 0')).toBeInTheDocument();
});
it('Create button opens the PolicyFormModal in create mode', async () => {
renderWithQuery(<RenewalPoliciesPage />);
await waitFor(() => {
expect(screen.getByText('Standard 30-day')).toBeInTheDocument();
});
fireEvent.click(await screen.findByRole('button', { name: /\+ New Policy/ }));
await waitFor(() => {
expect(screen.getByText('Create Renewal Policy')).toBeInTheDocument();
});
});
it('Edit button opens the PolicyFormModal in edit mode (B-1 closure)', async () => {
renderWithQuery(<RenewalPoliciesPage />);
await waitFor(() => {
expect(screen.getByText('Standard 30-day')).toBeInTheDocument();
});
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }));
await waitFor(() => {
expect(screen.getByText('Edit Renewal Policy')).toBeInTheDocument();
});
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): TargetsPage Vitest coverage.
//
// Pins:
// 1. Targets list renders when getTargets resolves.
// 2. Status column derives from `enabled` (D-2 phantom-field trim).
// 3. Connection column reads test_status (D-2 contract).
// 4. Delete confirm flow calls deleteTarget(id).
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getTargets: vi.fn(),
createTarget: vi.fn(),
deleteTarget: vi.fn(),
getAgents: vi.fn(),
}));
import TargetsPage from './TargetsPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const targetEnabled = {
id: 'tgt-iis-prod',
name: 'IIS Web01',
type: 'iis',
agent_id: 'agent-iis01',
enabled: true,
test_status: 'success',
config: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const targetUntested = {
id: 'tgt-untested',
name: 'New Target',
type: 'kubernetes',
agent_id: '',
enabled: false,
test_status: 'untested',
config: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
describe('TargetsPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getTargets).mockResolvedValue({
data: [targetEnabled, targetUntested],
total: 2,
page: 1,
per_page: 50,
});
vi.mocked(client.getAgents).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 });
vi.mocked(client.deleteTarget).mockResolvedValue({ message: 'deleted' });
});
it('renders the targets list when getTargets resolves', async () => {
renderWithQuery(<TargetsPage />);
await waitFor(() => {
expect(screen.getByText('IIS Web01')).toBeInTheDocument();
});
expect(screen.getByText('New Target')).toBeInTheDocument();
});
it('derives the Status column from enabled (D-2 phantom-field trim)', async () => {
renderWithQuery(<TargetsPage />);
await waitFor(() => {
expect(screen.getByText('IIS Web01')).toBeInTheDocument();
});
// Pre-D-2 the column read a phantom `status` field; post-D-2 it derives
// 'Enabled' / 'Disabled' purely from the boolean.
expect(screen.getAllByText(/Enabled/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Disabled/).length).toBeGreaterThan(0);
});
it('Delete confirm flow calls deleteTarget(id)', async () => {
const origConfirm = globalThis.confirm;
globalThis.confirm = vi.fn(() => true);
try {
renderWithQuery(<TargetsPage />);
await waitFor(() => {
expect(screen.getByText('IIS Web01')).toBeInTheDocument();
});
const deleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
fireEvent.click(deleteButtons[0]!);
await waitFor(() => {
expect(client.deleteTarget).toHaveBeenCalled();
});
expect(vi.mocked(client.deleteTarget).mock.calls[0]?.[0]).toBe('tgt-iis-prod');
} finally {
globalThis.confirm = origConfirm;
}
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// -----------------------------------------------------------------------------
// T-1 closure (cat-s2-c24a548076c6): TeamsPage Vitest coverage.
//
// Pins the B-1 closure: Edit button opens EditTeamModal which calls
// updateTeam(id, payload). Mirrors the OwnersPage pattern.
// -----------------------------------------------------------------------------
vi.mock('../api/client', () => ({
getTeams: vi.fn(),
createTeam: vi.fn(),
updateTeam: vi.fn(),
deleteTeam: vi.fn(),
}));
import TeamsPage from './TeamsPage';
import * as client from '../api/client';
function renderWithQuery(ui: ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
});
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
const team = {
id: 't-platform',
name: 'Platform',
description: 'Core infra team',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
describe('TeamsPage — T-1 page coverage', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
vi.mocked(client.getTeams).mockResolvedValue({
data: [team],
total: 1,
page: 1,
per_page: 50,
});
vi.mocked(client.updateTeam).mockResolvedValue(team);
});
it('renders the teams list when getTeams resolves', async () => {
renderWithQuery(<TeamsPage />);
await waitFor(() => {
expect(screen.getByText('Platform')).toBeInTheDocument();
});
});
it('Edit + Save calls updateTeam with the right payload (B-1 closure)', async () => {
renderWithQuery(<TeamsPage />);
await waitFor(() => {
expect(screen.getByText('Platform')).toBeInTheDocument();
});
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }));
await waitFor(() => {
expect(screen.getByText('Edit Team')).toBeInTheDocument();
});
fireEvent.click(await screen.findByRole('button', { name: /Save Changes/ }));
await waitFor(() => {
expect(client.updateTeam).toHaveBeenCalled();
});
const [id, payload] = vi.mocked(client.updateTeam).mock.calls[0]!;
expect(id).toBe('t-platform');
expect(payload).toMatchObject({ name: 'Platform' });
});
});