diff --git a/client/modules/search.test.ts b/client/modules/search.test.ts index e60eea4a9..116c52348 100644 --- a/client/modules/search.test.ts +++ b/client/modules/search.test.ts @@ -24,7 +24,17 @@ const mockFetchResponse = (results: string[][]) => { }; describe("Search Module", () => { - beforeEach(() => { + beforeEach(async () => { + // Reset shared mutable state mutated by other test suites so every + // describe block runs with a clean, deterministic cache config. + searchModule.searchServiceInstance.updateCacheConfig({ + ttl: 15 * 60 * 1000, + maxEntries: 100, + enabled: true, + }); + // Clear the fake IndexedDB database so each test starts fresh. + await searchModule.searchServiceInstance.clearSearchCache(); + // Clear mocks after clearSearchCache so addLogEntry starts empty. vi.clearAllMocks(); }); @@ -360,6 +370,144 @@ describe("Search Module", () => { }); }); + describe("Cache Hit Path", () => { + it("should serve a repeated text query from the IndexedDB cache without a second fetch", async () => { + const mockResults: string[][] = [ + ["Title", "Snippet", "https://example.com"], + ]; + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResults), + }); + + // First call — cache miss, fetches from network. + const firstResults = await searchModule.searchText("cached query"); + expect(firstResults).toEqual(mockResults); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Second call with the same query — should be a cache hit. + const secondResults = await searchModule.searchText("cached query"); + expect(secondResults).toEqual(mockResults); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(addLogEntry).toHaveBeenCalledWith( + expect.stringContaining("Text search: Reused 1 results from the cache"), + ); + expect(searchModule.searchServiceInstance.getCacheStats().textHits).toBe( + 1, + ); + }); + + it("should serve a repeated image query from the IndexedDB cache without a second fetch", async () => { + const mockResults: string[][] = [ + ["Image", "Alt", "https://example.com/img.jpg"], + ]; + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResults), + }); + + const firstResults = await searchModule.searchImages("cached images"); + expect(firstResults).toEqual(mockResults); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const secondResults = await searchModule.searchImages("cached images"); + expect(secondResults).toEqual(mockResults); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(addLogEntry).toHaveBeenCalledWith( + expect.stringContaining( + "Image search: Reused 1 results from the cache", + ), + ); + expect(searchModule.searchServiceInstance.getCacheStats().imageHits).toBe( + 1, + ); + }); + + it("should treat different queries as separate cache entries", async () => { + const mockResults: string[][] = [ + ["Title", "Snippet", "https://example.com"], + ]; + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResults), + }); + + await searchModule.searchText("query A"); + await searchModule.searchText("query B"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("should treat different limits as separate cache entries", async () => { + const mockResults: string[][] = [ + ["Title", "Snippet", "https://example.com"], + ]; + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResults), + }); + + await searchModule.searchText("same query", 5); + await searchModule.searchText("same query", 10); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("should refetch when TTL expires", async () => { + const mockResults: string[][] = [ + ["Title", "Snippet", "https://example.com"], + ]; + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResults), + }); + + await searchModule.searchText("ttl query"); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Expire the cache entry instantly. + searchModule.searchServiceInstance.updateCacheConfig({ ttl: 0 }); + + const secondResults = await searchModule.searchText("ttl query"); + expect(secondResults).toEqual(mockResults); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("should skip cache when caching is disabled", async () => { + const mockResults: string[][] = [ + ["Title", "Snippet", "https://example.com"], + ]; + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResults), + }); + + searchModule.searchServiceInstance.updateCacheConfig({ enabled: false }); + + await searchModule.searchText("disabled cache query"); + await searchModule.searchText("disabled cache query"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("should use separate caches for text and image stores", async () => { + const mockResults: string[][] = [ + ["Title", "Snippet", "https://example.com"], + ]; + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResults), + }); + + await searchModule.searchText("cross-store query"); + expect(mockFetch).toHaveBeenCalledTimes(1); + + await searchModule.searchImages("cross-store query"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Repeat the image query — should hit the image cache, not the network. + await searchModule.searchImages("cross-store query"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + }); + describe("URL Construction", () => { it("should include the search token hash in the request URL", async () => { mockFetchResponse([]); diff --git a/client/setupTests.ts b/client/setupTests.ts index c5c7d0200..33b29c877 100644 --- a/client/setupTests.ts +++ b/client/setupTests.ts @@ -1,3 +1,7 @@ +// fake-indexeddb/auto installs all IndexedDB globals (indexedDB, IDBKeyRange, +// etc.) so that Dexie-based cache tests exercise the real cache code paths +// instead of silently erroring into catch blocks. +import "fake-indexeddb/auto"; import "@testing-library/jest-dom"; import { vi } from "vitest"; diff --git a/package-lock.json b/package-lock.json index 46f51d130..ee7bf668c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "@vitejs/plugin-basic-ssl": "^2.0.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/coverage-v8": "^4.0.18", + "fake-indexeddb": "^6.0.0", "globals": "^17.0.0", "husky": "^9.1.7", "jscpd": "^5.0.0", @@ -3598,6 +3599,16 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 339812dd2..b3c3932a2 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@vitejs/plugin-basic-ssl": "^2.0.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/coverage-v8": "^4.0.18", + "fake-indexeddb": "^6.0.0", "globals": "^17.0.0", "husky": "^9.1.7", "jscpd": "^5.0.0",