mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
feat(freehire-search): search returns each hit's full description (#251)
The skill queried /api/v1/jobs/search, whose `description` is the search index's truncated preview — and the CLI dropped it entirely, so a result carried only title/company/location/date/url. Reading a posting therefore meant a `detail` call per hit, which is exactly what job-scraper's Step 2 prescribes: "fetch full detail with that portal's `detail` command". freehire exposes a search endpoint for programmatic consumers, /api/v1/agent/jobs/search: same query, ranking, facets and pagination, but asked to (`include_description=true`) it replaces the preview with the posting's full description read from the database, rendered as `description_format=markdown|text|html`. Reproduce the difference: curl -s "https://freehire.me/api/v1/jobs/search?q=golang&limit=1" \ | jq -r '.data[0].description | length' # preview, capped curl -s "https://freehire.me/api/v1/agent/jobs/search?q=golang&limit=1\ &include_description=true&description_format=markdown" \ | jq -r '.data[0].description | length' # full text So `search` now calls that endpoint, always asking for full descriptions, and each JSON result carries `description` verbatim — no client-side HTML stripping, since the API already rendered it. Markdown is the default because it preserves the headings and requirement lists /rank reasons over; `--description-format text|html` selects the others. The flag is validated client-side: the API answers an unrecognized format with raw HTML rather than an error, so a typo would silently change the output instead of failing. `table` and `plain` stay description-free — a full posting body would swamp a scannable list — and `detail` is untouched, for looking one posting up by slug (including a closed one, absent from search). One behaviour change beyond the endpoint: a 404 from the search path used to be folded into an empty result set. On the agent endpoint a 404 means the instance predates it — a self-hosted freehire behind FREEHIRE_API_URL — so it is now reported as an error naming the path, instead of a plausible "no results" that hides the misconfiguration. Tests cover the requested URL and params, verbatim (unstripped) markdown, the null-when-absent case, the 404-is-an-error contract, and the flag validation. All network-free.
This commit is contained in:
@@ -15,12 +15,32 @@ function captureStdout(): { get: () => string } {
|
||||
return { get: () => buf };
|
||||
}
|
||||
|
||||
function mockFetch(status: number, body: unknown): void {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
/** Stub fetch with a canned response; the return value exposes the URL it was called with. */
|
||||
function mockFetch(status: number, body: unknown): { url: () => string } {
|
||||
let requested = "";
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
});
|
||||
}) as typeof fetch;
|
||||
return { url: () => requested };
|
||||
}
|
||||
|
||||
/** The query params of the URL the mocked fetch was called with. */
|
||||
function requestedParams(mock: { url: () => string }): URLSearchParams {
|
||||
return new URL(mock.url()).searchParams;
|
||||
}
|
||||
|
||||
function captureStderr(): { get: () => string; restore: () => void } {
|
||||
let buf = "";
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
buf += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
return { get: () => buf, restore: () => (process.stderr.write = original) };
|
||||
}
|
||||
|
||||
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
|
||||
@@ -56,6 +76,7 @@ const searchOpts = {
|
||||
page: 1,
|
||||
limit: 25,
|
||||
format: "json" as const,
|
||||
descriptionFormat: "markdown" as const,
|
||||
regions: [] as string[],
|
||||
countries: [] as string[],
|
||||
cities: [] as string[],
|
||||
@@ -80,6 +101,61 @@ describe("runSearch (mocked fetch)", () => {
|
||||
expect(parsed.results[0].date).toBe("2026-07-06T00:00:00Z");
|
||||
});
|
||||
|
||||
test("queries the agent endpoint asking for full descriptions", async () => {
|
||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||
captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(new URL(mock.url()).pathname).toBe("/api/v1/agent/jobs/search");
|
||||
expect(requestedParams(mock).get("include_description")).toBe("true");
|
||||
expect(requestedParams(mock).get("description_format")).toBe("markdown");
|
||||
});
|
||||
|
||||
test("asks for the requested description format", async () => {
|
||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||
captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, descriptionFormat: "text", query: "backend" });
|
||||
|
||||
expect(requestedParams(mock).get("description_format")).toBe("text");
|
||||
});
|
||||
|
||||
test("carries each hit's description verbatim, in the server's format", async () => {
|
||||
const markdown = "## About the role\n\n- Write Go\n- Ship things";
|
||||
mockFetch(200, { data: [job({ description: markdown })], meta: { total: 1 } });
|
||||
const out = captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(JSON.parse(out.get()).results[0].description).toBe(markdown);
|
||||
});
|
||||
|
||||
test("a hit with no description carries null, not an empty string", async () => {
|
||||
mockFetch(200, { data: [job({ description: "" })], meta: { total: 1 } });
|
||||
const out = captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(JSON.parse(out.get()).results[0].description).toBeNull();
|
||||
});
|
||||
|
||||
// A self-hosted freehire predating /agent/jobs/search answers 404, which apiGet
|
||||
// maps to null. Reporting that as "no results" would hide a broken endpoint
|
||||
// behind an empty, plausible-looking result set.
|
||||
test("a 404 from the search endpoint is an error, not an empty result set", async () => {
|
||||
mockFetch(404, { error: "not found" });
|
||||
const err = captureStderr();
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "backend" });
|
||||
err.restore();
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(out.get()).toBe("");
|
||||
expect(JSON.parse(err.get()).error).toMatch(/agent\/jobs\/search/);
|
||||
});
|
||||
|
||||
test("empty result set yields an empty results array", async () => {
|
||||
mockFetch(200, { data: [], meta: { total: 0 } });
|
||||
const out = captureStdout();
|
||||
|
||||
Reference in New Issue
Block a user