history.test.js 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. /* @vitest-environment node */
  2. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
  3. import {
  4. SEARCH_SCOPE,
  5. DEFAULT_SEARCH_LIMIT,
  6. } from "@/lib/frontend/search/urlState";
  7. import {
  8. buildSearchHistoryStorageKey,
  9. loadSearchHistory,
  10. saveSearchHistory,
  11. addSearchHistoryEntry,
  12. clearSearchHistory,
  13. normalizeSearchHistoryEntry,
  14. buildSearchHrefFromEntry,
  15. } from "./history.js";
  16. function createLocalStorageMock() {
  17. const store = new Map();
  18. return {
  19. getItem: vi.fn((key) => (store.has(key) ? store.get(key) : null)),
  20. setItem: vi.fn((key, value) => {
  21. store.set(String(key), String(value));
  22. }),
  23. removeItem: vi.fn((key) => {
  24. store.delete(String(key));
  25. }),
  26. clear: vi.fn(() => {
  27. store.clear();
  28. }),
  29. dump: () => store,
  30. };
  31. }
  32. describe("lib/frontend/search/history", () => {
  33. let localStorageMock;
  34. beforeEach(() => {
  35. localStorageMock = createLocalStorageMock();
  36. vi.stubGlobal("window", { localStorage: localStorageMock });
  37. });
  38. afterEach(() => {
  39. vi.unstubAllGlobals();
  40. vi.restoreAllMocks();
  41. });
  42. it("buildSearchHistoryStorageKey is user-scoped and versioned", () => {
  43. expect(buildSearchHistoryStorageKey("u-1")).toBe("rhl.searchHistory.v1.u-1");
  44. expect(buildSearchHistoryStorageKey(" ")).toBe(null);
  45. expect(buildSearchHistoryStorageKey(null)).toBe(null);
  46. });
  47. it("normalizeSearchHistoryEntry canonicalizes values", () => {
  48. const normalized = normalizeSearchHistoryEntry({
  49. routeBranch: " nl1 ",
  50. q: " bridgestone ",
  51. scope: SEARCH_SCOPE.MULTI,
  52. branches: ["nl20", "NL06", "NL20", "bad"],
  53. limit: 999,
  54. from: " 2025-01-01 ",
  55. to: " ",
  56. createdAt: "1700000000000",
  57. });
  58. expect(normalized).toEqual({
  59. routeBranch: "NL1",
  60. q: "bridgestone",
  61. scope: SEARCH_SCOPE.MULTI,
  62. branches: ["NL06", "NL20"],
  63. limit: DEFAULT_SEARCH_LIMIT,
  64. from: "2025-01-01",
  65. to: null,
  66. createdAt: 1700000000000,
  67. });
  68. });
  69. it("save/load keeps deterministic normalized entries and dedupes by canonical identity", () => {
  70. const userId = "admin-1";
  71. const saved = saveSearchHistory(userId, [
  72. {
  73. routeBranch: "NL17",
  74. q: "abc",
  75. scope: SEARCH_SCOPE.MULTI,
  76. branches: ["NL20", "NL06"],
  77. limit: 200,
  78. from: "2025-01-01",
  79. to: "2025-01-31",
  80. createdAt: 100,
  81. },
  82. {
  83. routeBranch: "NL17",
  84. q: " abc ",
  85. scope: SEARCH_SCOPE.MULTI,
  86. branches: ["NL06", "NL20", "NL20"],
  87. limit: 200,
  88. from: "2025-01-01",
  89. to: "2025-01-31",
  90. createdAt: 200,
  91. },
  92. ]);
  93. expect(saved).toHaveLength(1);
  94. expect(saved[0]).toMatchObject({
  95. routeBranch: "NL17",
  96. q: "abc",
  97. scope: SEARCH_SCOPE.MULTI,
  98. branches: ["NL06", "NL20"],
  99. limit: 200,
  100. from: "2025-01-01",
  101. to: "2025-01-31",
  102. });
  103. expect(loadSearchHistory(userId)).toEqual(saved);
  104. });
  105. it("addSearchHistoryEntry moves reused queries to the top (LRU)", () => {
  106. const userId = "dev-1";
  107. addSearchHistoryEntry(userId, {
  108. routeBranch: "NL01",
  109. q: "first",
  110. scope: SEARCH_SCOPE.SINGLE,
  111. branches: [],
  112. limit: 100,
  113. from: null,
  114. to: null,
  115. createdAt: 1,
  116. });
  117. addSearchHistoryEntry(userId, {
  118. routeBranch: "NL02",
  119. q: "second",
  120. scope: SEARCH_SCOPE.SINGLE,
  121. branches: [],
  122. limit: 100,
  123. from: null,
  124. to: null,
  125. createdAt: 2,
  126. });
  127. const next = addSearchHistoryEntry(userId, {
  128. routeBranch: "NL01",
  129. q: "first",
  130. scope: SEARCH_SCOPE.SINGLE,
  131. branches: [],
  132. limit: 100,
  133. from: null,
  134. to: null,
  135. createdAt: 3,
  136. });
  137. expect(next).toHaveLength(2);
  138. expect(next[0].routeBranch).toBe("NL01");
  139. expect(next[0].q).toBe("first");
  140. expect(next[1].routeBranch).toBe("NL02");
  141. expect(next[1].q).toBe("second");
  142. });
  143. it("addSearchHistoryEntry enforces cap", () => {
  144. const userId = "cap-user";
  145. for (let i = 1; i <= 12; i += 1) {
  146. addSearchHistoryEntry(
  147. userId,
  148. {
  149. routeBranch: "NL17",
  150. q: `q-${i}`,
  151. scope: SEARCH_SCOPE.SINGLE,
  152. branches: [],
  153. limit: 100,
  154. from: null,
  155. to: null,
  156. createdAt: i,
  157. },
  158. { maxItems: 10 },
  159. );
  160. }
  161. const entries = loadSearchHistory(userId);
  162. expect(entries).toHaveLength(10);
  163. expect(entries[0].q).toBe("q-12");
  164. expect(entries[9].q).toBe("q-3");
  165. });
  166. it("clearSearchHistory removes persisted entries", () => {
  167. const userId = "clear-user";
  168. saveSearchHistory(userId, [
  169. {
  170. routeBranch: "NL17",
  171. q: "x",
  172. scope: SEARCH_SCOPE.SINGLE,
  173. branches: [],
  174. limit: 100,
  175. from: null,
  176. to: null,
  177. createdAt: 1,
  178. },
  179. ]);
  180. expect(loadSearchHistory(userId)).toHaveLength(1);
  181. expect(clearSearchHistory(userId)).toBe(true);
  182. expect(loadSearchHistory(userId)).toEqual([]);
  183. });
  184. it("buildSearchHrefFromEntry returns only safe internal hrefs", () => {
  185. const href = buildSearchHrefFromEntry({
  186. routeBranch: "NL17",
  187. q: "bridgestone",
  188. scope: SEARCH_SCOPE.MULTI,
  189. branches: ["NL20", "NL06"],
  190. limit: 200,
  191. from: "2025-01-01",
  192. to: "2025-01-31",
  193. createdAt: 1,
  194. });
  195. expect(href).toBe(
  196. "/NL17/search?q=bridgestone&scope=multi&branches=NL06%2CNL20&limit=200&from=2025-01-01&to=2025-01-31",
  197. );
  198. expect(href.startsWith("/")).toBe(true);
  199. expect(href.startsWith("//")).toBe(false);
  200. expect(
  201. buildSearchHrefFromEntry({
  202. routeBranch: "http://evil.com",
  203. q: "x",
  204. scope: SEARCH_SCOPE.SINGLE,
  205. branches: [],
  206. limit: 100,
  207. from: null,
  208. to: null,
  209. createdAt: 1,
  210. }),
  211. ).toBe(null);
  212. });
  213. });