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(`
+ -
+
+
+ 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");
+ });
+});