mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-07 23:47:38 +00:00
feat: add skills
This commit is contained in:
1 parent
6984212b17
commit
c50aa92862
150 files changed
+12702
No files matched your search
@@ -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,97 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { runCLI } from "./helpers";
|
||||
|
||||
// All cases fail schema validation (or the required-flag guard) before any
|
||||
// network request, so the suite is network-free. Regression context: a bare
|
||||
// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently
|
||||
// dropped the last result instead of erroring. Filter flags (--category,
|
||||
// --jobtitle-id) also accepted negative and fractional values that were
|
||||
// sent raw to the portal.
|
||||
|
||||
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
const error = JSON.parse(result.stderr);
|
||||
expect(error.ok).toBe(false);
|
||||
expect(error.error.kind).toBe("validation");
|
||||
expect(error.error.option).toBe(option);
|
||||
}
|
||||
|
||||
describe("Jobdanmark CLI flag validation", () => {
|
||||
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
|
||||
const result = await runCLI(["search", "--limit=-1"]);
|
||||
expectValidationError(result, "limit");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||
});
|
||||
|
||||
test("search --page=0 is rejected on the 1-indexed portal", async () => {
|
||||
const result = await runCLI(["search", "--page=0"]);
|
||||
expectValidationError(result, "page");
|
||||
});
|
||||
|
||||
test("search --category=-1 is rejected", async () => {
|
||||
const result = await runCLI(["search", "--category=-1"]);
|
||||
expectValidationError(result, "category");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||
});
|
||||
|
||||
test("search --jobtitle-id=1.5 is rejected as non-integer", async () => {
|
||||
const result = await runCLI(["search", "--jobtitle-id=1.5"]);
|
||||
expectValidationError(result, "jobtitle-id");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||
});
|
||||
|
||||
test("search --limit=1.5 is rejected as non-integer", async () => {
|
||||
const result = await runCLI(["search", "--limit=1.5"]);
|
||||
expectValidationError(result, "limit");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||
});
|
||||
|
||||
test("categories --limit=-1 is rejected", async () => {
|
||||
const result = await runCLI(["categories", "--limit=-1"]);
|
||||
expectValidationError(result, "limit");
|
||||
});
|
||||
|
||||
test("locations --limit=-1 is rejected", async () => {
|
||||
const result = await runCLI(["locations", "--query", "test", "--limit=-1"]);
|
||||
expectValidationError(result, "limit");
|
||||
});
|
||||
|
||||
test("autocomplete --limit=-1 is rejected", async () => {
|
||||
const result = await runCLI(["autocomplete", "--query", "test", "--limit=-1"]);
|
||||
expectValidationError(result, "limit");
|
||||
});
|
||||
|
||||
test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => {
|
||||
const result = await runCLI(["autocomplete", "--limit=3"]);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(JSON.parse(result.stderr)).toEqual({
|
||||
error: "--query is required",
|
||||
code: "MISSING_REQUIRED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("unknown flag rejection", () => {
|
||||
// add-portal.md's contract: "a bogus flag or missing required arg exits 1
|
||||
// with a JSON error on stderr". A silently discarded flag is worse than an
|
||||
// error: on jobdanmark a wrong flag name returned the entire database
|
||||
// (13,862 results) as if it matched the query (review finding F13,
|
||||
// 2026-08-19). Rejection happens before dispatch, so these are network-free.
|
||||
test("a bogus --flag exits 1 with a JSON error instead of being silently discarded", async () => {
|
||||
const result = await runCLI(["search", "--text", "test", "--bogus-flag", "xyz"]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
const error = JSON.parse(result.stderr);
|
||||
expect(error.code).toBe("UNKNOWN_FLAG");
|
||||
expect(error.error).toContain("--bogus-flag");
|
||||
});
|
||||
|
||||
test("--query (another portal's free-text flag) is rejected, not treated as no filter", async () => {
|
||||
const result = await runCLI(["search", "--query", "test"]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(JSON.parse(result.stderr).code).toBe("UNKNOWN_FLAG");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseJobPostingFromHtml } from "../src/commands/detail";
|
||||
|
||||
const HTML_WITHOUT_JSON_LD = `<!doctype html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<title>Journalistisk udvikler søges | jobdanmark</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="job-list-details">
|
||||
<div class="job-details-head row mx-0">
|
||||
<div class="company-logo col-auto">
|
||||
<img src="/media/jfm-logo.png?width=100" alt="JFM">
|
||||
</div>
|
||||
<h3 class="title">Journalistisk udvikler søges</h3>
|
||||
<a href="/virksomheder/jfm">JFM</a>
|
||||
<span>Banegårdspladsen 1, 5000 Odense C</span>
|
||||
</div>
|
||||
<p>Hvad nu hvis du med få klik kunne undersøge dit lokalområde?</p>
|
||||
<ul>
|
||||
<li>identificere relevante datasæt og muligheder</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="https://jfm.career.emply.com/da/apply/example" class="action primary count-click">Ansøg nu</a>
|
||||
<ul class="job-overview list-unstyled">
|
||||
<li><strong>Udgivet:</strong> 03-07-2026</li>
|
||||
<li><strong>Jobtype:</strong> Fuldtid</li>
|
||||
<li><strong>Arbejdssted:</strong> Banegårdspladsen 1, 5000 Odense C</li>
|
||||
<li><strong>Ansøgningsfrist:</strong> 02-08-2026 23.59</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
describe("parseJobPostingFromHtml", () => {
|
||||
test("falls back to rendered Jobdanmark HTML when JSON-LD is absent", () => {
|
||||
const parsed = parseJobPostingFromHtml(
|
||||
HTML_WITHOUT_JSON_LD,
|
||||
"journalistisk-udvikler",
|
||||
"https://jobdanmark.dk/job/journalistisk-udvikler",
|
||||
);
|
||||
|
||||
expect(parsed.title).toBe("Journalistisk udvikler søges");
|
||||
// The fallback must emit the same shapes as the JSON-LD branch: contract
|
||||
// dates, not the page's raw DD-MM-YYYY text (review finding F25, 2026-08-19).
|
||||
expect(parsed.datePosted).toBe("2026-07-03");
|
||||
expect(parsed.validThrough).toBe("2026-08-02");
|
||||
expect(parsed.employmentType).toEqual(["Fuldtid"]);
|
||||
expect(parsed.hiringOrganization.name).toBe("JFM");
|
||||
expect(parsed.hiringOrganization.logo).toBe("https://jobdanmark.dk/media/jfm-logo.png?width=100");
|
||||
expect(parsed.jobLocation.streetAddress).toBe("Banegårdspladsen 1, 5000 Odense C");
|
||||
expect(parsed.jobLocation.addressLocality).toBe("Odense C");
|
||||
expect(parsed.description).toContain("identificere relevante datasæt");
|
||||
expect(parsed.applyUrl).toBe("https://jfm.career.emply.com/da/apply/example");
|
||||
});
|
||||
|
||||
test("maps a rolling deadline (Løbende) to null in the HTML fallback", () => {
|
||||
// "Løbende" is free text meaning rolling/ongoing - jobbank's parser maps
|
||||
// its equivalent to null, and a stored "Løbende" deadline would hit every
|
||||
// date-arithmetic consumer (review finding F25, 2026-08-19).
|
||||
const html = HTML_WITHOUT_JSON_LD.replace(
|
||||
"<li><strong>Ansøgningsfrist:</strong> 02-08-2026 23.59</li>",
|
||||
"<li><strong>Ansøgningsfrist:</strong> Løbende</li>",
|
||||
);
|
||||
const parsed = parseJobPostingFromHtml(html, "s", "https://jobdanmark.dk/job/s");
|
||||
expect(parsed.validThrough).toBeNull();
|
||||
});
|
||||
|
||||
test("does not reject titles containing '404' mid-phrase", () => {
|
||||
const htmlWith404InTitle = HTML_WITHOUT_JSON_LD.replace(
|
||||
"<title>Journalistisk udvikler søges | jobdanmark</title>",
|
||||
"<title>HTTP 404 Page Designer | jobdanmark</title>",
|
||||
).replace(
|
||||
'<h3 class="title">Journalistisk udvikler søges</h3>',
|
||||
'<h3 class="title">HTTP 404 Page Designer</h3>',
|
||||
);
|
||||
|
||||
const parsed = parseJobPostingFromHtml(
|
||||
htmlWith404InTitle,
|
||||
"http-404-designer",
|
||||
"https://jobdanmark.dk/job/http-404-designer",
|
||||
);
|
||||
|
||||
expect(parsed.title).toBe("HTTP 404 Page Designer");
|
||||
expect(parsed.hiringOrganization.name).toBe("JFM");
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { join } from "path";
|
||||
|
||||
const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
|
||||
|
||||
export interface CLIResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export async function runCLI(args: string[]): Promise<CLIResult> {
|
||||
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
||||
}
|
||||
|
||||
export function parseJSON<T = unknown>(result: CLIResult): T {
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${result.exitCode}. stderr: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout) as T;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse JSON. stdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiFetch, apiPost } from "../src/helpers";
|
||||
|
||||
// A stalled upstream connection (accepted socket, no response) would otherwise
|
||||
// hang the CLI forever - fetch has no default timeout. Assert both request
|
||||
// wrappers carry an AbortSignal timeout. Fails on the pre-fix code (no signal).
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("request timeout", () => {
|
||||
test("apiFetch passes an AbortSignal timeout to fetch", async () => {
|
||||
let init: RequestInit | undefined;
|
||||
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
|
||||
init = i;
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await apiFetch("/search");
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
test("apiPost passes an AbortSignal timeout to fetch", async () => {
|
||||
let init: RequestInit | undefined;
|
||||
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
|
||||
init = i;
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await apiPost("/search", { q: "x" });
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiFetch, apiPost } from "../src/helpers";
|
||||
|
||||
// The portal contract requires backoff on 429/5xx. These tests pin the retry
|
||||
// loop offline: a stubbed fetch counts attempts, and a stubbed setTimeout
|
||||
// fires immediately so the exhaustion case does not sleep through the real
|
||||
// 500ms -> 5s backoff schedule. apiFetch and apiPost carry separate copies of
|
||||
// the loop, so both are exercised to keep them from drifting apart.
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
});
|
||||
|
||||
function instantTimers() {
|
||||
globalThis.setTimeout = ((fn: () => void) =>
|
||||
originalSetTimeout(fn, 0)) as unknown as typeof setTimeout;
|
||||
}
|
||||
|
||||
function stubFetch(responses: Array<() => Response>): { calls: number } {
|
||||
const state = { calls: 0 };
|
||||
globalThis.fetch = (async () => {
|
||||
const i = Math.min(state.calls, responses.length - 1);
|
||||
state.calls++;
|
||||
return responses[i]();
|
||||
}) as unknown as typeof fetch;
|
||||
return state;
|
||||
}
|
||||
|
||||
const wrappers: Array<[string, () => Promise<{ ok: boolean }>]> = [
|
||||
["apiFetch", () => apiFetch<{ ok: boolean }>("/x")],
|
||||
["apiPost", () => apiPost<{ ok: boolean }>("/x", {})],
|
||||
];
|
||||
|
||||
for (const [name, call] of wrappers) {
|
||||
describe(`${name} retry/backoff`, () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response('{"ok":true}', { status: 200 }),
|
||||
]);
|
||||
|
||||
const data = await call();
|
||||
expect(data.ok).toBe(true);
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("does not retry a plain 4xx", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 400 })]);
|
||||
|
||||
await expect(call()).rejects.toThrow(/400/);
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
|
||||
test("gives up after the initial attempt plus six retries on persistent 5xx", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([() => new Response("", { status: 500 })]);
|
||||
|
||||
await expect(call()).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeItem, type ApiSearchItem } from "../src/commands/search";
|
||||
|
||||
function item(): ApiSearchItem {
|
||||
return {
|
||||
title: "Softwareudvikler",
|
||||
companyName: "Statens It",
|
||||
companyLogo: null,
|
||||
companyLogoSvgMarkup: null,
|
||||
overlayColor: null,
|
||||
companyAddress: "Lautruphøj 2, 2750 Ballerup",
|
||||
jobTypes: ["fuldtid"],
|
||||
boostJob: false,
|
||||
publishedDate: "27-07-2026",
|
||||
applicationDeadline: "17-08-2026",
|
||||
url: "/job/softwareudvikler-til-statens-it",
|
||||
coverImage: null,
|
||||
silhouetteLogo: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Jobdanmark search normalization", () => {
|
||||
test("additively emits the /scrape contract fields (company, location, date, deadline)", () => {
|
||||
const result = normalizeItem(item());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
company: "Statens It",
|
||||
location: "Ballerup",
|
||||
date: "2026-07-27",
|
||||
deadline: "2026-08-17",
|
||||
url: "https://jobdanmark.dk/job/softwareudvikler-til-statens-it",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps a missing address zip and a null deadline to null", () => {
|
||||
const result = normalizeItem({
|
||||
...item(),
|
||||
companyAddress: "Lautruphøj 2",
|
||||
applicationDeadline: null,
|
||||
});
|
||||
|
||||
expect(result.location).toBeNull();
|
||||
expect(result.deadline).toBeNull();
|
||||
expect(result.company).toBe("Statens It");
|
||||
});
|
||||
|
||||
test("extracts the city when a comma follows the postcode (live jobdanmark shape)", () => {
|
||||
const result = normalizeItem({
|
||||
...item(),
|
||||
companyAddress: "2670, Greve",
|
||||
});
|
||||
|
||||
expect(result.location).toBe("Greve");
|
||||
});
|
||||
|
||||
test("trims trailing whitespace from the extracted city", () => {
|
||||
const result = normalizeItem({
|
||||
...item(),
|
||||
companyAddress: "7100, Vejle ",
|
||||
});
|
||||
|
||||
expect(result.location).toBe("Vejle");
|
||||
});
|
||||
|
||||
test("does not mistake a 4-digit street number for the postcode", () => {
|
||||
const result = normalizeItem({
|
||||
...item(),
|
||||
companyAddress: "Vejlevej 1234, 7100 Vejle",
|
||||
});
|
||||
|
||||
expect(result.location).toBe("Vejle");
|
||||
});
|
||||
|
||||
test("survives a null companyAddress from the API", () => {
|
||||
const result = normalizeItem({
|
||||
...item(),
|
||||
companyAddress: null,
|
||||
});
|
||||
|
||||
expect(result.location).toBeNull();
|
||||
expect(result.company).toBe("Statens It");
|
||||
});
|
||||
|
||||
test("keeps native fields unchanged (additive contract)", () => {
|
||||
const result = normalizeItem(item());
|
||||
|
||||
expect(result.companyName).toBe("Statens It");
|
||||
expect(result.publishedDate).toBe("27-07-2026");
|
||||
expect(result.applicationDeadline).toBe("17-08-2026");
|
||||
});
|
||||
|
||||
test("omits presentation-only keys the agent can never use", () => {
|
||||
// coverImage/companyLogo/companyLogoSvgMarkup/overlayColor/silhouetteLogo
|
||||
// were ~40% of a live search payload, fed into agent context on every
|
||||
// /scrape query (review finding F3, 2026-08-19). The #340 compatibility
|
||||
// duplicates (companyName, publishedDate, applicationDeadline) stay.
|
||||
const result = normalizeItem(item());
|
||||
|
||||
expect(result).not.toHaveProperty("coverImage");
|
||||
expect(result).not.toHaveProperty("companyLogo");
|
||||
expect(result).not.toHaveProperty("companyLogoSvgMarkup");
|
||||
expect(result).not.toHaveProperty("overlayColor");
|
||||
expect(result).not.toHaveProperty("silhouetteLogo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiFetch, apiPost, USER_AGENT } from "../src/helpers";
|
||||
|
||||
// Bun's fetch injects an anonymous default User-Agent (Bun/1.3.10) when code
|
||||
// sets none. This CLI should say who is asking, in the honest style jobindex
|
||||
// already uses on htmlFetch ("Mozilla/5.0 (compatible; jobindex-cli/1.0)").
|
||||
// Assert the header is present on every request. Fails on the pre-change code.
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
function headerValue(headers: RequestInit["headers"], name: string): string | null {
|
||||
if (headers instanceof Headers) return headers.get(name);
|
||||
if (Array.isArray(headers)) {
|
||||
const found = headers.find(([k]) => k === name);
|
||||
return found ? String(found[1]) : null;
|
||||
}
|
||||
const value = headers?.[name];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
describe("apiFetch user agent", () => {
|
||||
test("sends a User-Agent header", async () => {
|
||||
let init: RequestInit | undefined;
|
||||
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
|
||||
init = i;
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await apiFetch("/api/search/autocomplete", { q: "it" });
|
||||
expect(headerValue(init?.headers, "User-Agent")).toBe(USER_AGENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiPost user agent", () => {
|
||||
test("sends a User-Agent header alongside Content-Type", async () => {
|
||||
let init: RequestInit | undefined;
|
||||
globalThis.fetch = (async (_url: string | URL | Request, i?: RequestInit) => {
|
||||
init = i;
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await apiPost("/api/jobsearch/search/1", { q: "it" });
|
||||
expect(headerValue(init?.headers, "User-Agent")).toBe(USER_AGENT);
|
||||
expect(headerValue(init?.headers, "Content-Type")).toBe("application/json");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user