mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-08 07:57:39 +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,96 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { runCLI } from "./helpers";
|
||||
|
||||
// These assert on validation error codes that are emitted BEFORE any network
|
||||
// call (or independently of it), so the suite is network-free: a valid-flag case
|
||||
// still runs offline because it only checks the ABSENCE of a validation error.
|
||||
|
||||
function parsedStderr(stderr: string): { error?: string; code?: string } {
|
||||
try {
|
||||
return JSON.parse(stderr);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
describe("freehire CLI flag validation", () => {
|
||||
describe("numeric flag validation", () => {
|
||||
for (const name of ["jobage", "page", "limit"]) {
|
||||
test(`--${name} non-numeric exits 1 with BAD_ARG`, async () => {
|
||||
const result = await runCLI(["search", `--${name}`, "foo"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
const err = parsedStderr(result.stderr);
|
||||
expect(err.code).toBe("BAD_ARG");
|
||||
expect(err.error).toMatch(new RegExp(name));
|
||||
});
|
||||
}
|
||||
|
||||
test("valid integers produce no BAD_ARG", async () => {
|
||||
const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"]);
|
||||
expect(parsedStderr(result.stderr).code).not.toBe("BAD_ARG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("--description-format validation", () => {
|
||||
test("an unsupported format exits 1 with BAD_ARG", async () => {
|
||||
const result = await runCLI(["search", "--description-format", "tekst"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
const err = parsedStderr(result.stderr);
|
||||
expect(err.code).toBe("BAD_ARG");
|
||||
expect(err.error).toMatch(/description-format/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--facet validation", () => {
|
||||
test("a facet without '=' exits 1 with BAD_ARG", async () => {
|
||||
const result = await runCLI(["search", "--facet", "novalue"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detail argument validation", () => {
|
||||
test("missing slug exits 1 with NO_ID", async () => {
|
||||
const result = await runCLI(["detail"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(parsedStderr(result.stderr).code).toBe("NO_ID");
|
||||
});
|
||||
|
||||
test("an unparseable slug exits 1 with BAD_ID (no network)", async () => {
|
||||
const result = await runCLI(["detail", "not a slug!"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(parsedStderr(result.stderr).code).toBe("BAD_ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("command dispatch", () => {
|
||||
test("unknown command exits 1 with BAD_CMD", async () => {
|
||||
const result = await runCLI(["frobnicate"]);
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(parsedStderr(result.stderr).code).toBe("BAD_CMD");
|
||||
});
|
||||
|
||||
test("no command prints help and exits 1", async () => {
|
||||
const result = await runCLI([]);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toMatch(/USAGE/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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", "--query", "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");
|
||||
});
|
||||
});
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { runSearch } from "../src/commands/search";
|
||||
import { runDetail } from "../src/commands/detail";
|
||||
import type { FreehireJob } from "../src/helpers";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
|
||||
function captureStdout(): { get: () => string } {
|
||||
let buf = "";
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
buf += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
return { get: () => buf };
|
||||
}
|
||||
|
||||
/** Stub fetch with a canned response; the return value exposes the URL it was called with. */
|
||||
function mockFetch(status: number, body: unknown): { url: () => string } {
|
||||
let requested = "";
|
||||
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||
requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
return { url: () => requested };
|
||||
}
|
||||
|
||||
/** The query params of the URL the mocked fetch was called with. */
|
||||
function requestedParams(mock: { url: () => string }): URLSearchParams {
|
||||
return new URL(mock.url()).searchParams;
|
||||
}
|
||||
|
||||
function captureStderr(): { get: () => string; restore: () => void } {
|
||||
let buf = "";
|
||||
const original = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
buf += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
return { get: () => buf, restore: () => (process.stderr.write = original) };
|
||||
}
|
||||
|
||||
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
|
||||
return {
|
||||
public_slug: "backend-engineer-acme-ab12cd34",
|
||||
source: "greenhouse",
|
||||
external_id: "acme:1",
|
||||
url: "https://boards.greenhouse.io/acme/jobs/1",
|
||||
title: "Backend Engineer",
|
||||
company: "Acme",
|
||||
company_slug: "acme",
|
||||
location: "Berlin, Germany",
|
||||
description: "<p>Build things</p>",
|
||||
skills: ["go"],
|
||||
work_mode: "remote",
|
||||
regions: ["eu"],
|
||||
countries: ["de"],
|
||||
cities: ["Berlin"],
|
||||
posted_at: "2026-07-06T00:00:00Z",
|
||||
created_at: "2026-07-06T15:00:00Z",
|
||||
enrichment: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
});
|
||||
|
||||
const searchOpts = {
|
||||
jobage: 9999,
|
||||
page: 1,
|
||||
limit: 25,
|
||||
format: "json" as const,
|
||||
descriptionFormat: "markdown" as const,
|
||||
regions: [] as string[],
|
||||
countries: [] as string[],
|
||||
cities: [] as string[],
|
||||
seniority: [] as string[],
|
||||
category: [] as string[],
|
||||
skills: [] as string[],
|
||||
facets: {} as Record<string, string[]>,
|
||||
};
|
||||
|
||||
describe("runSearch (mocked fetch)", () => {
|
||||
test("emits the contract envelope with meta.count/page/total", async () => {
|
||||
mockFetch(200, { data: [job()], meta: { total: 42, limit: 25, offset: 0 } });
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "backend" });
|
||||
expect(code).toBe(0);
|
||||
|
||||
const parsed = JSON.parse(out.get());
|
||||
expect(parsed.meta).toEqual({ count: 1, page: 1, total: 42 });
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0].id).toBe("backend-engineer-acme-ab12cd34");
|
||||
expect(parsed.results[0].date).toBe("2026-07-06T00:00:00Z");
|
||||
});
|
||||
|
||||
test("queries the agent endpoint asking for full descriptions", async () => {
|
||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||
captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(new URL(mock.url()).pathname).toBe("/api/v1/agent/jobs/search");
|
||||
expect(requestedParams(mock).get("include_description")).toBe("true");
|
||||
expect(requestedParams(mock).get("description_format")).toBe("markdown");
|
||||
});
|
||||
|
||||
test("skips description hydration when includeDescription is false", async () => {
|
||||
// A default search hydrates ~20k tokens of description bodies per query,
|
||||
// while /scrape's Step 2 says to pre-filter by title/snippet before
|
||||
// reading bodies. --no-description keeps the discovery pass cheap;
|
||||
// hydration stays the default (review opportunity O1, 2026-08-19).
|
||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||
|
||||
const out = captureStdout();
|
||||
await runSearch({ ...searchOpts, query: "backend", includeDescription: false });
|
||||
|
||||
expect(requestedParams(mock).get("include_description")).toBe("false");
|
||||
expect(requestedParams(mock).get("description_format")).toBeNull();
|
||||
// The live API ignores include_description=false and sends bodies anyway
|
||||
// (verified 2026-08-19), so the lean guarantee is enforced client-side.
|
||||
const parsed = JSON.parse(out.get());
|
||||
expect(parsed.results[0].description).toBeNull();
|
||||
});
|
||||
|
||||
test("asks for the requested description format", async () => {
|
||||
const mock = mockFetch(200, { data: [job()], meta: { total: 1 } });
|
||||
captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, descriptionFormat: "text", query: "backend" });
|
||||
|
||||
expect(requestedParams(mock).get("description_format")).toBe("text");
|
||||
});
|
||||
|
||||
test("carries each hit's description verbatim, in the server's format", async () => {
|
||||
const markdown = "## About the role\n\n- Write Go\n- Ship things";
|
||||
mockFetch(200, { data: [job({ description: markdown })], meta: { total: 1 } });
|
||||
const out = captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(JSON.parse(out.get()).results[0].description).toBe(markdown);
|
||||
});
|
||||
|
||||
test("a hit with no description carries null, not an empty string", async () => {
|
||||
mockFetch(200, { data: [job({ description: "" })], meta: { total: 1 } });
|
||||
const out = captureStdout();
|
||||
|
||||
await runSearch({ ...searchOpts, query: "backend" });
|
||||
|
||||
expect(JSON.parse(out.get()).results[0].description).toBeNull();
|
||||
});
|
||||
|
||||
// A self-hosted freehire predating /agent/jobs/search answers 404, which apiGet
|
||||
// maps to null. Reporting that as "no results" would hide a broken endpoint
|
||||
// behind an empty, plausible-looking result set.
|
||||
test("a 404 from the search endpoint is an error, not an empty result set", async () => {
|
||||
mockFetch(404, { error: "not found" });
|
||||
const err = captureStderr();
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "backend" });
|
||||
err.restore();
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(out.get()).toBe("");
|
||||
expect(JSON.parse(err.get()).error).toMatch(/agent\/jobs\/search/);
|
||||
});
|
||||
|
||||
test("empty result set yields an empty results array", async () => {
|
||||
mockFetch(200, { data: [], meta: { total: 0 } });
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "nothing-matches-xyz" });
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(out.get()).results).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("network failure exits 1 with SEARCH_FAILED", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}) as typeof fetch;
|
||||
let err = "";
|
||||
const origErr = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
err += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
const code = await runSearch({ ...searchOpts, query: "backend" });
|
||||
process.stderr.write = origErr;
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(JSON.parse(err).code).toBe("SEARCH_FAILED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDetail (mocked fetch)", () => {
|
||||
test("prints the reshaped detail with a cleaned description", async () => {
|
||||
mockFetch(200, { data: job() });
|
||||
const out = captureStdout();
|
||||
|
||||
const code = await runDetail({ id: "backend-engineer-acme-ab12cd34", format: "json" });
|
||||
expect(code).toBe(0);
|
||||
|
||||
const parsed = JSON.parse(out.get());
|
||||
expect(parsed.id).toBe("backend-engineer-acme-ab12cd34");
|
||||
expect(parsed.description).toBe("Build things");
|
||||
expect(parsed.cities).toEqual(["Berlin"]);
|
||||
});
|
||||
|
||||
test("404 exits 1 with NOT_FOUND", async () => {
|
||||
mockFetch(404, { error: "not found" });
|
||||
let err = "";
|
||||
const origErr = process.stderr.write;
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
err += chunk.toString();
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
const code = await runDetail({ id: "does-not-exist", format: "json" });
|
||||
process.stderr.write = origErr;
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(JSON.parse(err).code).toBe("NOT_FOUND");
|
||||
});
|
||||
});
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
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[], env: Record<string, string> = {}): Promise<CLIResult> {
|
||||
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { cleanHtml, normalizeSlug, toResult, toDetail, type FreehireJob } from "../src/helpers";
|
||||
|
||||
function job(overrides: Partial<FreehireJob> = {}): FreehireJob {
|
||||
return {
|
||||
public_slug: "backend-engineer-acme-ab12cd34",
|
||||
source: "greenhouse",
|
||||
external_id: "acme:1",
|
||||
url: "https://boards.greenhouse.io/acme/jobs/1",
|
||||
title: "Backend Engineer",
|
||||
company: "Acme",
|
||||
company_slug: "acme",
|
||||
location: "Berlin, Germany",
|
||||
description: "<ul><li>Build APIs</li><li>Ship & iterate</li></ul>",
|
||||
skills: ["go", "kubernetes"],
|
||||
work_mode: "remote",
|
||||
regions: ["eu"],
|
||||
countries: ["de"],
|
||||
cities: ["Berlin"],
|
||||
posted_at: "2026-07-06T00:00:00Z",
|
||||
created_at: "2026-07-06T15:00:00Z",
|
||||
enrichment: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toResult — reshape into the portal-skill contract", () => {
|
||||
test("maps public_slug -> id and posted_at -> date", () => {
|
||||
const r = toResult(job());
|
||||
expect(r.id).toBe("backend-engineer-acme-ab12cd34");
|
||||
expect(r.date).toBe("2026-07-06T00:00:00Z");
|
||||
});
|
||||
|
||||
test("carries the required contract fields", () => {
|
||||
const r = toResult(job());
|
||||
expect(r).toMatchObject({
|
||||
title: "Backend Engineer",
|
||||
company: "Acme",
|
||||
company_slug: "acme",
|
||||
location: "Berlin, Germany",
|
||||
url: "https://boards.greenhouse.io/acme/jobs/1",
|
||||
});
|
||||
});
|
||||
|
||||
test("missing values are null, not omitted", () => {
|
||||
const r = toResult(job({ company: "", location: "", posted_at: null, work_mode: undefined }));
|
||||
expect(r.company).toBeNull();
|
||||
expect(r.location).toBeNull();
|
||||
expect(r.date).toBeNull();
|
||||
expect(r.work_mode).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDetail — adds cleaned description + enrichment", () => {
|
||||
test("strips HTML and decodes entities in the description", () => {
|
||||
const d = toDetail(job());
|
||||
expect(d.description).toBe("Build APIs\nShip & iterate");
|
||||
});
|
||||
|
||||
test("surfaces enrichment fields and a formatted salary", () => {
|
||||
const d = toDetail(
|
||||
job({
|
||||
enrichment: { seniority: "senior", category: "backend", employment_type: "full_time", salary_min: 90000, salary_max: 120000, salary_currency: "EUR" },
|
||||
}),
|
||||
);
|
||||
expect(d.seniority).toBe("senior");
|
||||
expect(d.category).toBe("backend");
|
||||
expect(d.salary).toBe("EUR 90000–120000");
|
||||
});
|
||||
|
||||
test("null enrichment fields when the enrichment object is empty", () => {
|
||||
const d = toDetail(job({ enrichment: {} }));
|
||||
expect(d.seniority).toBeNull();
|
||||
expect(d.salary).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanHtml", () => {
|
||||
test("preserves paragraph breaks between blocks", () => {
|
||||
expect(cleanHtml("<p>One</p><p>Two</p>")).toBe("One\nTwo");
|
||||
});
|
||||
test("decodes hex numeric entities", () => {
|
||||
expect(cleanHtml("Café")).toBe("Café");
|
||||
});
|
||||
test("returns null for empty input", () => {
|
||||
expect(cleanHtml("")).toBeNull();
|
||||
expect(cleanHtml(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeSlug", () => {
|
||||
test("accepts a bare slug", () => {
|
||||
expect(normalizeSlug("golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
|
||||
});
|
||||
test("extracts the slug from a /jobs/<slug> URL", () => {
|
||||
expect(normalizeSlug("https://freehire.me/jobs/golang-zensar-2bxu6dxm")).toBe("golang-zensar-2bxu6dxm");
|
||||
});
|
||||
test("rejects a non-slug string", () => {
|
||||
expect(normalizeSlug("not a slug!")).toBeNull();
|
||||
expect(normalizeSlug("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiGet } 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("apiGet 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(JSON.stringify({ data: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await apiGet("/jobs");
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { apiGet } 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 -> 8s backoff schedule. apiGet's documented graceful-degradation
|
||||
// contract (connection failures fail fast, no retry) is pinned too.
|
||||
|
||||
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("apiGet retry/backoff", () => {
|
||||
test("retries a 429 and succeeds on the next attempt", async () => {
|
||||
instantTimers();
|
||||
const state = stubFetch([
|
||||
() => new Response("", { status: 429 }),
|
||||
() => new Response('{"data":[]}', { status: 200 }),
|
||||
]);
|
||||
|
||||
const envelope = await apiGet<unknown[]>("/x");
|
||||
expect(envelope).not.toBeNull();
|
||||
expect(state.calls).toBe(2);
|
||||
});
|
||||
|
||||
test("returns the documented null on 404 without retrying", async () => {
|
||||
const state = stubFetch([() => new Response("", { status: 404 })]);
|
||||
|
||||
const envelope = await apiGet("/x");
|
||||
expect(envelope).toBeNull();
|
||||
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(apiGet("/x")).rejects.toThrow(/500/);
|
||||
expect(state.calls).toBe(7);
|
||||
});
|
||||
|
||||
test("fails fast on a connection error - no retry, per the graceful-degradation contract", async () => {
|
||||
const state = { calls: 0 };
|
||||
globalThis.fetch = (async () => {
|
||||
state.calls++;
|
||||
throw new TypeError("Unable to connect");
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await expect(apiGet("/x")).rejects.toThrow(/could not reach the freehire API/);
|
||||
expect(state.calls).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user