mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +00:00
test(cli): cover Jobbank and Jobdanmark contracts (#148)
Jobbank and Jobdanmark each had only one narrow test, leaving required-argument errors, RSS normalization, JSON-LD variants, and malformed-page handling unprotected. Add network-free fixture and subprocess tests for repeated RSS filters, description and ID parsing, stderr JSON errors, Bunli numeric validation, JSON-LD objects and arrays, optional fields, not-found pages, and parse failures. The suites now cover eight Jobbank cases and six Jobdanmark cases without making live portal requests.
This commit is contained in:
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(`<?xml version="1.0"?>
|
||||||
|
<rss><channel><item>
|
||||||
|
<title><![CDATA[Data Scientist]]></title>
|
||||||
|
<description><![CDATA[Fuldtidsjob hos Acme A/S, København (Ansøgningsfrist: 31.07.2026)]]></description>
|
||||||
|
<link>https://jobbank.dk/job/12345/acme/data-scientist</link>
|
||||||
|
<pubDate>Mon, 13 Jul 2026 08:00:00 GMT</pubDate>
|
||||||
|
</item></channel></rss>`);
|
||||||
|
}) 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { parseJobPostingFromHtml } from "../src/commands/detail";
|
||||||
|
|
||||||
|
function pageWithScripts(...scripts: string[]): string {
|
||||||
|
return `<html><head>${scripts
|
||||||
|
.map((content) => `<script type="application/ld+json">${content}</script>`)
|
||||||
|
.join("")}</head><body></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "<p>Build reliable pipelines.</p>",
|
||||||
|
};
|
||||||
|
|
||||||
|
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: "<p>Build reliable pipelines.</p>",
|
||||||
|
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(
|
||||||
|
"<html><head><title>404 | Jobdanmark</title></head><body></body></html>",
|
||||||
|
"missing",
|
||||||
|
"https://jobdanmark.dk/job/missing",
|
||||||
|
),
|
||||||
|
).toThrow("NOT_FOUND");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
parseJobPostingFromHtml(
|
||||||
|
"<html><head><title>Jobdanmark</title></head><body></body></html>",
|
||||||
|
"broken",
|
||||||
|
"https://jobdanmark.dk/job/broken",
|
||||||
|
),
|
||||||
|
).toThrow("Failed to parse job page HTML");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user