From 4c8294548489a490e6753643ff7a99838992d939 Mon Sep 17 00:00:00 2001 From: Mariusz Banach Date: Wed, 18 Feb 2026 02:46:20 +0100 Subject: [PATCH] MAESTRO: add report component tests --- ...er-analyzer-Phase-06-Interactive-Report.md | 60 +++++++ .../report/HopChainVisualisation.test.tsx | 93 ++++++++++ .../__tests__/report/ReportContainer.test.tsx | 163 ++++++++++++++++++ .../__tests__/report/ReportExport.test.tsx | 147 ++++++++++++++++ .../__tests__/report/ReportSearchBar.test.tsx | 161 +++++++++++++++++ .../report/SecurityAppliancesSummary.test.tsx | 84 +++++++++ .../__tests__/report/TestResultCard.test.tsx | 133 ++++++++++++++ 7 files changed, 841 insertions(+) create mode 100644 Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md create mode 100644 frontend/src/__tests__/report/HopChainVisualisation.test.tsx create mode 100644 frontend/src/__tests__/report/ReportContainer.test.tsx create mode 100644 frontend/src/__tests__/report/ReportExport.test.tsx create mode 100644 frontend/src/__tests__/report/ReportSearchBar.test.tsx create mode 100644 frontend/src/__tests__/report/SecurityAppliancesSummary.test.tsx create mode 100644 frontend/src/__tests__/report/TestResultCard.test.tsx diff --git a/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md new file mode 100644 index 0000000..7d30165 --- /dev/null +++ b/Auto Run Docs/SpecKit-web-header-analyzer-Phase-06-Interactive-Report.md @@ -0,0 +1,60 @@ +# Phase 06: US4 — Interactive Report Rendering + +This phase implements the full interactive report: collapsible test result cards with colour-coded severity, hop chain flow visualisation, security appliances summary, search/filter bar, and export to HTML/JSON. By the end of this phase, users receive a complete, interactive analysis report. TDD Red-Green: write all failing component tests first, then implement each component. + +## Spec Kit Context + +- **Feature:** 1-web-header-analyzer +- **Specification:** .specify/specs/1-web-header-analyzer/spec.md (FR-07, FR-08, FR-09, FR-20, FR-21, FR-25) +- **Plan:** .specify/specs/1-web-header-analyzer/plan.md +- **Tasks:** .specify/specs/1-web-header-analyzer/tasks.md +- **Data Model:** .specify/specs/1-web-header-analyzer/data-model.md (TestResult, HopChainNode, SecurityAppliance, AnalysisReport) +- **User Story:** US4 — Interactive Report Rendering (Scenario 1, steps 7–8) +- **Constitution:** .specify/memory/constitution.md (TDD: P6, UX: P7) + +## Dependencies + +- **Requires Phase 05** completed (analysis results available to render) + +## Severity Colour Mapping + +- **Spam:** `#ff5555` (red) — FontAwesome warning/ban icon +- **Suspicious:** `#ffb86c` (amber) — FontAwesome exclamation icon +- **Clean:** `#50fa7b` (green) — FontAwesome check icon +- **Info:** `#bd93f9` (purple) — FontAwesome info icon +- **Error:** error indicator with failure explanation (FR-25) + +## Component Architecture + +``` +ReportContainer +├── Summary Stats (total tests, passed, failed, severity breakdown) +├── ReportSearchBar (filter by text match) +├── ReportExport (HTML / JSON download buttons) +├── SecurityAppliancesSummary (detected security products as badges) +├── HopChainVisualisation (vertical flow diagram of mail server hops) +└── TestResultCard[] (one per test result, collapsible, severity-coloured) +``` + +## Tasks + +- [x] T030 [US4] Write failing tests (TDD Red) in `frontend/src/__tests__/report/TestResultCard.test.tsx` (each severity level, expand/collapse, error indicator), `HopChainVisualisation.test.tsx` (render with sample hops), `ReportSearchBar.test.tsx` (filter simulation), `ReportExport.test.tsx` (export trigger), `SecurityAppliancesSummary.test.tsx` (render with sample appliances, empty state), `ReportContainer.test.tsx` (full report with mixed results) +- [ ] T031 [P] [US4] Create `frontend/src/components/report/TestResultCard.tsx` — collapsible card per test result. Severity-coloured indicator (red=spam, amber=suspicious, green=clean per FR-09), header name, monospace value, analysis text. Failed tests show error indicator (FR-25). Expand/collapse with animation, keyboard accessible (NFR-02). Verify `TestResultCard.test.tsx` passes (TDD Green) +- [ ] T032 [P] [US4] Create `frontend/src/components/report/HopChainVisualisation.tsx` — vertical flow diagram of mail server hop chain (FR-08): hostname, IP, timestamp, server version, connecting arrows. FontAwesome server/network icons. Responsive. Verify `HopChainVisualisation.test.tsx` passes (TDD Green) +- [ ] T033 [P] [US4] Create `frontend/src/components/report/ReportSearchBar.tsx` — search/filter bar above report (FR-20). Filters by text match against test name, header name, or analysis text. Highlights matches, shows count. FontAwesome search icon, Escape to clear. Verify `ReportSearchBar.test.tsx` passes (TDD Green) +- [ ] T034 [P] [US4] Create `frontend/src/components/report/ReportExport.tsx` — export as HTML (styled standalone page) or JSON (raw data) per FR-21. FontAwesome download icons, triggers browser download. Verify `ReportExport.test.tsx` passes (TDD Green) +- [ ] T035 [US4] Create `frontend/src/components/report/SecurityAppliancesSummary.tsx` — summary listing detected email security products as badges/tags with FontAwesome shield icons. Handle empty state (no appliances detected). Verify `SecurityAppliancesSummary.test.tsx` passes (TDD Green) +- [ ] T036 [US4] Create `frontend/src/components/report/ReportContainer.tsx` — top-level wrapper receiving `AnalysisReport`. Renders: summary stats (total tests, passed, failed, severity breakdown), `TestResultCard` list, `HopChainVisualisation`, `SecurityAppliancesSummary`, `ReportSearchBar`, `ReportExport`. FontAwesome summary icons. Verify `ReportContainer.test.tsx` passes (TDD Green) + +## Completion + +- [ ] All vitest tests pass: `npx vitest run src/__tests__/report/` +- [ ] Report renders all test results as collapsible cards with correct severity colours +- [ ] Hop chain displays as a vertical visual flow with server details and connecting arrows +- [ ] Security appliances show as badges; empty state handled gracefully +- [ ] Search filters results in real-time across test name, header name, and analysis text +- [ ] Export JSON produces a valid JSON file containing all results +- [ ] Export HTML produces a styled standalone page viewable in any browser +- [ ] All report components are keyboard accessible +- [ ] Linting passes (`npx eslint src/`, `npx prettier --check src/`) +- [ ] Run `/speckit.analyze` to verify consistency diff --git a/frontend/src/__tests__/report/HopChainVisualisation.test.tsx b/frontend/src/__tests__/report/HopChainVisualisation.test.tsx new file mode 100644 index 0000000..3790ad1 --- /dev/null +++ b/frontend/src/__tests__/report/HopChainVisualisation.test.tsx @@ -0,0 +1,93 @@ +import type { ReactElement } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import HopChainVisualisation from "../../components/report/HopChainVisualisation"; +import type { HopChainNode } from "../../types/analysis"; + +type RenderResult = { + container: HTMLDivElement; +}; + +const cleanups: Array<() => void> = []; + +const render = (ui: ReactElement): RenderResult => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(ui); + }); + + cleanups.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + return { container }; +}; + +const getByTestId = (container: HTMLElement, testId: string): HTMLElement => { + const element = container.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`Expected element ${testId} to be rendered.`); + } + return element as HTMLElement; +}; + +const hopChain: HopChainNode[] = [ + { + index: 0, + hostname: "mail.sender.example", + ip: "192.0.2.10", + timestamp: "2026-02-17 10:00:01", + serverInfo: "Postfix", + }, + { + index: 1, + hostname: "mx.receiver.example", + ip: "203.0.113.5", + timestamp: "2026-02-17 10:00:04", + serverInfo: "Exchange 2019", + }, +]; + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } +}); + +describe("HopChainVisualisation", () => { + it("renders hop nodes with server details", () => { + const { container } = render(); + + const root = getByTestId(container, "hop-chain-visualisation"); + expect(root).toBeTruthy(); + + const firstHop = getByTestId(container, "hop-chain-node-0"); + expect(firstHop.textContent ?? "").toContain("mail.sender.example"); + expect(firstHop.textContent ?? "").toContain("192.0.2.10"); + + const secondHop = getByTestId(container, "hop-chain-node-1"); + expect(secondHop.textContent ?? "").toContain("mx.receiver.example"); + expect(secondHop.textContent ?? "").toContain("203.0.113.5"); + }); + + it("renders connectors between hop nodes", () => { + const { container } = render(); + + const connectors = container.querySelectorAll( + '[data-testid^="hop-chain-connector-"]', + ); + + expect(connectors.length).toBe(hopChain.length - 1); + }); +}); diff --git a/frontend/src/__tests__/report/ReportContainer.test.tsx b/frontend/src/__tests__/report/ReportContainer.test.tsx new file mode 100644 index 0000000..6a2f402 --- /dev/null +++ b/frontend/src/__tests__/report/ReportContainer.test.tsx @@ -0,0 +1,163 @@ +import type { ReactElement } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import ReportContainer from "../../components/report/ReportContainer"; +import type { AnalysisReport } from "../../types/analysis"; + +type RenderResult = { + container: HTMLDivElement; +}; + +const cleanups: Array<() => void> = []; + +const render = (ui: ReactElement): RenderResult => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(ui); + }); + + cleanups.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + return { container }; +}; + +const getByTestId = (container: HTMLElement, testId: string): HTMLElement => { + const element = container.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`Expected element ${testId} to be rendered.`); + } + return element as HTMLElement; +}; + +const report: AnalysisReport = { + results: [ + { + testId: 101, + testName: "SpamAssassin Rule Hits", + headerName: "X-Spam-Flag", + headerValue: "YES", + analysis: "Flagged by local rules.", + description: "SpamAssassin rules matched.", + severity: "spam", + status: "error", + error: "Timeout", + }, + { + testId: 202, + testName: "Mimecast Fingerprint", + headerName: "X-Mimecast-Spam-Info", + headerValue: "none", + analysis: "No fingerprint detected.", + description: "No known fingerprint found.", + severity: "clean", + status: "success", + error: null, + }, + { + testId: 303, + testName: "Barracuda Reputation", + headerName: "X-Barracuda", + headerValue: "neutral", + analysis: "Reputation check pending.", + description: "Awaiting response.", + severity: "suspicious", + status: "success", + error: null, + }, + { + testId: 404, + testName: "Custom Header Check", + headerName: "X-Custom", + headerValue: "ok", + analysis: "Custom info.", + description: "Informational.", + severity: "info", + status: "success", + error: null, + }, + ], + hopChain: [ + { + index: 0, + hostname: "mail.sender.example", + ip: "192.0.2.10", + timestamp: "2026-02-17 10:00:01", + serverInfo: "Postfix", + }, + { + index: 1, + hostname: "mx.receiver.example", + ip: "203.0.113.5", + timestamp: "2026-02-17 10:00:04", + serverInfo: "Exchange 2019", + }, + ], + securityAppliances: [ + { + name: "Mimecast Email Security", + vendor: "Mimecast", + headers: ["X-Mimecast-Spam-Info"], + }, + ], + metadata: { + totalTests: 4, + passedTests: 3, + failedTests: 1, + skippedTests: 0, + elapsedMs: 4200, + timedOut: false, + incompleteTests: [], + }, +}; + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } +}); + +describe("ReportContainer", () => { + it("renders the full report with summary stats and sections", () => { + const { container } = render(); + + getByTestId(container, "report-container"); + + const total = getByTestId(container, "report-summary-total"); + const passed = getByTestId(container, "report-summary-passed"); + const failed = getByTestId(container, "report-summary-failed"); + const spam = getByTestId(container, "report-summary-severity-spam"); + const suspicious = getByTestId(container, "report-summary-severity-suspicious"); + const clean = getByTestId(container, "report-summary-severity-clean"); + const info = getByTestId(container, "report-summary-severity-info"); + + expect(total.textContent ?? "").toContain("4"); + expect(passed.textContent ?? "").toContain("3"); + expect(failed.textContent ?? "").toContain("1"); + expect(spam.textContent ?? "").toContain("1"); + expect(suspicious.textContent ?? "").toContain("1"); + expect(clean.textContent ?? "").toContain("1"); + expect(info.textContent ?? "").toContain("1"); + + getByTestId(container, "report-search-bar"); + getByTestId(container, "report-export"); + getByTestId(container, "security-appliances-summary"); + getByTestId(container, "hop-chain-visualisation"); + + report.results.forEach((result) => { + getByTestId(container, `test-result-card-${result.testId}`); + }); + }); +}); diff --git a/frontend/src/__tests__/report/ReportExport.test.tsx b/frontend/src/__tests__/report/ReportExport.test.tsx new file mode 100644 index 0000000..d317f94 --- /dev/null +++ b/frontend/src/__tests__/report/ReportExport.test.tsx @@ -0,0 +1,147 @@ +import type { ReactElement } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ReportExport from "../../components/report/ReportExport"; +import type { AnalysisReport } from "../../types/analysis"; + +type RenderResult = { + container: HTMLDivElement; +}; + +type UrlStatics = { + createObjectURL?: (blob: Blob) => string; + revokeObjectURL?: (url: string) => void; +}; + +const cleanups: Array<() => void> = []; + +const render = (ui: ReactElement): RenderResult => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(ui); + }); + + cleanups.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + return { container }; +}; + +const getByTestId = (container: HTMLElement, testId: string): HTMLElement => { + const element = container.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`Expected element ${testId} to be rendered.`); + } + return element as HTMLElement; +}; + +const report: AnalysisReport = { + results: [ + { + testId: 101, + testName: "SpamAssassin Rule Hits", + headerName: "X-Spam-Flag", + headerValue: "YES", + analysis: "Flagged by local rules.", + description: "SpamAssassin rules matched during analysis.", + severity: "spam", + status: "error", + error: "Timeout", + }, + ], + hopChain: [], + securityAppliances: [], + metadata: { + totalTests: 1, + passedTests: 0, + failedTests: 1, + skippedTests: 0, + elapsedMs: 1200, + timedOut: false, + incompleteTests: [], + }, +}; + +let createObjectUrlSpy: ReturnType | null = null; +let revokeObjectUrlSpy: ReturnType | null = null; +let clickSpy: ReturnType | null = null; + +const originalUrlStatics: UrlStatics = { + createObjectURL: (URL as unknown as UrlStatics).createObjectURL, + revokeObjectURL: (URL as unknown as UrlStatics).revokeObjectURL, +}; + +beforeEach(() => { + const urlStatics = URL as unknown as UrlStatics; + + if (!urlStatics.createObjectURL) { + urlStatics.createObjectURL = () => "blob:report"; + } + if (!urlStatics.revokeObjectURL) { + urlStatics.revokeObjectURL = () => undefined; + } + + createObjectUrlSpy = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:report"); + revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => { + return undefined; + }); + clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => undefined); +}); + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } + + createObjectUrlSpy?.mockRestore(); + revokeObjectUrlSpy?.mockRestore(); + clickSpy?.mockRestore(); + + const urlStatics = URL as unknown as UrlStatics; + urlStatics.createObjectURL = originalUrlStatics.createObjectURL; + urlStatics.revokeObjectURL = originalUrlStatics.revokeObjectURL; +}); + +describe("ReportExport", () => { + it("triggers a JSON download", () => { + const { container } = render(); + + const jsonButton = getByTestId(container, "report-export-json"); + + act(() => { + jsonButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(createObjectUrlSpy).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + }); + + it("triggers an HTML download", () => { + const { container } = render(); + + const htmlButton = getByTestId(container, "report-export-html"); + + act(() => { + htmlButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(createObjectUrlSpy).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/report/ReportSearchBar.test.tsx b/frontend/src/__tests__/report/ReportSearchBar.test.tsx new file mode 100644 index 0000000..01ee0ec --- /dev/null +++ b/frontend/src/__tests__/report/ReportSearchBar.test.tsx @@ -0,0 +1,161 @@ +import type { ReactElement } from "react"; +import { useState } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import ReportSearchBar from "../../components/report/ReportSearchBar"; +import type { TestResult } from "../../types/analysis"; + +type RenderResult = { + container: HTMLDivElement; +}; + +const cleanups: Array<() => void> = []; + +const render = (ui: ReactElement): RenderResult => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(ui); + }); + + cleanups.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + return { container }; +}; + +const getByTestId = (container: HTMLElement, testId: string): HTMLElement => { + const element = container.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`Expected element ${testId} to be rendered.`); + } + return element as HTMLElement; +}; + +const sampleResults: TestResult[] = [ + { + testId: 101, + testName: "SpamAssassin Rule Hits", + headerName: "X-Spam-Flag", + headerValue: "YES", + analysis: "Flagged by local rules.", + description: "SpamAssassin rules matched.", + severity: "spam", + status: "error", + error: "Timeout", + }, + { + testId: 202, + testName: "Mimecast Fingerprint", + headerName: "X-Mimecast-Spam-Info", + headerValue: "none", + analysis: "No fingerprint detected.", + description: "No known fingerprint found.", + severity: "clean", + status: "success", + error: null, + }, + { + testId: 303, + testName: "Barracuda Reputation", + headerName: "X-Barracuda", + headerValue: "neutral", + analysis: "Reputation check pending.", + description: "Awaiting response.", + severity: "suspicious", + status: "success", + error: null, + }, +]; + +const filterResults = (query: string, results: TestResult[]): TestResult[] => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return results; + } + return results.filter((result) => { + return ( + result.testName.toLowerCase().includes(normalizedQuery) || + result.headerName.toLowerCase().includes(normalizedQuery) || + result.analysis.toLowerCase().includes(normalizedQuery) + ); + }); +}; + +const ReportSearchHarness = () => { + const [query, setQuery] = useState(""); + const filtered = filterResults(query, sampleResults); + + return ( +
+ +
    + {filtered.map((result) => ( +
  • {result.testName}
  • + ))} +
