mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
Add freehire-search: country-agnostic freehire.dev aggregator skill (#85)
* feat(freehire-search): add country-agnostic freehire.dev aggregator skill
Adds a portal-search skill over the freehire.dev public JSON API — an
open-source IT job aggregator normalizing ~50 ATS platforms across many
markets into one schema. Like linkedin-search it is country-agnostic and
zero-dependency (plain bun + fetch), but it queries a JSON API rather than
scraping HTML, so results carry structured facets (skills/seniority/region).
Honors the portal-skill contract: search + detail commands, --format
json|table|plain, stderr JSON errors with exit 1, backoff on 429/5xx. Reads
are public (no API key) — the same zero-signup bar as linkedin-search. The
hosted-service dependency (best-effort, no SLA) is labeled prominently in
SKILL.md, and FREEHIRE_API_URL swaps the base URL for a self-hosted backend.
Scoped tech-first: triggers cover software/data/engineering roles, where the
faceted filtering is strong; non-tech coverage exists but is still maturing.
Network-free tests (mocked fetch + pure reshape/parse functions); CI matrix
updated to typecheck the new CLI.
* refactor(freehire-search): clarity pass on cli flag parsing
No behavior change. Replace a nested ternary and a comma-operator side effect
in a ternary with explicit if/else, and fix a comment that described facets
while sitting on the alias map.
* refactor(freehire-search): tighten to boundary contracts, trim comments
- Validate/normalize at boundaries, trust the declared types inside: drop the
redundant '?? []' guards on facet arrays the wire contract already guarantees,
and the re-filter in buildQuery (commaList already stripped empties).
- Model enrichment as always-present (an unenriched job serializes it as {}),
removing the '?? {}' guard.
- Replace the positional table-row builder with a declarative column list; add a
shared shortDate and a labeled-field helper for detail's plain output.
- Extract stringFlag for the string-or-bare-boolean flags (--remote/--query/...).
- Dedup the response parse in apiGet to a single tolerant read (drop safeJson).
- SKILL.md: document partial data + the 'none' unspecified-region facet.
- Trim restating comments to the reference skills' density.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
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("--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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user