Files
ai-job-search/.agents/skills/freehire-search/cli/tests/parsing.test.ts
T
447fec0302 fix(freehire-search): reject detail URLs on other hosts instead of asking the API for their last segment (#622) (#623)
* fix(freehire-search): reject detail URLs on other hosts instead of asking the API for their last segment

normalizeSlug took the segment after "/jobs/" from ANY string, so a
Greenhouse apply link - which is what a freehire search result's own url
field carries - was reduced to its last path segment and detail
requested /api/v1/jobs/1, then reported NOT_FOUND for a URL that was
never a freehire posting. Every other portal CLI rejects an off-host
detail URL with BAD_ID (#447, #430, #499); freehire was the last one
trusting the path.

Parse URLs for real: freehire.me (apex or any subdomain, scheme optional)
or the host of FREEHIRE_API_URL for a self-hosted instance, plus a
/jobs/<slug> path, yields the slug; other hosts, look-alike and userinfo
hosts, and non-job freehire pages exit 1 with BAD_ID before any request.
Bare slugs are unchanged.

Five new normalizeSlug cases and a handler-level test that pastes the
search fixture's own apply URL into detail with fetch stubbed; the
off-host cases fail on master.

* changelog: place the #623 entry under [Unreleased]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:20:06 +02:00

191 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, test, expect } from "bun:test";
import { cleanHtml, contractDate, normalizeSlug, toResult, toDetail, type FreehireJob } from "../src/helpers";
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: "<ul><li>Build APIs</li><li>Ship &amp; iterate</li></ul>",
skills: ["go", "kubernetes"],
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,
};
}
describe("toResult — reshape into the portal-skill contract", () => {
test("maps public_slug -> id and posted_at -> date", () => {
const r = toResult(job());
expect(r.id).toBe("backend-engineer-acme-ab12cd34");
// The contract shape is the date part only, like every other portal CLI.
expect(r.date).toBe("2026-07-06");
});
test("carries the required contract fields", () => {
const r = toResult(job());
expect(r).toMatchObject({
title: "Backend Engineer",
company: "Acme",
company_slug: "acme",
location: "Berlin, Germany",
url: "https://boards.greenhouse.io/acme/jobs/1",
});
});
test("missing values are null, not omitted", () => {
const r = toResult(job({ company: "", location: "", posted_at: null, work_mode: undefined }));
expect(r.company).toBeNull();
expect(r.location).toBeNull();
expect(r.date).toBeNull();
expect(r.work_mode).toBeNull();
});
test("decodes HTML entities and trims whitespace in the title", () => {
// Real title from the index (freehire slug intern-fullstack-amp-ai-innovation-noise-edaawqq2).
expect(toResult(job({ title: "Intern - Fullstack &amp; AI Innovation " })).title).toBe(
"Intern - Fullstack & AI Innovation",
);
expect(toResult(job({ title: "R&#38;D Engineer" })).title).toBe("R&D Engineer");
expect(toResult(job({ title: "SDE Intern - Frontend " })).title).toBe("SDE Intern - Frontend");
});
test("a whitespace-only title still falls back to (untitled)", () => {
expect(toResult(job({ title: " " })).title).toBe("(untitled)");
expect(toResult(job({ title: "" })).title).toBe("(untitled)");
});
test("detail inherits the cleaned title", () => {
expect(toDetail(job({ title: "Data &amp; Analytics Intern" })).title).toBe("Data & Analytics Intern");
});
});
describe("toDetail — adds cleaned description + enrichment", () => {
test("strips HTML and decodes entities in the description", () => {
const d = toDetail(job());
expect(d.description).toBe("Build APIs\nShip & iterate");
});
test("surfaces enrichment fields and a formatted salary", () => {
const d = toDetail(
job({
enrichment: { seniority: "senior", category: "backend", employment_type: "full_time", salary_min: 90000, salary_max: 120000, salary_currency: "EUR" },
}),
);
expect(d.seniority).toBe("senior");
expect(d.category).toBe("backend");
expect(d.salary).toBe("EUR 90000–120000");
});
test("keeps the salary period, so a yearly figure is not read as monthly", () => {
const d = toDetail(
job({ enrichment: { salary_min: 300000, salary_max: 300000, salary_currency: "INR", salary_period: "year" } }),
);
expect(d.salary).toBe("INR 300000–300000/year");
});
test("a single-bound salary keeps its period too", () => {
const d = toDetail(job({ enrichment: { salary_min: 12000, salary_currency: "INR", salary_period: "month" } }));
expect(d.salary).toBe("INR 12000/month");
});
test("null enrichment fields when the enrichment object is empty", () => {
const d = toDetail(job({ enrichment: {} }));
expect(d.seniority).toBeNull();
expect(d.salary).toBeNull();
});
});
describe("cleanHtml", () => {
test("preserves paragraph breaks between blocks", () => {
expect(cleanHtml("<p>One</p><p>Two</p>")).toBe("One\nTwo");
});
test("decodes hex numeric entities", () => {
expect(cleanHtml("Caf&#xE9;")).toBe("Café");
});
test("returns null for empty input", () => {
expect(cleanHtml("")).toBeNull();
expect(cleanHtml(null)).toBeNull();
});
});
describe("contractDate", () => {
test("cuts the API's ISO timestamp to YYYY-MM-DD", () => {
expect(contractDate("2026-07-06T00:00:00Z")).toBe("2026-07-06");
expect(contractDate("2026-07-06T15:30:00+02:00")).toBe("2026-07-06");
expect(contractDate("2026-07-06")).toBe("2026-07-06");
});
test("is null for an absent or non-date value", () => {
expect(contractDate(null)).toBeNull();
expect(contractDate(undefined)).toBeNull();
expect(contractDate("")).toBeNull();
expect(contractDate("soon")).toBeNull();
});
test("detail carries the same shape as search", () => {
expect(toDetail(job()).date).toBe("2026-07-06");
});
});
describe("normalizeSlug", () => {
test("accepts a bare slug", () => {
expect(normalizeSlug("golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
});
test("extracts the slug from a /jobs/<slug> URL", () => {
expect(normalizeSlug("https://freehire.me/jobs/golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
});
test("rejects a non-slug string", () => {
expect(normalizeSlug("not a slug!")).toBeNull();
expect(normalizeSlug("")).toBeNull();
});
test("accepts subdomains, a scheme-less host, a trailing slash and a query string", () => {
expect(normalizeSlug("https://www.freehire.me/jobs/golang-zensar-2bxu6dxm/")).toBe("golang-zensar-2bxu6dxm");
expect(normalizeSlug("freehire.me/jobs/golang-zensar-2bxu6dxm?utm_source=x")).toBe("golang-zensar-2bxu6dxm");
});
test("rejects a /jobs/ URL on another host instead of asking the API for its last segment", () => {
// The search fixture's own `url` is a Greenhouse apply link; pasting it
// back into detail used to request /api/v1/jobs/1 and report NOT_FOUND.
expect(normalizeSlug("https://boards.greenhouse.io/acme/jobs/1")).toBeNull();
expect(normalizeSlug("https://jobs.lever.co/acme/jobs/abc")).toBeNull();
});
test("rejects look-alike and userinfo hosts", () => {
expect(normalizeSlug("https://freehire.me.evil.io/jobs/x")).toBeNull();
expect(normalizeSlug("https://notfreehire.me/jobs/x")).toBeNull();
expect(normalizeSlug("https://freehire.me@evil.io/jobs/x")).toBeNull();
});
test("rejects a freehire.me URL that is not a job page", () => {
expect(normalizeSlug("https://freehire.me/companies/acme")).toBeNull();
expect(normalizeSlug("https://freehire.me/jobs/")).toBeNull();
});
test("requires the path to be exactly /jobs/<slug>", () => {
// The slug is the whole path after /jobs/: a nested path, a prefix before
// /jobs/, or a slug with characters outside the slug alphabet is not a
// freehire job page, even on the right host.
expect(normalizeSlug("https://freehire.me/jobs/my-slug/apply")).toBeNull();
expect(normalizeSlug("https://freehire.me/en/jobs/my-slug")).toBeNull();
expect(normalizeSlug("https://freehire.me/jobs/my_slug")).toBeNull();
expect(normalizeSlug("https://freehire.me/jobs/my-slug#details")).toBe("my-slug");
});
test("accepts the self-hosted base URL's host", () => {
const saved = process.env.FREEHIRE_API_URL;
process.env.FREEHIRE_API_URL = "http://localhost:8080";
try {
expect(normalizeSlug("http://localhost:8080/jobs/my-slug")).toBe("my-slug");
expect(normalizeSlug("https://boards.greenhouse.io/acme/jobs/1")).toBeNull();
} finally {
if (saved === undefined) delete process.env.FREEHIRE_API_URL;
else process.env.FREEHIRE_API_URL = saved;
}
});
});