+
+ ); +}; + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } +}); + +describe("ReportSearchBar", () => { + it("filters results when the search query changes", () => { + const { container } = render(); + + const input = getByTestId(container, "report-search-input") as HTMLInputElement; + + act(() => { + input.value = "mime"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const filteredList = getByTestId(container, "filtered-results"); + expect(filteredList.textContent ?? "").toContain("Mimecast Fingerprint"); + expect(filteredList.textContent ?? "").not.toContain("SpamAssassin Rule Hits"); + + const count = getByTestId(container, "report-search-count"); + expect(count.textContent ?? "").toMatch(/1/); + }); + + it("clears the query on Escape", () => { + const { container } = render(); + + const input = getByTestId(container, "report-search-input") as HTMLInputElement; + + act(() => { + input.value = "spam"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + + expect(input.value).toBe(""); + const filteredList = getByTestId(container, "filtered-results"); + expect(filteredList.textContent ?? "").toContain("SpamAssassin Rule Hits"); + expect(filteredList.textContent ?? "").toContain("Mimecast Fingerprint"); + }); +}); diff --git a/frontend/src/__tests__/report/SecurityAppliancesSummary.test.tsx b/frontend/src/__tests__/report/SecurityAppliancesSummary.test.tsx new file mode 100644 index 0000000..e6ddd00 --- /dev/null +++ b/frontend/src/__tests__/report/SecurityAppliancesSummary.test.tsx @@ -0,0 +1,84 @@ +import type { ReactElement } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import SecurityAppliancesSummary from "../../components/report/SecurityAppliancesSummary"; +import type { SecurityAppliance } from "../../types/analysis"; + +type RenderResult = { + container: HTMLDivElement; +}; + +const cleanups: Array<() => void> = []; + +const render = (ui: ReactElement): RenderResult => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(ui); + }); + + cleanups.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + return { container }; +}; + +const getByTestId = (container: HTMLElement, testId: string): HTMLElement => { + const element = container.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`Expected element ${testId} to be rendered.`); + } + return element as HTMLElement; +}; + +const sampleAppliances: SecurityAppliance[] = [ + { + name: "Mimecast Email Security", + vendor: "Mimecast", + headers: ["X-Mimecast-Spam-Info"], + }, + { + name: "Proofpoint Protection", + vendor: "Proofpoint", + headers: ["X-Proofpoint-Verdict"], + }, +]; + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } +}); + +describe("SecurityAppliancesSummary", () => { + it("renders detected security appliances as badges", () => { + const { container } = render( + , + ); + + const summary = getByTestId(container, "security-appliances-summary"); + expect(summary.textContent ?? "").toContain("Mimecast Email Security"); + expect(summary.textContent ?? "").toContain("Proofpoint Protection"); + + const firstBadge = getByTestId(container, "security-appliance-0"); + expect(firstBadge.textContent ?? "").toContain("Mimecast"); + }); + + it("renders an empty state when no appliances are detected", () => { + const { container } = render(); + + const emptyState = getByTestId(container, "security-appliances-empty"); + expect(emptyState.textContent ?? "").toMatch(/No security appliances detected/i); + }); +}); diff --git a/frontend/src/__tests__/report/TestResultCard.test.tsx b/frontend/src/__tests__/report/TestResultCard.test.tsx new file mode 100644 index 0000000..c5f8ae5 --- /dev/null +++ b/frontend/src/__tests__/report/TestResultCard.test.tsx @@ -0,0 +1,133 @@ +import type { ReactElement } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import TestResultCard from "../../components/report/TestResultCard"; +import type { TestResult, TestSeverity } from "../../types/analysis"; + +type RenderResult = { + container: HTMLDivElement; +}; + +const cleanups: Array<() => void> = []; + +const render = (ui: ReactElement): RenderResult => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(ui); + }); + + cleanups.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + return { container }; +}; + +const getByTestId = (container: HTMLElement, testId: string): HTMLElement => { + const element = container.querySelector(`[data-testid="${testId}"]`); + if (!element) { + throw new Error(`Expected element ${testId} to be rendered.`); + } + return element as HTMLElement; +}; + +const buildResult = (overrides: Partial): TestResult => ({ + testId: 101, + testName: "SpamAssassin Rule Hits", + headerName: "X-Spam-Flag", + headerValue: "YES", + analysis: "Flagged by local rules.", + description: "SpamAssassin rules matched during analysis.", + severity: "spam", + status: "success", + error: null, + ...overrides, +}); + +afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + if (cleanup) { + cleanup(); + } + } +}); + +describe("TestResultCard", () => { + it("renders severity labels with mapped styles", () => { + const severityCases: Array<{ + severity: TestSeverity; + label: string; + className: string; + }> = [ + { severity: "spam", label: "Spam", className: "text-spam" }, + { severity: "suspicious", label: "Suspicious", className: "text-suspicious" }, + { severity: "clean", label: "Clean", className: "text-clean" }, + { severity: "info", label: "Info", className: "text-accent" }, + ]; + + severityCases.forEach((severityCase, index) => { + const result = buildResult({ + testId: 200 + index, + testName: `Severity ${severityCase.label}`, + severity: severityCase.severity, + }); + + const { container } = render(); + + const severityBadge = getByTestId( + container, + `test-result-severity-${result.testId}`, + ); + + expect(severityBadge.textContent ?? "").toContain(severityCase.label); + expect(severityBadge.className).toContain(severityCase.className); + }); + }); + + it("expands and collapses on click and keyboard", () => { + const result = buildResult({ testId: 311, testName: "Header Anomaly" }); + const { container } = render(); + + const toggle = getByTestId(container, `test-result-toggle-${result.testId}`); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + + act(() => { + toggle.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + + act(() => { + toggle.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + + act(() => { + toggle.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true })); + }); + + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + }); + + it("shows error indicators for failed tests", () => { + const result = buildResult({ + testId: 404, + status: "error", + error: "SpamAssassin database timeout.", + }); + const { container } = render(); + + const errorIndicator = getByTestId(container, `test-result-error-${result.testId}`); + expect(errorIndicator.textContent ?? "").toMatch(/SpamAssassin database timeout/); + }); +});