From 1c74a57c5e24e76e4b803a94568767f70fc066f2 Mon Sep 17 00:00:00 2001 From: Ayobami Adegoke Date: Tue, 28 Jul 2026 19:11:46 +0100 Subject: [PATCH] test(cli): pin the 429/5xx retry contract in all six portal CLIs (#246) * test(cli): pin the 429/5xx retry contract in all six portal CLIs The portal-skill contract requires backoff on 429/5xx, and every CLI implements it - a retry loop with exponential delay and jitter - but nothing verified the loops actually retry, stop retrying on plain 4xx, or give up after the documented attempt budget. A regression here is invisible: a CLI that stops retrying still works on every healthy request. Each CLI gains tests/retry-backoff.test.ts, network-free, using the request-timeout.test.ts pattern from #197 (import the fetch wrapper, stub globalThis.fetch): a stubbed fetch counts attempts, and a stubbed setTimeout fires immediately so the exhaustion case does not sleep through the real 500ms -> 5s/8s backoff schedule (tests run in milliseconds, not ~17s). Three assertions per fetch wrapper, adapted to each CLI's documented semantics: - a 429 is retried and the next attempt's result is returned - a plain 4xx is not retried (jobbank's fetchWithUA RETURNS the response for callers to handle - pinned as such; linkedin's htmlFetch returns "" on 404; freehire's apiGet returns null) - persistent 5xx gives up after the initial attempt plus six retries (7 fetch calls) with the status in the error freehire additionally pins its documented graceful-degradation contract: a connection failure fails fast with no retry. jobdanmark exercises both apiFetch and apiPost, which carry separate copies of the loop that could drift apart. Mutation-checked: changing maxRetries in jobindex makes the exhaustion test fail, so the tests distinguish the current behavior from a silently altered one. Verified: bun test green in all six CLIs (jobindex 19, jobnet 20, jobbank 20, jobdanmark 21, linkedin 21, freehire 31 - 0 fail); tsc --noEmit clean in all six; python3 tools/lint_skills.py OK. * test(jobindex): pin apiFetch's retry loop alongside htmlFetch's Review parity gap: jobindex carries two separate copies of the retry loop and only htmlFetch was exercised, so apiFetch's retry budget could drift silently - the same situation jobdanmark's test already handles for its apiFetch/apiPost pair. apiFetch gets the same three assertions, adapted to its documented semantics (JSON return on success, throw on plain 4xx): a 429 is retried and the next attempt's parsed body returned, a 400 is not retried, persistent 5xx gives up after the initial attempt plus six retries (7 calls). Mutation-checked on the new axis: changing apiFetch's maxRetries (the file's first copy of the loop) fails its exhaustion test while htmlFetch's tests stay green, so each wrapper is now pinned independently. Verified: bun test 30 pass / 0 fail (full jobindex suite); tsc --noEmit clean. --- .../cli/tests/retry-backoff.test.ts | 72 +++++++++++++++ .../cli/tests/retry-backoff.test.ts | 64 +++++++++++++ .../cli/tests/retry-backoff.test.ts | 67 ++++++++++++++ .../cli/tests/retry-backoff.test.ts | 89 +++++++++++++++++++ .../cli/tests/retry-backoff.test.ts | 59 ++++++++++++ .../cli/tests/retry-backoff.test.ts | 60 +++++++++++++ 6 files changed, 411 insertions(+) create mode 100644 .agents/skills/freehire-search/cli/tests/retry-backoff.test.ts create mode 100644 .agents/skills/jobbank-search/cli/tests/retry-backoff.test.ts create mode 100644 .agents/skills/jobdanmark-search/cli/tests/retry-backoff.test.ts create mode 100644 .agents/skills/jobindex-search/cli/tests/retry-backoff.test.ts create mode 100644 .agents/skills/jobnet-search/cli/tests/retry-backoff.test.ts create mode 100644 .agents/skills/linkedin-search/cli/tests/retry-backoff.test.ts diff --git a/.agents/skills/freehire-search/cli/tests/retry-backoff.test.ts b/.agents/skills/freehire-search/cli/tests/retry-backoff.test.ts new file mode 100644 index 0000000..fad5654 --- /dev/null +++ b/.agents/skills/freehire-search/cli/tests/retry-backoff.test.ts @@ -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("/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); + }); +}); diff --git a/.agents/skills/jobbank-search/cli/tests/retry-backoff.test.ts b/.agents/skills/jobbank-search/cli/tests/retry-backoff.test.ts new file mode 100644 index 0000000..8af008d --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/retry-backoff.test.ts @@ -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); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/tests/retry-backoff.test.ts b/.agents/skills/jobdanmark-search/cli/tests/retry-backoff.test.ts new file mode 100644 index 0000000..eac8fe4 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/retry-backoff.test.ts @@ -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); + }); + }); +} diff --git a/.agents/skills/jobindex-search/cli/tests/retry-backoff.test.ts b/.agents/skills/jobindex-search/cli/tests/retry-backoff.test.ts new file mode 100644 index 0000000..661f182 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/tests/retry-backoff.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { apiFetch, htmlFetch } 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 htmlFetch 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; +} + +describe("htmlFetch 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 html = await htmlFetch("https://www.jobindex.dk/x"); + expect(html).toContain("ok"); + expect(state.calls).toBe(2); + }); + + test("does not retry a plain 4xx", async () => { + const state = stubFetch([() => new Response("", { status: 400 })]); + + await expect(htmlFetch("https://www.jobindex.dk/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(htmlFetch("https://www.jobindex.dk/x")).rejects.toThrow(/500/); + expect(state.calls).toBe(7); + }); +}); + +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); + }); +}); diff --git a/.agents/skills/jobnet-search/cli/tests/retry-backoff.test.ts b/.agents/skills/jobnet-search/cli/tests/retry-backoff.test.ts new file mode 100644 index 0000000..f7e4143 --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/retry-backoff.test.ts @@ -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); + }); +}); diff --git a/.agents/skills/linkedin-search/cli/tests/retry-backoff.test.ts b/.agents/skills/linkedin-search/cli/tests/retry-backoff.test.ts new file mode 100644 index 0000000..f2b418a --- /dev/null +++ b/.agents/skills/linkedin-search/cli/tests/retry-backoff.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { htmlFetch } 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. + +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("htmlFetch 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 html = await htmlFetch("https://www.linkedin.com/x"); + expect(html).toContain("ok"); + expect(state.calls).toBe(2); + }); + + test("returns the documented empty string on 404 without retrying", async () => { + const state = stubFetch([() => new Response("", { status: 404 })]); + + const html = await htmlFetch("https://www.linkedin.com/x"); + expect(html).toBe(""); + 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(htmlFetch("https://www.linkedin.com/x")).rejects.toThrow(/500/); + expect(state.calls).toBe(7); + }); +});