mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
* 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.
141 lines
4.3 KiB
TypeScript
141 lines
4.3 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import { runSearch } from "../src/commands/search";
|
|
import { runDetail } from "../src/commands/detail";
|
|
import type { FreehireJob } from "../src/helpers";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalStdoutWrite = process.stdout.write;
|
|
|
|
function captureStdout(): { get: () => string } {
|
|
let buf = "";
|
|
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
buf += chunk.toString();
|
|
return true;
|
|
}) as typeof process.stdout.write;
|
|
return { get: () => buf };
|
|
}
|
|
|
|
function mockFetch(status: number, body: unknown): void {
|
|
globalThis.fetch = (async () =>
|
|
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
})) as typeof fetch;
|
|
}
|
|
|
|
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
|
|
return {
|
|
public_slug: "backend-engineer-acme-ab12cd34",
|
|
source: "greenhouse",
|
|
external_id: "acme:1",
|
|
url: "https://boards.greenhouse.io/acme/jobs/1",
|
|
title: "Backend Engineer",
|
|
company: "Acme",
|
|
company_slug: "acme",
|
|
location: "Berlin, Germany",
|
|
description: "<p>Build things</p>",
|
|
skills: ["go"],
|
|
work_mode: "remote",
|
|
regions: ["eu"],
|
|
countries: ["de"],
|
|
cities: ["Berlin"],
|
|
posted_at: "2026-07-06T00:00:00Z",
|
|
created_at: "2026-07-06T15:00:00Z",
|
|
enrichment: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
process.stdout.write = originalStdoutWrite;
|
|
});
|
|
|
|
const searchOpts = {
|
|
jobage: 9999,
|
|
page: 1,
|
|
limit: 25,
|
|
format: "json" as const,
|
|
regions: [] as string[],
|
|
countries: [] as string[],
|
|
cities: [] as string[],
|
|
seniority: [] as string[],
|
|
category: [] as string[],
|
|
skills: [] as string[],
|
|
facets: {} as Record<string, string[]>,
|
|
};
|
|
|
|
describe("runSearch (mocked fetch)", () => {
|
|
test("emits the contract envelope with meta.count/page/total", async () => {
|
|
mockFetch(200, { data: [job()], meta: { total: 42, limit: 25, offset: 0 } });
|
|
const out = captureStdout();
|
|
|
|
const code = await runSearch({ ...searchOpts, query: "backend" });
|
|
expect(code).toBe(0);
|
|
|
|
const parsed = JSON.parse(out.get());
|
|
expect(parsed.meta).toEqual({ count: 1, page: 1, total: 42 });
|
|
expect(parsed.results).toHaveLength(1);
|
|
expect(parsed.results[0].id).toBe("backend-engineer-acme-ab12cd34");
|
|
expect(parsed.results[0].date).toBe("2026-07-06T00:00:00Z");
|
|
});
|
|
|
|
test("empty result set yields an empty results array", async () => {
|
|
mockFetch(200, { data: [], meta: { total: 0 } });
|
|
const out = captureStdout();
|
|
|
|
const code = await runSearch({ ...searchOpts, query: "nothing-matches-xyz" });
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(out.get()).results).toHaveLength(0);
|
|
});
|
|
|
|
test("network failure exits 1 with SEARCH_FAILED", async () => {
|
|
globalThis.fetch = (async () => {
|
|
throw new Error("ECONNREFUSED");
|
|
}) as typeof fetch;
|
|
let err = "";
|
|
const origErr = process.stderr.write;
|
|
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
err += chunk.toString();
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
|
|
const code = await runSearch({ ...searchOpts, query: "backend" });
|
|
process.stderr.write = origErr;
|
|
|
|
expect(code).toBe(1);
|
|
expect(JSON.parse(err).code).toBe("SEARCH_FAILED");
|
|
});
|
|
});
|
|
|
|
describe("runDetail (mocked fetch)", () => {
|
|
test("prints the reshaped detail with a cleaned description", async () => {
|
|
mockFetch(200, { data: job() });
|
|
const out = captureStdout();
|
|
|
|
const code = await runDetail({ id: "backend-engineer-acme-ab12cd34", format: "json" });
|
|
expect(code).toBe(0);
|
|
|
|
const parsed = JSON.parse(out.get());
|
|
expect(parsed.id).toBe("backend-engineer-acme-ab12cd34");
|
|
expect(parsed.description).toBe("Build things");
|
|
expect(parsed.cities).toEqual(["Berlin"]);
|
|
});
|
|
|
|
test("404 exits 1 with NOT_FOUND", async () => {
|
|
mockFetch(404, { error: "not found" });
|
|
let err = "";
|
|
const origErr = process.stderr.write;
|
|
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
err += chunk.toString();
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
|
|
const code = await runDetail({ id: "does-not-exist", format: "json" });
|
|
process.stderr.write = origErr;
|
|
|
|
expect(code).toBe(1);
|
|
expect(JSON.parse(err).code).toBe("NOT_FOUND");
|
|
});
|
|
});
|