All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m20s
Enter waehrend Debounce-Fenster feuerte bislang eine zweite Fetch fuer dieselbe Query. Race-Guard greift nicht, weil q identisch ist. runSearch clearTimeout am Anfang behebt's, neuer Unit-Test sichert es. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { SearchStore, type SearchSnapshot } from '../../src/lib/client/search.svelte';
|
|
|
|
type FetchMock = ReturnType<typeof vi.fn>;
|
|
|
|
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<Response>((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('filterParam option: gets appended to both local and web requests', async () => {
|
|
vi.useFakeTimers();
|
|
const fetchImpl = mockFetch([
|
|
{ body: { hits: [] } },
|
|
{ body: { hits: [] } }
|
|
]);
|
|
const store = new SearchStore({
|
|
fetchImpl,
|
|
debounceMs: 10,
|
|
filterParam: () => '&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]).toMatch(/&domains=chefkoch\.de/);
|
|
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: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
|
|
]);
|
|
const store = new SearchStore({
|
|
fetchImpl,
|
|
debounceMs: 10,
|
|
filterDebounceMs: 5,
|
|
filterParam: () => filter
|
|
});
|
|
store.query = 'broth';
|
|
store.runDebounced();
|
|
await vi.advanceTimersByTimeAsync(15);
|
|
filter = '&domains=chefkoch.de';
|
|
store.reSearch();
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
await vi.waitFor(() => expect(store.hits).toHaveLength(1));
|
|
const last = fetchImpl.mock.calls.at(-1)?.[0] as string;
|
|
expect(last).toMatch(/&domains=chefkoch\.de/);
|
|
});
|
|
});
|