mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-08 16:07: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,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,82 @@
|
||||
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. The --company filter flag
|
||||
// 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("Jobbank CLI flag validation", () => {
|
||||
test("search --limit=-1 is rejected instead of silently dropping the last result", async () => {
|
||||
const result = await runCLI(["search", "--key", "test", "--limit=-1"]);
|
||||
expectValidationError(result, "limit");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||
});
|
||||
|
||||
test("search --limit=0 is rejected", async () => {
|
||||
const result = await runCLI(["search", "--key", "test", "--limit=0"]);
|
||||
expectValidationError(result, "limit");
|
||||
});
|
||||
|
||||
test("search --limit=1.5 is rejected as non-integer", async () => {
|
||||
const result = await runCLI(["search", "--key", "test", "--limit=1.5"]);
|
||||
expectValidationError(result, "limit");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||
});
|
||||
|
||||
test("search --company=-1 is rejected", async () => {
|
||||
const result = await runCLI(["search", "--key", "test", "--company=-1"]);
|
||||
expectValidationError(result, "company");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
|
||||
});
|
||||
|
||||
test("search --company=1.5 is rejected as non-integer", async () => {
|
||||
const result = await runCLI(["search", "--key", "test", "--company=1.5"]);
|
||||
expectValidationError(result, "company");
|
||||
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
|
||||
});
|
||||
|
||||
test("valid --limit passes schema validation (proven offline via the required-filter guard)", async () => {
|
||||
const result = await runCLI(["search", "--limit=5"]);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(JSON.parse(result.stderr)).toEqual({
|
||||
error: "--key or at least one filter 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", "--key", "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,40 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseJobPostingJsonLd } from "../src/helpers"
|
||||
|
||||
const script = (value: string) => `<script type="application/ld+json">${value}</script>`
|
||||
|
||||
describe("parseJobPostingJsonLd", () => {
|
||||
test("finds a JobPosting inside an @graph", () => {
|
||||
const html = script(
|
||||
JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{ "@type": "WebPage", name: "Jobs" },
|
||||
{ "@type": "JobPosting", title: "Data Engineer" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(parseJobPostingJsonLd(html)).toEqual({
|
||||
"@type": "JobPosting",
|
||||
title: "Data Engineer",
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves top-level object and array support", () => {
|
||||
expect(parseJobPostingJsonLd(script('{"@type":"JobPosting","title":"One"}'))?.title).toBe("One")
|
||||
expect(
|
||||
parseJobPostingJsonLd(script('[{"@type":"WebPage"},{"@type":"JobPosting","title":"Two"}]'))?.title,
|
||||
).toBe("Two")
|
||||
})
|
||||
|
||||
test("skips malformed scripts and checks later JSON-LD", () => {
|
||||
const html = `${script("{not-json")}${script('{"@type":"JobPosting","title":"Valid"}')}`
|
||||
|
||||
expect(parseJobPostingJsonLd(html)?.title).toBe("Valid")
|
||||
})
|
||||
|
||||
test("returns null when no JobPosting exists", () => {
|
||||
expect(parseJobPostingJsonLd(script('{"@graph":[{"@type":"WebPage"}]}'))).toBeNull()
|
||||
})
|
||||
})
|
||||
+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,23 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { fetchWithUA } from "../src/helpers";
|
||||
|
||||
// A stalled upstream connection (accepted socket, no response) would otherwise
|
||||
// hang the CLI forever - fetch has no default timeout. Assert the request
|
||||
// wrapper carries an AbortSignal timeout. Fails on the pre-fix code (no signal).
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("fetchWithUA request timeout", () => {
|
||||
test("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("<rss></rss>", { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await fetchWithUA("https://jobbank.dk/job/rss");
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { fetchWithUA } 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.
|
||||
//
|
||||
// fetchWithUA deliberately RETURNS non-retry statuses instead of throwing -
|
||||
// callers own 4xx handling (e.g. rssFetch's Cloudflare 403 message). The 4xx
|
||||
// test pins that contract.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
describe("fetchWithUA retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response("ok", { status: 200 }),
|
||||
]);
|
||||
|
||||
const response = await fetchWithUA("https://jobbank.dk/x");
|
||||
expect(response.status).toBe(200);
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("returns a plain 4xx to the caller without retrying", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 403 })]);
|
||||
|
||||
const response = await fetchWithUA("https://jobbank.dk/x");
|
||||
expect(response.status).toBe(403);
|
||||
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(fetchWithUA("https://jobbank.dk/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { rssFetch } from "../src/helpers";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("rssFetch", () => {
|
||||
test("reports Cloudflare bot protection clearly", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response("<html><title>Just a moment...</title></html>", {
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
await expect(rssFetch({ key: "data" })).rejects.toThrow(/Cloudflare bot protection/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
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: "2026-07-31",
|
||||
});
|
||||
});
|
||||
|
||||
test("passes an unrecognized deadline shape through for downstream defensive parsing", () => {
|
||||
const parsed = parseRssDescription(
|
||||
"Fuldtidsjob hos Acme A/S, Odense (Ansøgningsfrist: snarest muligt)",
|
||||
);
|
||||
expect(parsed.deadline).toBe("snarest muligt");
|
||||
});
|
||||
|
||||
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,38 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeSearchItem } from "../src/commands/search";
|
||||
import type { RssItem } from "../src/helpers";
|
||||
|
||||
function rssItem(): RssItem {
|
||||
return {
|
||||
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: "Fri, 14 Aug 2026 09:30:00 +0200",
|
||||
};
|
||||
}
|
||||
|
||||
describe("Jobbank search normalization", () => {
|
||||
test("derives the /scrape contract date from posted as YYYY-MM-DD", () => {
|
||||
const result = normalizeSearchItem(rssItem());
|
||||
|
||||
expect(result.posted).toBe("2026-08-14T07:30:00.000Z");
|
||||
expect(result.date).toBe((result.posted as string).slice(0, 10));
|
||||
expect(result.date).toBe("2026-08-14");
|
||||
});
|
||||
|
||||
test("emits a null date when pubDate is absent (posted is empty)", () => {
|
||||
const result = normalizeSearchItem({ ...rssItem(), pubDate: "" });
|
||||
|
||||
expect(result.posted).toBe("");
|
||||
expect(result.date).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps the native fields alongside the contract date (additive)", () => {
|
||||
const result = normalizeSearchItem(rssItem());
|
||||
|
||||
expect(result.company).toBe("Acme A/S");
|
||||
expect(result.location).toBe("København");
|
||||
expect(result.url).toBe("https://jobbank.dk/job/12345/acme/data-scientist");
|
||||
expect(result.deadline).toBe("2026-07-31");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user