| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- /* @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);
- });
- });
|