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); + }); +});