mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-21 21:13:31 +01:00
MAESTRO: add browser cache tests
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Phase 07: US5 — Browser Caching
|
||||
|
||||
This phase implements browser-side persistence using localStorage so that previously submitted headers and analysis results survive page reloads. Users can view cached results immediately on return visits and explicitly clear the cache. TDD Red-Green: write failing tests first, then implement the cache hook and page integration.
|
||||
|
||||
## Spec Kit Context
|
||||
|
||||
- **Feature:** 1-web-header-analyzer
|
||||
- **Specification:** .specify/specs/1-web-header-analyzer/spec.md (FR-12, FR-13)
|
||||
- **Plan:** .specify/specs/1-web-header-analyzer/plan.md
|
||||
- **Tasks:** .specify/specs/1-web-header-analyzer/tasks.md
|
||||
- **User Story:** US5 — Browser Caching (Scenario 4)
|
||||
- **Constitution:** .specify/memory/constitution.md (TDD: P6)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Requires Phase 03** completed (HeaderInput for headers text)
|
||||
- **Requires Phase 06** completed (ReportContainer for cached report display)
|
||||
|
||||
## localStorage Key Namespace
|
||||
|
||||
Use a consistent prefix to avoid collisions:
|
||||
- `wha:headers` — last submitted header text
|
||||
- `wha:config` — last analysis config (selected tests, toggles)
|
||||
- `wha:result` — last analysis result (full AnalysisReport JSON)
|
||||
- `wha:timestamp` — when the cache was last written
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] T037 [US5] Write failing tests (TDD Red) in `frontend/src/__tests__/useAnalysisCache.test.ts` (save/load/clear with mocked localStorage, size awareness) and `frontend/src/__tests__/page.test.tsx` (cache restoration on mount, clear cache button)
|
||||
- [ ] T038 [P] [US5] Create `frontend/src/hooks/useAnalysisCache.ts` — hook managing localStorage with namespaced keys. Saves: headers text, analysis config, analysis result. Size-aware (warns near limits). Methods: `save()`, `load()`, `clear()`, `hasCachedData()`. Verify `useAnalysisCache.test.ts` passes (TDD Green)
|
||||
- [ ] T039 [US5] Integrate caching into `frontend/src/app/page.tsx` — on mount restore cached data, render cached report, "Clear Cache" button with FontAwesome trash icon (FR-13), subtle indicator when viewing cached results. Verify `page.test.tsx` passes (TDD Green)
|
||||
|
||||
## Completion
|
||||
|
||||
- [ ] All vitest tests pass: `npx vitest run src/__tests__/useAnalysisCache.test.ts src/__tests__/page.test.tsx`
|
||||
- [ ] After analysis, headers and results are saved to localStorage
|
||||
- [ ] Page refresh restores the previous analysis (headers in input, report rendered)
|
||||
- [ ] "Clear Cache" button removes all stored data and resets the view to empty state
|
||||
- [ ] Subtle indicator distinguishes cached results from fresh analysis
|
||||
- [ ] Size-awareness warns if localStorage is near capacity
|
||||
- [ ] Linting passes (`npx eslint src/`, `npx prettier --check src/`)
|
||||
- [ ] Run `/speckit.analyze` to verify consistency
|
||||
169
frontend/src/__tests__/page.test.tsx
Normal file
169
frontend/src/__tests__/page.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
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 Home from "../app/page";
|
||||
import type { AnalysisConfig, AnalysisReport } from "../types/analysis";
|
||||
|
||||
const { submitSpy, cancelSpy, useAnalysisState } = vi.hoisted(() => {
|
||||
const submitSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const cancelSpy = vi.fn();
|
||||
|
||||
return {
|
||||
submitSpy,
|
||||
cancelSpy,
|
||||
useAnalysisState: {
|
||||
status: "idle",
|
||||
progress: null,
|
||||
result: null,
|
||||
error: null,
|
||||
submit: submitSpy,
|
||||
cancel: cancelSpy,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../hooks/useAnalysis", () => ({
|
||||
__esModule: true,
|
||||
default: () => useAnalysisState,
|
||||
}));
|
||||
|
||||
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 getTextarea = (container: HTMLElement): HTMLTextAreaElement => {
|
||||
const textarea = container.querySelector("textarea");
|
||||
if (!textarea) {
|
||||
throw new Error("Expected header textarea to be rendered.");
|
||||
}
|
||||
return textarea as HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
const getClearCacheButton = (container: HTMLElement): HTMLButtonElement => {
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const button = buttons.find((candidate) =>
|
||||
(candidate.textContent ?? "").toLowerCase().includes("clear cache"),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error("Expected clear cache button to be rendered.");
|
||||
}
|
||||
return button as HTMLButtonElement;
|
||||
};
|
||||
|
||||
const cachedConfig: AnalysisConfig = {
|
||||
testIds: [101, 202],
|
||||
resolve: false,
|
||||
decodeAll: false,
|
||||
};
|
||||
|
||||
const cachedReport: AnalysisReport = {
|
||||
results: [
|
||||
{
|
||||
testId: 101,
|
||||
testName: "SpamAssassin Rule Hits",
|
||||
headerName: "X-Spam-Status",
|
||||
headerValue: "Yes",
|
||||
analysis: "Hits: 6.2",
|
||||
description: "Spam scoring summary.",
|
||||
severity: "spam",
|
||||
status: "success",
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
hopChain: [],
|
||||
securityAppliances: [],
|
||||
metadata: {
|
||||
totalTests: 1,
|
||||
passedTests: 1,
|
||||
failedTests: 0,
|
||||
skippedTests: 0,
|
||||
elapsedMs: 1200,
|
||||
timedOut: false,
|
||||
incompleteTests: [],
|
||||
},
|
||||
};
|
||||
|
||||
const seedCache = (headers: string) => {
|
||||
localStorage.setItem("wha:headers", headers);
|
||||
localStorage.setItem("wha:config", JSON.stringify(cachedConfig));
|
||||
localStorage.setItem("wha:result", JSON.stringify(cachedReport));
|
||||
localStorage.setItem("wha:timestamp", "1700000000000");
|
||||
};
|
||||
|
||||
const resetUseAnalysisState = (): void => {
|
||||
useAnalysisState.status = "idle";
|
||||
useAnalysisState.progress = null;
|
||||
useAnalysisState.result = null;
|
||||
useAnalysisState.error = null;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetUseAnalysisState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length > 0) {
|
||||
const cleanup = cleanups.pop();
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
submitSpy.mockClear();
|
||||
cancelSpy.mockClear();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("Home page cache", () => {
|
||||
it("restores cached analysis on mount", () => {
|
||||
seedCache("Received: from mail.example.com");
|
||||
|
||||
const { container } = render(<Home />);
|
||||
const textarea = getTextarea(container);
|
||||
|
||||
expect(textarea.value).toBe("Received: from mail.example.com");
|
||||
const report = container.querySelector('[data-testid="report-container"]');
|
||||
expect(report).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clears cached data and resets the view", () => {
|
||||
seedCache("Received: from mail.example.com");
|
||||
|
||||
const { container } = render(<Home />);
|
||||
const button = getClearCacheButton(container);
|
||||
|
||||
act(() => {
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("wha:headers")).toBeNull();
|
||||
expect(localStorage.getItem("wha:config")).toBeNull();
|
||||
expect(localStorage.getItem("wha:result")).toBeNull();
|
||||
expect(localStorage.getItem("wha:timestamp")).toBeNull();
|
||||
expect(getTextarea(container).value).toBe("");
|
||||
expect(container.querySelector('[data-testid="report-container"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
204
frontend/src/__tests__/useAnalysisCache.test.ts
Normal file
204
frontend/src/__tests__/useAnalysisCache.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { useState } from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { AnalysisConfig, AnalysisReport } from "../types/analysis";
|
||||
import useAnalysisCache from "../hooks/useAnalysisCache";
|
||||
|
||||
type RenderResult = {
|
||||
container: HTMLDivElement;
|
||||
};
|
||||
|
||||
type CachedPayload = {
|
||||
headers: string;
|
||||
config: AnalysisConfig;
|
||||
result: AnalysisReport;
|
||||
};
|
||||
|
||||
type CachedData = CachedPayload & {
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
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 baseConfig: AnalysisConfig = {
|
||||
testIds: [101, 202],
|
||||
resolve: false,
|
||||
decodeAll: true,
|
||||
};
|
||||
|
||||
const baseReport: AnalysisReport = {
|
||||
results: [
|
||||
{
|
||||
testId: 101,
|
||||
testName: "SpamAssassin Rule Hits",
|
||||
headerName: "X-Spam-Status",
|
||||
headerValue: "Yes",
|
||||
analysis: "Hits: 6.2",
|
||||
description: "Spam scoring summary.",
|
||||
severity: "spam",
|
||||
status: "success",
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
hopChain: [],
|
||||
securityAppliances: [],
|
||||
metadata: {
|
||||
totalTests: 3,
|
||||
passedTests: 2,
|
||||
failedTests: 1,
|
||||
skippedTests: 0,
|
||||
elapsedMs: 1200,
|
||||
timedOut: false,
|
||||
incompleteTests: [],
|
||||
},
|
||||
};
|
||||
|
||||
const basePayload: CachedPayload = {
|
||||
headers: "Received: from mail.example.com",
|
||||
config: baseConfig,
|
||||
result: baseReport,
|
||||
};
|
||||
|
||||
const CacheHarness = ({ payload }: { payload: CachedPayload }) => {
|
||||
const { save, load, clear, hasCachedData, isNearLimit } = useAnalysisCache();
|
||||
const [loaded, setLoaded] = useState<CachedData | null>(null);
|
||||
const [hasCache, setHasCache] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="has-cache">{hasCache ? "true" : "false"}</span>
|
||||
<span data-testid="loaded-headers">{loaded?.headers ?? ""}</span>
|
||||
<span data-testid="loaded-test-count">{loaded?.result.metadata.totalTests ?? ""}</span>
|
||||
<span data-testid="loaded-test-ids">
|
||||
{loaded ? loaded.config.testIds.join(",") : ""}
|
||||
</span>
|
||||
<span data-testid="near-limit">{isNearLimit ? "true" : "false"}</span>
|
||||
<button type="button" data-testid="save" onClick={() => save(payload)}>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" data-testid="load" onClick={() => setLoaded(load())}>
|
||||
Load
|
||||
</button>
|
||||
<button type="button" data-testid="check" onClick={() => setHasCache(hasCachedData())}>
|
||||
Check
|
||||
</button>
|
||||
<button type="button" data-testid="clear" onClick={() => clear()}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length > 0) {
|
||||
const cleanup = cleanups.pop();
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("useAnalysisCache", () => {
|
||||
it("saves, loads, and clears cached analysis data", () => {
|
||||
const { container } = render(<CacheHarness payload={basePayload} />);
|
||||
|
||||
act(() => {
|
||||
getByTestId(container, "check").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(getByTestId(container, "has-cache").textContent).toBe("false");
|
||||
|
||||
act(() => {
|
||||
getByTestId(container, "save").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("wha:headers")).toBe(basePayload.headers);
|
||||
expect(localStorage.getItem("wha:config")).toBe(JSON.stringify(basePayload.config));
|
||||
expect(localStorage.getItem("wha:result")).toBe(JSON.stringify(basePayload.result));
|
||||
const timestampValue = localStorage.getItem("wha:timestamp");
|
||||
expect(timestampValue).not.toBeNull();
|
||||
expect(Number.isNaN(Number(timestampValue))).toBe(false);
|
||||
|
||||
act(() => {
|
||||
getByTestId(container, "check").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(getByTestId(container, "has-cache").textContent).toBe("true");
|
||||
|
||||
act(() => {
|
||||
getByTestId(container, "load").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(getByTestId(container, "loaded-headers").textContent).toBe(basePayload.headers);
|
||||
expect(getByTestId(container, "loaded-test-count").textContent).toBe("3");
|
||||
expect(getByTestId(container, "loaded-test-ids").textContent).toBe("101,202");
|
||||
|
||||
act(() => {
|
||||
getByTestId(container, "clear").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("wha:headers")).toBeNull();
|
||||
expect(localStorage.getItem("wha:config")).toBeNull();
|
||||
expect(localStorage.getItem("wha:result")).toBeNull();
|
||||
expect(localStorage.getItem("wha:timestamp")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
getByTestId(container, "check").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(getByTestId(container, "has-cache").textContent).toBe("false");
|
||||
});
|
||||
|
||||
it("flags when cache usage is near the localStorage limit", () => {
|
||||
const storageLimitBytes = 5 * 1024 * 1024;
|
||||
const warningThresholdBytes = Math.floor(storageLimitBytes * 0.9) + 1024;
|
||||
const largeHeaders = "a".repeat(warningThresholdBytes);
|
||||
const payload: CachedPayload = {
|
||||
...basePayload,
|
||||
headers: largeHeaders,
|
||||
};
|
||||
|
||||
const { container } = render(<CacheHarness payload={payload} />);
|
||||
|
||||
act(() => {
|
||||
getByTestId(container, "save").dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(getByTestId(container, "near-limit").textContent).toBe("true");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user