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.
226 lines
6.4 KiB
TypeScript
226 lines
6.4 KiB
TypeScript
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();
|
||
});
|