diff --git a/.agents/skills/jobbank-search/cli/tests/cli-contract.test.ts b/.agents/skills/jobbank-search/cli/tests/cli-contract.test.ts new file mode 100644 index 0000000..5ede28f --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/cli-contract.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { runCLI } from "./helpers"; + +function parseError(stderr: string): { error: string; code: string } { + return JSON.parse(stderr); +} + +describe("Jobbank CLI error contract", () => { + test("search without a filter fails with JSON on stderr", async () => { + const result = await runCLI(["search"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(parseError(result.stderr)).toEqual({ + error: "--key or at least one filter is required", + code: "MISSING_REQUIRED", + }); + }); + + test("detail without an ID fails before making a request", async () => { + const result = await runCLI(["detail"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(parseError(result.stderr)).toEqual({ + error: "Job ID is required", + code: "MISSING_REQUIRED", + }); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts b/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts new file mode 100644 index 0000000..672019f --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/rss-parsing.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + extractJobIdFromUrl, + parseRssDescription, + rssFetch, + USER_AGENT, +} from "../src/helpers"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("rssFetch", () => { + test("serializes repeated filters and parses CDATA and plain RSS fields", async () => { + let requestedUrl = ""; + let requestedUserAgent = ""; + globalThis.fetch = (async (input, init) => { + requestedUrl = String(input); + requestedUserAgent = new Headers(init?.headers).get("User-Agent") ?? ""; + return new Response(` + + <![CDATA[Data Scientist]]> + + https://jobbank.dk/job/12345/acme/data-scientist + Mon, 13 Jul 2026 08:00:00 GMT + `); + }) as typeof fetch; + + const items = await rssFetch({ key: "data science", cvtype: ["3", "6"] }); + const url = new URL(requestedUrl); + + expect(url.pathname).toBe("/job/rss"); + expect(url.searchParams.get("key")).toBe("data science"); + expect(url.searchParams.getAll("cvtype")).toEqual(["3", "6"]); + expect(requestedUserAgent).toBe(USER_AGENT); + expect(items).toEqual([ + { + title: "Data Scientist", + description: "Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)", + link: "https://jobbank.dk/job/12345/acme/data-scientist", + pubDate: "Mon, 13 Jul 2026 08:00:00 GMT", + }, + ]); + }); +}); + +describe("parseRssDescription", () => { + test("separates multiple job types, company, location, and deadline", () => { + expect( + parseRssDescription( + "Fuldtidsjob, Graduate/trainee hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)", + ), + ).toEqual({ + jobType: "Fuldtidsjob, Graduate/trainee", + company: "Acme A/S", + location: "København", + deadline: "31.07.2026", + }); + }); + + test("normalizes a rolling deadline to null", () => { + expect( + parseRssDescription("Deltidsjob hos Example ApS, Aarhus (Ansøgningsfrist: løbende)"), + ).toEqual({ + jobType: "Deltidsjob", + company: "Example ApS", + location: "Aarhus", + deadline: null, + }); + }); + + test("keeps an unstructured description as the company fallback", () => { + expect(parseRssDescription("Unstructured feed value")).toEqual({ + jobType: "", + company: "Unstructured feed value", + location: "", + deadline: null, + }); + }); +}); + +describe("extractJobIdFromUrl", () => { + test("extracts numeric IDs and rejects unsupported URL shapes", () => { + expect(extractJobIdFromUrl("https://jobbank.dk/job/12345/acme/role")).toBe("12345"); + expect(extractJobIdFromUrl("https://jobbank.dk/job/not-a-number/acme/role")).toBe(""); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/tests/cli-contract.test.ts b/.agents/skills/jobdanmark-search/cli/tests/cli-contract.test.ts new file mode 100644 index 0000000..47803a5 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/cli-contract.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { runCLI } from "./helpers"; + +describe("Jobdanmark CLI error contract", () => { + test("detail without a slug fails with JSON on stderr", async () => { + const result = await runCLI(["detail"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr)).toEqual({ + error: "slug argument is required", + code: "MISSING_REQUIRED", + }); + }); + + test("an invalid numeric option fails before making a request", async () => { + const result = await runCLI(["search", "--page", "not-a-number"]); + const error = JSON.parse(result.stderr); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(error.ok).toBe(false); + expect(error.error.kind).toBe("validation"); + expect(error.error.option).toBe("page"); + expect(error.error.message).toContain("Expected number"); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/tests/detail-jsonld.test.ts b/.agents/skills/jobdanmark-search/cli/tests/detail-jsonld.test.ts new file mode 100644 index 0000000..208d82e --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/detail-jsonld.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { parseJobPostingFromHtml } from "../src/commands/detail"; + +function pageWithScripts(...scripts: string[]): string { + return `${scripts + .map((content) => ``) + .join("")}`; +} + +describe("parseJobPostingFromHtml JSON-LD", () => { + test("normalizes a JobPosting object and scalar employment type", () => { + const posting = { + "@context": "https://schema.org", + "@type": "JobPosting", + title: "Data Engineer", + datePosted: "2026-07-13", + validThrough: "2026-08-13", + employmentType: "FULL_TIME", + hiringOrganization: { name: "Acme A/S", logo: "https://cdn.example/logo.png" }, + jobLocation: { + address: { + streetAddress: "Testvej 1", + addressLocality: "Odense", + addressRegion: "Syddanmark", + postalCode: "5000", + addressCountry: "DK", + }, + }, + description: "

Build reliable pipelines.

", + }; + + const parsed = parseJobPostingFromHtml( + pageWithScripts(JSON.stringify(posting)), + "data-engineer", + "https://jobdanmark.dk/job/data-engineer", + ); + + expect(parsed).toEqual({ + slug: "data-engineer", + url: "https://jobdanmark.dk/job/data-engineer", + title: "Data Engineer", + datePosted: "2026-07-13", + validThrough: "2026-08-13", + employmentType: ["FULL_TIME"], + hiringOrganization: { name: "Acme A/S", logo: "https://cdn.example/logo.png" }, + jobLocation: { + streetAddress: "Testvej 1", + addressLocality: "Odense", + addressRegion: "Syddanmark", + postalCode: "5000", + addressCountry: "DK", + }, + description: "

Build reliable pipelines.

", + applyUrl: null, + }); + }); + + test("skips malformed scripts and finds a JobPosting in an array", () => { + const scripts = [ + "{not-json", + JSON.stringify([ + { "@type": "BreadcrumbList" }, + { "@type": "JobPosting", title: "Analyst", employmentType: ["FULL_TIME", "PART_TIME"] }, + ]), + ]; + + const parsed = parseJobPostingFromHtml( + pageWithScripts(...scripts), + "analyst", + "https://jobdanmark.dk/job/analyst", + ); + + expect(parsed.title).toBe("Analyst"); + expect(parsed.employmentType).toEqual(["FULL_TIME", "PART_TIME"]); + expect(parsed.validThrough).toBeNull(); + expect(parsed.hiringOrganization).toEqual({ name: "", logo: null }); + }); + + test("recognizes rendered not-found and unparseable pages", () => { + expect(() => + parseJobPostingFromHtml( + "404 | Jobdanmark", + "missing", + "https://jobdanmark.dk/job/missing", + ), + ).toThrow("NOT_FOUND"); + + expect(() => + parseJobPostingFromHtml( + "Jobdanmark", + "broken", + "https://jobdanmark.dk/job/broken", + ), + ).toThrow("Failed to parse job page HTML"); + }); +});