mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 16:46:24 +00:00
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.
80 lines
2.8 KiB
TypeScript
80 lines
2.8 KiB
TypeScript
import { describe, test, expect } from "bun:test";
|
|
import { runCLI } from "./helpers";
|
|
|
|
// These assert on validation error codes that are emitted BEFORE any network
|
|
// call (or independently of it), so the suite is network-free: a valid-flag case
|
|
// still runs offline because it only checks the ABSENCE of a validation error.
|
|
|
|
function parsedStderr(stderr: string): { error?: string; code?: string } {
|
|
try {
|
|
return JSON.parse(stderr);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
describe("freehire CLI flag validation", () => {
|
|
describe("numeric flag validation", () => {
|
|
for (const name of ["jobage", "page", "limit"]) {
|
|
test(`--${name} non-numeric exits 1 with BAD_ARG`, async () => {
|
|
const result = await runCLI(["search", `--${name}`, "foo"]);
|
|
expect(result.exitCode).not.toBe(0);
|
|
const err = parsedStderr(result.stderr);
|
|
expect(err.code).toBe("BAD_ARG");
|
|
expect(err.error).toMatch(new RegExp(name));
|
|
});
|
|
}
|
|
|
|
test("valid integers produce no BAD_ARG", async () => {
|
|
const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"]);
|
|
expect(parsedStderr(result.stderr).code).not.toBe("BAD_ARG");
|
|
});
|
|
});
|
|
|
|
describe("--description-format validation", () => {
|
|
test("an unsupported format exits 1 with BAD_ARG", async () => {
|
|
const result = await runCLI(["search", "--description-format", "tekst"]);
|
|
expect(result.exitCode).not.toBe(0);
|
|
const err = parsedStderr(result.stderr);
|
|
expect(err.code).toBe("BAD_ARG");
|
|
expect(err.error).toMatch(/description-format/);
|
|
});
|
|
});
|
|
|
|
describe("--facet validation", () => {
|
|
test("a facet without '=' exits 1 with BAD_ARG", async () => {
|
|
const result = await runCLI(["search", "--facet", "novalue"]);
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
|
|
});
|
|
});
|
|
|
|
describe("detail argument validation", () => {
|
|
test("missing slug exits 1 with NO_ID", async () => {
|
|
const result = await runCLI(["detail"]);
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(parsedStderr(result.stderr).code).toBe("NO_ID");
|
|
});
|
|
|
|
test("an unparseable slug exits 1 with BAD_ID (no network)", async () => {
|
|
const result = await runCLI(["detail", "not a slug!"]);
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(parsedStderr(result.stderr).code).toBe("BAD_ID");
|
|
});
|
|
});
|
|
|
|
describe("command dispatch", () => {
|
|
test("unknown command exits 1 with BAD_CMD", async () => {
|
|
const result = await runCLI(["frobnicate"]);
|
|
expect(result.exitCode).not.toBe(0);
|
|
expect(parsedStderr(result.stderr).code).toBe("BAD_CMD");
|
|
});
|
|
|
|
test("no command prints help and exits 1", async () => {
|
|
const result = await runCLI([]);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.stdout).toMatch(/USAGE/);
|
|
});
|
|
});
|
|
});
|