/*
Unit tests for retry covering retryWithBackoff. It also guards the documented public contract and regression-sensitive edge cases.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-1311: Compass header sweep — declares the retry suite's contract for test-file scaffold coverage.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { retryWithBackoff } from "../changelog/utils/retry.ts";
// Helper: run retryWithBackoff and fake timers concurrently.
// The promise and its rejection handler must both be set up in the same
// synchronous tick to avoid PromiseRejectionHandledWarning.
async function runWithTimers(
promise: Promise,
): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> {
const settled = promise.then(
(value) => ({ ok: true as const, value }),
(error) => ({ ok: false as const, error }),
);
await vi.runAllTimersAsync();
return settled;
}
describe("retryWithBackoff", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the result immediately on first success", async () => {
const fn = vi.fn().mockResolvedValue("ok");
const result = await retryWithBackoff(fn, { retries: 2, baseDelay: 1000, jitter: false });
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("retries after failure and returns on second success", async () => {
const fn = vi.fn().mockRejectedValueOnce(new Error("fail")).mockResolvedValue("ok");
const promise = retryWithBackoff(fn, { retries: 2, baseDelay: 1000, factor: 2, jitter: false });
const settled = await runWithTimers(promise);
expect(settled.ok).toBe(true);
if (settled.ok) expect(settled.value).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
it("throws the last error after all retries are exhausted", async () => {
const boom = new Error("always fails");
const fn = vi.fn().mockRejectedValue(boom);
const promise = retryWithBackoff(fn, { retries: 2, baseDelay: 100, jitter: false });
const settled = await runWithTimers(promise);
expect(settled.ok).toBe(false);
if (!settled.ok) expect(settled.error).toBe(boom);
expect(fn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it("calls fn exactly retries+1 times on total failure", async () => {
const fn = vi.fn().mockRejectedValue(new Error("x"));
const promise = retryWithBackoff(fn, { retries: 4, baseDelay: 10, jitter: false });
await runWithTimers(promise);
expect(fn).toHaveBeenCalledTimes(5);
});
it("waits exponentially between attempts (no jitter)", async () => {
const fn = vi.fn().mockRejectedValue(new Error("x"));
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const promise = retryWithBackoff(fn, { retries: 2, baseDelay: 1000, factor: 2, jitter: false });
await runWithTimers(promise);
// delays: 1000ms (attempt 0→1), 2000ms (attempt 1→2)
const delays = setTimeoutSpy.mock.calls.map(([, delay]) => delay);
expect(delays[0]).toBe(1000);
expect(delays[1]).toBe(2000);
setTimeoutSpy.mockRestore();
});
it("does not wait after the final failed attempt", async () => {
const fn = vi.fn().mockRejectedValue(new Error("x"));
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const promise = retryWithBackoff(fn, { retries: 2, baseDelay: 1000, jitter: false });
await runWithTimers(promise);
// 2 retries → 2 waits, not 3
expect(setTimeoutSpy).toHaveBeenCalledTimes(2);
setTimeoutSpy.mockRestore();
});
it("retries=0 means single attempt, no retry on failure", async () => {
const fn = vi.fn().mockRejectedValue(new Error("x"));
const promise = retryWithBackoff(fn, { retries: 0, baseDelay: 1000, jitter: false });
await runWithTimers(promise);
expect(fn).toHaveBeenCalledTimes(1);
});
});