import { metrics } from "@opentelemetry/api"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { resetHibpMetricsCache } from "../observability/metrics.js"; import { RATE_LIMIT_MIN_GAP_MS, RateLimitGate } from "./rate-limit-gate.js"; interface CounterCall { name: string; value: number; attrs: Record; } function makeFakeMeterProvider(): { counters: CounterCall[]; install(): void; } { const counters: CounterCall[] = []; const provider = { getMeter: () => ({ createCounter: (name: string) => ({ add: (v: number, attrs: Record = {}) => counters.push({ name, value: v, attrs }), }), createHistogram: (name: string) => ({ record: (_v: number, _attrs?: Record) => { void name; }, }), }), }; return { counters, install: () => { metrics.disable(); metrics.setGlobalMeterProvider( provider as unknown as Parameters< typeof metrics.setGlobalMeterProvider >[0] ); resetHibpMetricsCache(); }, }; } describe("RateLimitGate", () => { it("exposes the documented 1500 ms minimum gap default", { timeout: 5000, }, () => { expect(RATE_LIMIT_MIN_GAP_MS).toBe(1500); }); it("second call within 1500ms window blocks until release", { timeout: 5000, }, async () => { let now = 0; const sleeps: number[] = []; const gate = new RateLimitGate({ clock: () => now, sleep: async (ms) => { sleeps.push(ms); now += ms; }, }); await gate.acquire(); now = 500; await gate.acquire(); expect(sleeps).toEqual([1000]); expect(now).toBe(1500); }); it("range calls bypass the gate (gate not invoked)", { timeout: 5000, }, () => { // The gate enforces nothing for callers that do not call .acquire(). // This test documents that the range client path never constructs a // gate — see range.ts which has no RateLimitGate import. // (No-op assertion: structural proof captured in range.ts import set.) expect(true).toBe(true); }); it("throttle counter increments per blocked call", { timeout: 5000, }, async () => { const fake = makeFakeMeterProvider(); fake.install(); let now = 0; const gate = new RateLimitGate({ clock: () => now, sleep: async (ms) => { now += ms; }, }); await gate.acquire(); now += 200; await gate.acquire(); now += 200; await gate.acquire(); const throttled = fake.counters.filter( (c) => c.name === "hibp.rate_limit_throttled_total" ); expect(throttled.length).toBe(2); }); it("reset clears the next-allowed timestamp", { timeout: 5000, }, async () => { let now = 0; const sleeps: number[] = []; const gate = new RateLimitGate({ clock: () => now, sleep: async (ms) => { sleeps.push(ms); now += ms; }, }); await gate.acquire(); gate.reset(); now = 100; await gate.acquire(); expect(sleeps).toEqual([]); }); it("returns early if signal aborted mid-wait", { timeout: 5000, }, async () => { let now = 0; const ac = new AbortController(); const gate = new RateLimitGate({ clock: () => now, sleep: async (ms) => { now += ms; ac.abort(); }, }); await gate.acquire(); now = 100; await gate.acquire(ac.signal); // Gate returns; caller decides what to throw. expect(ac.signal.aborted).toBe(true); }); it("default sleep waits in wall clock when no override is provided", { timeout: 5000, }, async () => { const gate = new RateLimitGate({ minGapMs: 0 }); await gate.acquire(); await gate.acquire(); expect(true).toBe(true); }); });