// @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SearchStore, type SearchSnapshot } from '../../src/lib/client/search.svelte'; type FetchMock = ReturnType; function mockFetch(responses: Array<{ ok?: boolean; status?: number; body: unknown }>): FetchMock { const calls = [...responses]; return vi.fn(async () => { const r = calls.shift(); if (!r) throw new Error('fetch called more times than expected'); return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body } as Response; }); } describe('SearchStore', () => { beforeEach(() => { vi.useRealTimers(); }); it('keeps results empty while query is <= 3 chars (debounced)', async () => { vi.useFakeTimers(); const fetchImpl = mockFetch([]); const store = new SearchStore({ fetchImpl, debounceMs: 50 }); store.query = 'abc'; store.runDebounced(); await vi.advanceTimersByTimeAsync(100); expect(store.searching).toBe(false); expect(fetchImpl).not.toHaveBeenCalled(); }); it('fires local search after debounce when query > 3 chars', async () => { vi.useFakeTimers(); const fetchImpl = mockFetch([ { body: { hits: [{ id: 1, title: 'Pasta', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } } ]); const store = new SearchStore({ fetchImpl, debounceMs: 50, pageSize: 30 }); store.query = 'pasta'; store.runDebounced(); expect(store.searching).toBe(true); await vi.advanceTimersByTimeAsync(100); await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalled()); expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?q=pasta&limit=30/); expect(store.hits).toHaveLength(1); expect(store.searchedFor).toBe('pasta'); expect(store.localExhausted).toBe(true); }); it('falls back to web search when local returns zero hits', async () => { vi.useFakeTimers(); const fetchImpl = mockFetch([ { body: { hits: [] } }, { body: { hits: [{ url: 'https://chefkoch.de/x', title: 'Foo', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } } ]); const store = new SearchStore({ fetchImpl, debounceMs: 50 }); store.query = 'pizza'; store.runDebounced(); await vi.advanceTimersByTimeAsync(100); await vi.waitFor(() => expect(store.webHits).toHaveLength(1)); expect(fetchImpl).toHaveBeenCalledTimes(2); expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?q=pizza&pageno=1/); expect(store.webPageno).toBe(1); }); it('race-guard: stale fetch response discarded when query was cleared/changed', async () => { vi.useFakeTimers(); let resolveFetch!: (v: Response) => void; const fetchImpl = vi.fn( () => new Promise((resolve) => { resolveFetch = resolve; }) ); const store = new SearchStore({ fetchImpl, debounceMs: 10 }); store.query = 'stale-query'; store.runDebounced(); await vi.advanceTimersByTimeAsync(15); expect(fetchImpl).toHaveBeenCalledTimes(1); // User keeps typing BEFORE the response arrives — race-guard should kick in // when the fetch finally resolves. store.query = 'different'; resolveFetch({ ok: true, status: 200, json: async () => ({ hits: [ { id: 99, title: 'Stale', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null } ] }) } as Response); // Flush microtasks so the awaited response + race-guard run. await vi.runOnlyPendingTimersAsync(); await Promise.resolve(); await Promise.resolve(); expect(store.hits).toEqual([]); expect(store.searchedFor).toBeNull(); }); it('loadMore: drains local first (offset pagination)', async () => { vi.useFakeTimers(); const page1 = Array.from({ length: 30 }, (_, i) => ({ id: i, title: `r${i}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null })); const page2 = Array.from({ length: 5 }, (_, i) => ({ id: i + 30, title: `r${i + 30}`, description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null })); const fetchImpl = mockFetch([ { body: { hits: page1 } }, { body: { hits: page2 } } ]); const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 }); store.query = 'meal'; store.runDebounced(); await vi.advanceTimersByTimeAsync(15); await vi.waitFor(() => expect(store.hits).toHaveLength(30)); expect(store.localExhausted).toBe(false); await store.loadMore(); expect(store.hits).toHaveLength(35); expect(fetchImpl.mock.calls[1][0]).toMatch(/offset=30/); expect(store.localExhausted).toBe(true); }); it('loadMore: switches to web pagination after local exhausted', async () => { vi.useFakeTimers(); const local = [{ id: 1, title: 'local', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }]; const webP1 = [{ url: 'https://a.com', title: 'A', domain: 'a.com', snippet: null, thumbnail: null }]; const webP2 = [{ url: 'https://b.com', title: 'B', domain: 'b.com', snippet: null, thumbnail: null }]; const fetchImpl = mockFetch([ { body: { hits: local } }, { body: { hits: webP1 } }, { body: { hits: webP2 } } ]); const store = new SearchStore({ fetchImpl, debounceMs: 10, pageSize: 30 }); store.query = 'soup'; store.runDebounced(); await vi.advanceTimersByTimeAsync(15); await vi.waitFor(() => expect(store.hits).toHaveLength(1)); expect(store.localExhausted).toBe(true); await store.loadMore(); expect(store.webHits).toHaveLength(1); await store.loadMore(); expect(store.webHits).toHaveLength(2); expect(store.webPageno).toBe(2); }); it('web search error sets webError and marks webExhausted', async () => { vi.useFakeTimers(); const fetchImpl = mockFetch([ { body: { hits: [] } }, { ok: false, status: 502, body: { message: 'SearXNG unreachable' } } ]); const store = new SearchStore({ fetchImpl, debounceMs: 10 }); store.query = 'anything'; store.runDebounced(); await vi.advanceTimersByTimeAsync(15); await vi.waitFor(() => expect(store.webError).toBe('SearXNG unreachable')); expect(store.webExhausted).toBe(true); }); it('reset(): clears query, results, and pending debounce', async () => { vi.useFakeTimers(); const fetchImpl = mockFetch([]); const store = new SearchStore({ fetchImpl, debounceMs: 100 }); store.query = 'foobar'; store.runDebounced(); store.reset(); await vi.advanceTimersByTimeAsync(200); expect(store.query).toBe(''); expect(store.hits).toEqual([]); expect(fetchImpl).not.toHaveBeenCalled(); }); it('captureSnapshot / restoreSnapshot: round-trips without re-fetching', async () => { vi.useFakeTimers(); const fetchImpl = mockFetch([]); const store = new SearchStore({ fetchImpl, debounceMs: 50 }); const snap: SearchSnapshot = { query: 'lasagne', hits: [{ id: 7, title: 'Lasagne', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }], webHits: [], searchedFor: 'lasagne', webError: null, localExhausted: true, webPageno: 0, webExhausted: false }; store.restoreSnapshot(snap); expect(store.query).toBe('lasagne'); expect(store.hits).toHaveLength(1); store.runDebounced(); await vi.advanceTimersByTimeAsync(100); expect(fetchImpl).not.toHaveBeenCalled(); const round = store.captureSnapshot(); expect(round).toEqual(snap); }); it('webFilterParam option: only appended to web requests, never to local', async () => { vi.useFakeTimers(); const fetchImpl = mockFetch([ { body: { hits: [] } }, { body: { hits: [] } } ]); const store = new SearchStore({ fetchImpl, debounceMs: 10, webFilterParam: () => '&domains=chefkoch.de' }); store.query = 'curry'; store.runDebounced(); await vi.advanceTimersByTimeAsync(15); await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); expect(fetchImpl.mock.calls[0][0]).not.toMatch(/domains=/); expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?/); expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?/); expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/); }); it('runSearch(q) cancels pending debounce to avoid double-fetch', async () => { vi.useFakeTimers(); const fetchImpl = mockFetch([ { body: { hits: [{ id: 1, title: 'immediate', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } } ]); const store = new SearchStore({ fetchImpl, debounceMs: 300 }); store.query = 'meal'; store.runDebounced(); // schedules the 300ms timer // Before the timer fires, call runSearch immediately (e.g. form submit). await store.runSearch('meal'); expect(fetchImpl).toHaveBeenCalledTimes(1); // Now advance past the original debounce — timer must not still fire. await vi.advanceTimersByTimeAsync(400); expect(fetchImpl).toHaveBeenCalledTimes(1); }); it('reSearch: immediate re-run with current query on filter change', async () => { vi.useFakeTimers(); let filter = ''; const fetchImpl = mockFetch([ { body: { hits: [] } }, { body: { hits: [] } }, { body: { hits: [] } }, { body: { hits: [{ url: 'https://chefkoch.de/x', title: 'filtered', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } } ]); const store = new SearchStore({ fetchImpl, debounceMs: 10, filterDebounceMs: 5, webFilterParam: () => filter }); store.query = 'broth'; store.runDebounced(); await vi.advanceTimersByTimeAsync(15); await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); filter = '&domains=chefkoch.de'; store.reSearch(); await vi.advanceTimersByTimeAsync(10); await vi.waitFor(() => expect(store.webHits).toHaveLength(1)); const last = fetchImpl.mock.calls.at(-1)?.[0] as string; expect(last).toMatch(/\/api\/recipes\/search\/web\?/); expect(last).toMatch(/&domains=chefkoch\.de/); }); });