feat: add skills

This commit is contained in:
Prad Nukala committed 2026-08-26 10:35:34 -04:00
1 parent 6984212b17
commit c50aa92862
150 files changed
+12702

No files matched your search

@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { runCLI } from "./helpers";
describe("Jobnet CLI error contract", () => {
test("detail without an ID 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: "Job ad ID is required",
code: "MISSING_REQUIRED",
});
});
test("occupations without a search string fails before making a request", async () => {
const result = await runCLI(["occupations"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(JSON.parse(result.stderr)).toEqual({
error: "--search-string is required",
code: "MISSING_REQUIRED",
});
});
test("suggestions without a query fails before making a request", async () => {
const result = await runCLI(["suggestions"]);
expect(result.exitCode).toBe(1);
expect(result.stdout).toBe("");
expect(JSON.parse(result.stderr)).toEqual({
error: "--query 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 / --per-page=-1, and slice(0, -1)
// then silently dropped the last result instead of erroring. The --radius
// 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("Jobnet 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 API", async () => {
const result = await runCLI(["search", "--page=0"]);
expectValidationError(result, "page");
});
test("search --per-page=-5 is rejected", async () => {
const result = await runCLI(["search", "--per-page=-5"]);
expectValidationError(result, "per-page");
});
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("search --radius=-10 is rejected", async () => {
const result = await runCLI(["search", "--radius=-10"]);
expectValidationError(result, "radius");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --radius=2.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--radius=2.5"]);
expectValidationError(result, "radius");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("occupations --per-page=-1 is rejected", async () => {
const result = await runCLI(["occupations", "--per-page=-1"]);
expectValidationError(result, "per-page");
});
test("suggestions --limit=-1 is rejected", async () => {
const result = await runCLI(["suggestions", "--limit=-1"]);
expectValidationError(result, "limit");
});
test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => {
const result = await runCLI(["occupations", "--per-page=5"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: "--search-string 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", "--search-string", "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,121 @@
import { describe, expect, test } from "bun:test";
import { formatDetailPlain, prepareDetail, type DetailApiResponse } from "../src/commands/detail";
function detail(overrides: Partial<DetailApiResponse> = {}): DetailApiResponse {
return {
id: "job-1",
title: "Data Engineer",
body: "<p>Build ETL &amp; analytics flows.</p><p>Use SQL&nbsp;daily.</p>",
publicationDateTime: "2026-07-01T09:00:00+02:00",
unpublicationDateTime: "2026-08-01T23:59:00+02:00",
approvalStatus: "Godkendt",
views: 10,
createdDateTime: "2026-07-01T08:00:00+02:00",
updatedDateTime: "2026-07-01T08:30:00+02:00",
isAnonymousEmployer: false,
hasLogo: true,
logoUrl: "/logo/job-1",
employer: {
cvrNumber: "12345678",
pNumber: "87654321",
name: "Acme",
hasCompanyLogo: true,
},
job: {
type: "FullTime",
address: {
streetName: "Examplevej 1",
city: "København",
postalCode: "2100",
municipality: "København",
countryCode: "DK",
countryName: "Danmark",
},
noFixedWorkplace: false,
isLimitedPeriod: false,
isDisabilityFriendly: false,
isPartTime: false,
employmentDate: null,
conceptUriDa: null,
preferredLabelDa: null,
driversLicenses: [],
classifications: [],
shifts: [],
isFavorite: false,
},
application: {
deadlineDate: "2026-08-01T23:59:00+02:00",
availablePositions: 2,
contactPersons: [],
url: "https://example.test/apply",
urlText: "Apply",
isApplicationDeadlineASAP: false,
},
organisationTypeId: null,
user: null,
...overrides,
};
}
describe("formatDetailPlain", () => {
test("renders the plain view with cleaned body text and apply URL", () => {
const formatted = formatDetailPlain(detail());
expect(formatted).toContain("Title: Data Engineer");
expect(formatted).toContain("Employer: Acme");
expect(formatted).toContain("Location: København, Danmark");
expect(formatted).toContain("Apply: https://example.test/apply");
expect(formatted).toContain("Build ETL & analytics flows. Use SQL daily.");
expect(formatted).not.toContain("<p>");
expect(formatted).not.toContain("&amp;");
});
test("uses placeholders when optional city, deadline, and apply URL are absent", () => {
const formatted = formatDetailPlain(
detail({
job: {
...detail().job,
address: {
...detail().job.address,
city: null,
},
},
application: {
...detail().application,
deadlineDate: null,
url: null,
},
}),
);
expect(formatted).toContain("Location: -, Danmark");
expect(formatted).toContain("Deadline: -");
expect(formatted).not.toContain("Apply:");
});
});
describe("prepareDetail deadline sentinel", () => {
// The API's "deadline not disclosed" sentinel is 1900-01-01 (paired with
// isApplicationDeadlineASAP / applicationDeadlineStatus). search maps it to
// null and has a test pinning that; detail dumped the raw response, so an
// undisclosed deadline read as 126 years expired and /rank's sweep would
// retire the job instantly (review finding F33, 2026-08-19).
test("maps the 1900-01-01 undisclosed sentinel to null", () => {
const data = detail();
data.application.deadlineDate = "1900-01-01T00:00:00+01:00";
expect(prepareDetail(data).application.deadlineDate).toBeNull();
});
test("keeps a real deadline unchanged", () => {
const data = detail();
data.application.deadlineDate = "2026-09-01T00:00:00+02:00";
expect(prepareDetail(data).application.deadlineDate).toBe("2026-09-01T00:00:00+02:00");
});
test("keeps a null deadline null", () => {
const data = detail();
data.application.deadlineDate = null;
expect(prepareDetail(data).application.deadlineDate).toBeNull();
});
});
+39
View File
@@ -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 { apiFetch } from "../src/helpers";
// A stalled upstream connection (accepted socket, no response) would otherwise
// hang the CLI forever - fetch has no default timeout. Assert every request
// carries an AbortSignal timeout. This fails on the pre-fix code (no signal).
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("apiFetch 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("{}", { status: 200 });
}) as unknown as typeof fetch;
await apiFetch("/search");
expect(init?.signal).toBeInstanceOf(AbortSignal);
});
});
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch } 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.
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("apiFetch 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 apiFetch<{ ok: boolean }>("/x");
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(apiFetch("/x")).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(apiFetch("/x")).rejects.toThrow(/500/);
expect(state.calls).toBe(7);
});
});
@@ -0,0 +1,163 @@
import { describe, expect, test } from "bun:test";
import {
buildSearchParams,
createSearchOutput,
type SearchApiResponse,
type SearchFlags,
} from "../src/commands/search";
const flags: SearchFlags = {
"search-string": "data engineer",
page: 2,
"per-page": 25,
order: "BestMatch",
region: "HovedstadenOgBornholm",
"work-hours": "FullTime",
duration: "Permanent",
"job-type": "Ordinaert",
"postal-code": "2100",
radius: 25,
"occupation-area": "10000",
"occupation-group": "10060",
limit: 1,
};
function apiResponse(): SearchApiResponse {
return {
totalJobAdCount: 2,
searchString: "data engineer",
searchFacets: {
regions: [{ type: "HovedstadenOgBornholm", jobAdCount: 2 }],
workHours: [{ type: "FullTime", jobAdCount: 2 }],
employmentDurations: [{ type: "Permanent", jobAdCount: 1 }],
occupationAreas: [{ identifier: "10000", jobAdCount: 2 }],
countries: [{ label: "Danmark", identifier: "DK", jobAdCount: 2 }],
},
jobAds: [
{
jobAdId: "job-1",
title: "Data Engineer",
hiringOrgName: "Acme",
occupation: null,
municipality: null,
postalCode: null,
postalDistrictName: null,
country: "Danmark",
publicationDate: "2026-07-01T00:00:00+02:00",
applicationDeadline: null,
applicationDeadlineStatus: null,
workHourPartTime: false,
isExternal: false,
hasLogo: false,
logoUrl: null,
cvr: null,
workPlaceAddress: "",
conceptUriDa: "http://example.test/occupation",
isSeen: false,
isFavorite: false,
description: "<p>Search results should not include this bulky HTML.</p>",
},
{
jobAdId: "job-2",
title: "Analytics Engineer",
hiringOrgName: "Example Co",
occupation: "Softwareudvikler",
municipality: "København",
postalCode: 2100,
postalDistrictName: "København Ø",
country: "Danmark",
publicationDate: "2026-07-02T00:00:00+02:00",
applicationDeadline: "2026-08-01T23:59:00+02:00",
applicationDeadlineStatus: "ExpirationDate",
workHourPartTime: false,
isExternal: true,
hasLogo: true,
logoUrl: "/logo/job-2",
cvr: "12345678",
workPlaceAddress: "Examplevej 1",
isSeen: false,
isFavorite: true,
},
],
};
}
describe("Jobnet search normalization", () => {
test("builds the API query with required paging and optional filters", () => {
expect(buildSearchParams(flags)).toEqual({
resultsPerPage: "25",
pageNumber: "2",
orderType: "BestMatch",
searchString: "data engineer",
regions: "HovedstadenOgBornholm",
workHoursType: "FullTime",
employmentDurationType: "Permanent",
jobAnnouncementType: "Ordinaert",
postalCode: "2100",
kmRadius: "25",
occupationAreas: "10000",
occupationGroups: "10060",
});
});
test("omits radius when postal-code is absent", () => {
const params = buildSearchParams({ ...flags, "postal-code": undefined });
expect(params.postalCode).toBeUndefined();
expect(params.kmRadius).toBeUndefined();
});
test("creates the documented output envelope and omits bulky descriptions", () => {
const output = createSearchOutput(apiResponse(), flags);
expect(output.meta).toEqual({
totalJobAdCount: 2,
pageNumber: 2,
resultsPerPage: 25,
searchString: "data engineer",
});
expect(output.facets.regions).toEqual([{ type: "HovedstadenOgBornholm", jobAdCount: 2 }]);
expect(output.results).toHaveLength(1);
expect(output.results[0]).toMatchObject({
jobAdId: "job-1",
occupation: null,
municipality: null,
postalCode: null,
applicationDeadline: null,
workPlaceAddress: "",
});
expect("description" in output.results[0]).toBe(false);
});
test("additively emits the /scrape contract fields (company, location, date, deadline, url)", () => {
const output = createSearchOutput(apiResponse(), { ...flags, limit: undefined });
expect(output.results).toHaveLength(2);
expect(output.results[0]).toMatchObject({
company: "Acme",
location: null,
date: "2026-07-01",
deadline: null,
url: "https://jobnet.dk/find-job/job-1",
});
expect(output.results[1]).toMatchObject({
company: "Example Co",
location: "København Ø",
date: "2026-07-02",
deadline: "2026-08-01",
url: "https://jobnet.dk/find-job/job-2",
});
expect(output.results[0].hiringOrgName).toBe("Acme");
expect(output.results[1].applicationDeadline).toBe("2026-08-01T23:59:00+02:00");
});
test("maps Jobnet's undisclosed-deadline sentinel (1900-01-01) to null", () => {
const response = apiResponse();
response.jobAds[0].applicationDeadline = "1900-01-01T00:00:00+01:00";
response.jobAds[0].applicationDeadlineStatus = "NotDisclosed";
const output = createSearchOutput(response, { ...flags, limit: undefined });
expect(output.results[0].deadline).toBeNull();
expect(output.results[1].deadline).toBe("2026-08-01");
});
});
+27
View File
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, test } from "bun:test";
import { apiFetch, 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;
});
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("/search");
const headers = init?.headers as Record<string, string> | Headers | undefined;
const value =
headers instanceof Headers ? headers.get("User-Agent") : headers?.["User-Agent"];
expect(value).toBe(USER_AGENT);
});
});