/* @vitest-environment node */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { SEARCH_SCOPE, DEFAULT_SEARCH_LIMIT, } from "@/lib/frontend/search/urlState"; import { buildSearchHistoryStorageKey, loadSearchHistory, saveSearchHistory, addSearchHistoryEntry, clearSearchHistory, normalizeSearchHistoryEntry, buildSearchHrefFromEntry, } from "./history.js"; function createLocalStorageMock() { const store = new Map(); return { getItem: vi.fn((key) => (store.has(key) ? store.get(key) : null)), setItem: vi.fn((key, value) => { store.set(String(key), String(value)); }), removeItem: vi.fn((key) => { store.delete(String(key)); }), clear: vi.fn(() => { store.clear(); }), dump: () => store, }; } describe("lib/frontend/search/history", () => { let localStorageMock; beforeEach(() => { localStorageMock = createLocalStorageMock(); vi.stubGlobal("window", { localStorage: localStorageMock }); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("buildSearchHistoryStorageKey is user-scoped and versioned", () => { expect(buildSearchHistoryStorageKey("u-1")).toBe("rhl.searchHistory.v1.u-1"); expect(buildSearchHistoryStorageKey(" ")).toBe(null); expect(buildSearchHistoryStorageKey(null)).toBe(null); }); it("normalizeSearchHistoryEntry canonicalizes values", () => { const normalized = normalizeSearchHistoryEntry({ routeBranch: " nl1 ", q: " bridgestone ", scope: SEARCH_SCOPE.MULTI, branches: ["nl20", "NL06", "NL20", "bad"], limit: 999, from: " 2025-01-01 ", to: " ", createdAt: "1700000000000", }); expect(normalized).toEqual({ routeBranch: "NL1", q: "bridgestone", scope: SEARCH_SCOPE.MULTI, branches: ["NL06", "NL20"], limit: DEFAULT_SEARCH_LIMIT, from: "2025-01-01", to: null, createdAt: 1700000000000, }); }); it("save/load keeps deterministic normalized entries and dedupes by canonical identity", () => { const userId = "admin-1"; const saved = saveSearchHistory(userId, [ { routeBranch: "NL17", q: "abc", scope: SEARCH_SCOPE.MULTI, branches: ["NL20", "NL06"], limit: 200, from: "2025-01-01", to: "2025-01-31", createdAt: 100, }, { routeBranch: "NL17", q: " abc ", scope: SEARCH_SCOPE.MULTI, branches: ["NL06", "NL20", "NL20"], limit: 200, from: "2025-01-01", to: "2025-01-31", createdAt: 200, }, ]); expect(saved).toHaveLength(1); expect(saved[0]).toMatchObject({ routeBranch: "NL17", q: "abc", scope: SEARCH_SCOPE.MULTI, branches: ["NL06", "NL20"], limit: 200, from: "2025-01-01", to: "2025-01-31", }); expect(loadSearchHistory(userId)).toEqual(saved); }); it("addSearchHistoryEntry moves reused queries to the top (LRU)", () => { const userId = "dev-1"; addSearchHistoryEntry(userId, { routeBranch: "NL01", q: "first", scope: SEARCH_SCOPE.SINGLE, branches: [], limit: 100, from: null, to: null, createdAt: 1, }); addSearchHistoryEntry(userId, { routeBranch: "NL02", q: "second", scope: SEARCH_SCOPE.SINGLE, branches: [], limit: 100, from: null, to: null, createdAt: 2, }); const next = addSearchHistoryEntry(userId, { routeBranch: "NL01", q: "first", scope: SEARCH_SCOPE.SINGLE, branches: [], limit: 100, from: null, to: null, createdAt: 3, }); expect(next).toHaveLength(2); expect(next[0].routeBranch).toBe("NL01"); expect(next[0].q).toBe("first"); expect(next[1].routeBranch).toBe("NL02"); expect(next[1].q).toBe("second"); }); it("addSearchHistoryEntry enforces cap", () => { const userId = "cap-user"; for (let i = 1; i <= 12; i += 1) { addSearchHistoryEntry( userId, { routeBranch: "NL17", q: `q-${i}`, scope: SEARCH_SCOPE.SINGLE, branches: [], limit: 100, from: null, to: null, createdAt: i, }, { maxItems: 10 }, ); } const entries = loadSearchHistory(userId); expect(entries).toHaveLength(10); expect(entries[0].q).toBe("q-12"); expect(entries[9].q).toBe("q-3"); }); it("clearSearchHistory removes persisted entries", () => { const userId = "clear-user"; saveSearchHistory(userId, [ { routeBranch: "NL17", q: "x", scope: SEARCH_SCOPE.SINGLE, branches: [], limit: 100, from: null, to: null, createdAt: 1, }, ]); expect(loadSearchHistory(userId)).toHaveLength(1); expect(clearSearchHistory(userId)).toBe(true); expect(loadSearchHistory(userId)).toEqual([]); }); it("buildSearchHrefFromEntry returns only safe internal hrefs", () => { const href = buildSearchHrefFromEntry({ routeBranch: "NL17", q: "bridgestone", scope: SEARCH_SCOPE.MULTI, branches: ["NL20", "NL06"], limit: 200, from: "2025-01-01", to: "2025-01-31", createdAt: 1, }); expect(href).toBe( "/NL17/search?q=bridgestone&scope=multi&branches=NL06%2CNL20&limit=200&from=2025-01-01&to=2025-01-31", ); expect(href.startsWith("/")).toBe(true); expect(href.startsWith("//")).toBe(false); expect( buildSearchHrefFromEntry({ routeBranch: "http://evil.com", q: "x", scope: SEARCH_SCOPE.SINGLE, branches: [], limit: 100, from: null, to: null, createdAt: 1, }), ).toBe(null); }); });