From 5efa0220d071f58c8203ee60e0f4cf67a7de2327 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 20 May 2026 08:36:37 +0000 Subject: [PATCH] feat(core-testing): add RecordingRateLimit for test assertions Captures consume + reset call arguments verbatim via consumeCalls and resetCalls accessors. Uses local IRateLimit type alias (no core-shared dep) following the recording-job-queue pattern. Co-Authored-By: Claude Sonnet 4.6 --- packages/core-testing/src/index.ts | 1 + packages/core-testing/src/rate-limit/index.ts | 5 ++ .../rate-limit/recording-rate-limit.test.ts | 48 +++++++++++++++ .../src/rate-limit/recording-rate-limit.ts | 60 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 packages/core-testing/src/rate-limit/index.ts create mode 100644 packages/core-testing/src/rate-limit/recording-rate-limit.test.ts create mode 100644 packages/core-testing/src/rate-limit/recording-rate-limit.ts diff --git a/packages/core-testing/src/index.ts b/packages/core-testing/src/index.ts index a1a03ac..b332966 100644 --- a/packages/core-testing/src/index.ts +++ b/packages/core-testing/src/index.ts @@ -1,3 +1,4 @@ export * from "./factory/index.js"; export * from "./contract/index.js"; export * from "./instrumentation/index.js"; +export * from "./rate-limit/index.js"; diff --git a/packages/core-testing/src/rate-limit/index.ts b/packages/core-testing/src/rate-limit/index.ts new file mode 100644 index 0000000..43ff8f9 --- /dev/null +++ b/packages/core-testing/src/rate-limit/index.ts @@ -0,0 +1,5 @@ +export { + RecordingRateLimit, + type RecordedConsumeCall, + type RecordedResetCall, +} from "./recording-rate-limit"; diff --git a/packages/core-testing/src/rate-limit/recording-rate-limit.test.ts b/packages/core-testing/src/rate-limit/recording-rate-limit.test.ts new file mode 100644 index 0000000..8c0788f --- /dev/null +++ b/packages/core-testing/src/rate-limit/recording-rate-limit.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { RecordingRateLimit } from "@/rate-limit/recording-rate-limit"; + +describe("RecordingRateLimit", () => { + it("starts with empty call arrays", () => { + const rl = new RecordingRateLimit(); + expect(rl.consumeCalls).toHaveLength(0); + expect(rl.resetCalls).toHaveLength(0); + }); + + it("captures consume call arguments verbatim including optional weight", async () => { + const rl = new RecordingRateLimit(); + await rl.consume("api", "user:123"); + await rl.consume("api", "user:456", 2); + expect(rl.consumeCalls).toEqual([ + { budgetName: "api", key: "user:123", weight: undefined }, + { budgetName: "api", key: "user:456", weight: 2 }, + ]); + }); + + it("captures reset call arguments verbatim", async () => { + const rl = new RecordingRateLimit(); + await rl.reset("api", "user:123"); + await rl.reset("auth", "ip:1.2.3.4"); + expect(rl.resetCalls).toEqual([ + { budgetName: "api", key: "user:123" }, + { budgetName: "auth", key: "ip:1.2.3.4" }, + ]); + }); + + it("returns allowed decision by default", async () => { + const rl = new RecordingRateLimit(); + const decision = await rl.consume("api", "user:123"); + expect(decision).toEqual({ + allowed: true, + remaining: Infinity, + resetAt: new Date(0), + }); + }); + + it("returns configured decision after withDecision", async () => { + const rl = new RecordingRateLimit(); + const blocked = { allowed: false, remaining: 0, resetAt: new Date(9999) }; + rl.withDecision(blocked); + const decision = await rl.consume("api", "user:123"); + expect(decision).toEqual(blocked); + }); +}); diff --git a/packages/core-testing/src/rate-limit/recording-rate-limit.ts b/packages/core-testing/src/rate-limit/recording-rate-limit.ts new file mode 100644 index 0000000..df67452 --- /dev/null +++ b/packages/core-testing/src/rate-limit/recording-rate-limit.ts @@ -0,0 +1,60 @@ +// Local type aliases matching the contracts in @repo/core-shared/rate-limit. +// Kept inline to avoid a build-graph cycle between core-testing and core-shared +// (mirrors the recording-job-queue pattern). + +type RateLimitDecision = { + allowed: boolean; + remaining: number; + resetAt: Date; +}; + +interface IRateLimit { + consume( + budgetName: string, + key: string, + weight?: number, + ): Promise; + reset(budgetName: string, key: string): Promise; +} + +export type RecordedConsumeCall = { + budgetName: string; + key: string; + weight: number | undefined; +}; + +export type RecordedResetCall = { + budgetName: string; + key: string; +}; + +const EPOCH = new Date(0); + +export class RecordingRateLimit implements IRateLimit { + readonly consumeCalls: RecordedConsumeCall[] = []; + readonly resetCalls: RecordedResetCall[] = []; + + private _decision: RateLimitDecision = { + allowed: true, + remaining: Infinity, + resetAt: EPOCH, + }; + + withDecision(decision: RateLimitDecision): this { + this._decision = decision; + return this; + } + + async consume( + budgetName: string, + key: string, + weight?: number, + ): Promise { + this.consumeCalls.push({ budgetName, key, weight }); + return this._decision; + } + + async reset(budgetName: string, key: string): Promise { + this.resetCalls.push({ budgetName, key }); + } +}