mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-02 21:22:50 +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,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 & analytics flows.</p><p>Use SQL 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("&");
|
||||
});
|
||||
|
||||
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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user