Files
job-search/web/src/primitives/createOpportunitySearch.test.ts
T
bwilliams b0230e6847 Replaced the Title filter with a general search, run by the backend
GET /api/opportunities/search?q= returns the UUIDs of the matching
opportunities, and the frontend filters its list by them. Words without
a prompt search the title and description; title:, buyer:, buyer_id:
and cpv: search only their own field, and a leading ! leaves out what a
term matches. Repeats of one prompt are OR, everything else AND. CPV
terms are checked per code: cpv:72 !cpv:7221 needs a code starting 72
that doesn't start 7221.

Unknown prompts are reported, and every response carries the help that
the search box's "?" panel shows. Migration 00011 adds the description
and buyer name to the full-text index, and indexes CPV descriptions.
2026-10-22 13:10:25 +01:00

226 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createRoot, createSignal } from 'solid-js';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type * as clientModule from '@/api/client';
import type { OpportunitySearch } from '@/types/Opportunities';
import type { OpportunitySearchResults } from './createOpportunitySearch';
import { createOpportunitySearch } from './createOpportunitySearch';
const requests = vi.hoisted(() => ({
searchOpportunities:
vi.fn<
(query: string, releasedFrom: string, signal?: AbortSignal) => Promise<OpportunitySearch>
>(),
}));
vi.mock('@/api/client', async (importOriginal) => ({
...(await importOriginal<typeof clientModule>()),
api: requests,
}));
/** A search the test answers when it chooses. */
interface SentSearch {
query: string;
releasedFrom: string;
signal: AbortSignal | undefined;
resolve: (found: OpportunitySearch) => void;
reject: (err: unknown) => void;
}
const HELP = {
prompts: [{ name: 'title', help: 'The title only.' }],
syntax: [{ example: '!cloud', help: 'Not cloud.' }],
};
const found = (...uuids: string[]): OpportunitySearch => ({
uuids,
matches_all: false,
unknown_prompts: [],
...HELP,
});
let sent: SentSearch[];
let errors: string[];
let search: OpportunitySearchResults;
let dispose: () => void;
let setFilters: (filters: { search: string; days: number }) => void;
let setReleasedFrom: (from: string) => void;
/** The searches sent with a query: the help's has none. */
const searches = (): SentSearch[] => sent.filter((s) => s.query !== '');
/** Lets pending promises, Solid's effects and timers up to ms run. */
const wait = async (ms = 0): Promise<void> => {
await vi.advanceTimersByTimeAsync(ms);
};
/** Starts the search with a stored query, and answers the help's request. */
async function start(query: string): Promise<void> {
createRoot((disposeRoot) => {
dispose = disposeRoot;
const [filters, setStored] = createSignal({ search: query, days: 7 });
const [releasedFrom, setFrom] = createSignal('2026-10-12T00:00:00Z');
setFilters = setStored;
setReleasedFrom = setFrom;
search = createOpportunitySearch(
() => filters().search,
releasedFrom,
(message) => errors.push(message),
);
});
sent.find((s) => s.query === '')?.resolve({ ...found(), matches_all: true });
await wait();
}
const type = (text: string): void => setFilters({ search: text, days: 7 });
beforeEach(() => {
vi.useFakeTimers();
sent = [];
errors = [];
requests.searchOpportunities.mockImplementation(
(query, releasedFrom, signal) =>
new Promise((resolve, reject) => {
sent.push({ query, releasedFrom, signal, resolve, reject });
}),
);
});
afterEach(() => {
dispose();
vi.clearAllMocks();
vi.useRealTimers();
});
test('loads the help at the start, with a blank query and no bound', async () => {
await start('');
expect(sent.map((s) => [s.query, s.releasedFrom])).toEqual([['', '']]);
expect(search.help()).toEqual(HELP);
expect(search.matches()).toBeUndefined();
});
test('runs a stored search at once, and keeps its matches', async () => {
await start('cloud');
expect(searches().map((s) => [s.query, s.releasedFrom])).toEqual([
['cloud', '2026-10-12T00:00:00Z'],
]);
expect(search.isSearching()).toBe(true);
searches()[0]?.resolve(found('a', 'b'));
await wait();
expect([...(search.matches() ?? [])]).toEqual(['a', 'b']);
expect(search.isSearching()).toBe(false);
});
test('waits for typing to pause, then searches for the latest text', async () => {
await start('');
type('c');
await wait(200);
type('cloud');
await wait(299);
expect(searches()).toHaveLength(0);
expect(search.isSearching()).toBe(true);
await wait(1);
expect(searches().map((s) => s.query)).toEqual(['cloud']);
});
test('aborts a search under way for the next, and ignores its late answer', async () => {
await start('cloud');
type('road');
await wait(300);
expect(searches()[0]?.signal?.aborted).toBe(true);
searches()[0]?.resolve(found('old'));
searches()[1]?.resolve(found('new'));
await wait();
expect([...(search.matches() ?? [])]).toEqual(['new']);
});
test('doesn’t filter when nothing in the box can be searched for', async () => {
await start('title:');
searches()[0]?.resolve({ ...found(), matches_all: true, unknown_prompts: ['foo'] });
await wait();
expect(search.matches()).toBeUndefined();
expect(search.unknownPrompts()).toEqual(['foo']);
});
test('a blank box clears the matches at once, without a request', async () => {
await start('cloud');
searches()[0]?.resolve(found('a'));
await wait();
type(' ');
await wait(300);
expect(searches()).toHaveLength(1);
expect(search.matches()).toBeUndefined();
expect(search.isSearching()).toBe(false);
});
test('keeps the last matches after a failure, and refresh tries again', async () => {
await start('cloud');
searches()[0]?.resolve(found('a'));
await wait();
type('road');
await wait(300);
searches()[1]?.reject(new Error('offline'));
await wait();
expect([...(search.matches() ?? [])]).toEqual(['a']);
expect(search.error()?.message).toBe('offline');
expect(errors).toEqual(["The search didn't run: offline"]);
search.refresh();
searches()[2]?.resolve(found('b'));
await wait();
expect(searches()[2]?.query).toBe('road');
expect([...(search.matches() ?? [])]).toEqual(['b']);
expect(search.error()).toBeUndefined();
});
test('runs again at once for a new releasedFrom, but not for other filters', async () => {
await start('cloud');
setFilters({ search: 'cloud', days: 30 });
await wait(300);
expect(searches()).toHaveLength(1);
setReleasedFrom('2026-10-01T00:00:00Z');
await wait();
expect(searches().map((s) => s.releasedFrom)).toEqual([
'2026-10-12T00:00:00Z',
'2026-10-01T00:00:00Z',
]);
});
test('a search mends help that didn’t load', async () => {
createRoot((disposeRoot) => {
dispose = disposeRoot;
const [query, setQuery] = createSignal('');
setFilters = (filters) => setQuery(filters.search);
search = createOpportunitySearch(
query,
() => '',
(message) => errors.push(message),
);
});
sent[0]?.reject(new Error('offline'));
await wait();
expect(search.helpError()?.message).toBe('offline');
expect(search.help()).toBeUndefined();
type('cloud');
await wait(300);
searches()[0]?.resolve(found());
await wait();
expect(search.help()).toEqual(HELP);
expect(search.helpError()).toBeUndefined();
});