|
| 1 | +import { Redis } from "@upstash/redis"; |
| 2 | +import { describe, expect, test } from "bun:test"; |
| 3 | +import crypto from "node:crypto"; |
| 4 | +import { Ratelimit } from "./index"; |
| 5 | +import type { Algorithm, RatelimitResponseType, RegionContext } from "./types"; |
| 6 | + |
| 7 | +const redis = Redis.fromEnv(); |
| 8 | + |
| 9 | +type ExpectedResult = { |
| 10 | + /** |
| 11 | + * Expected limit value in response |
| 12 | + */ |
| 13 | + limit: number; |
| 14 | + /** |
| 15 | + * Expected remaining tokens after consuming requestCount |
| 16 | + */ |
| 17 | + remaining: number; |
| 18 | + /** |
| 19 | + * Whether requests should succeed |
| 20 | + */ |
| 21 | + success: boolean; |
| 22 | + /** |
| 23 | + * Expected result from getDynamicLimit() |
| 24 | + */ |
| 25 | + dynamicLimit: number | null; |
| 26 | +}; |
| 27 | + |
| 28 | +type TestCase = { |
| 29 | + name: string; |
| 30 | + /** |
| 31 | + * Default limit configured in limiter |
| 32 | + */ |
| 33 | + defaultLimit: number; |
| 34 | + /** |
| 35 | + * Number of requests to make |
| 36 | + */ |
| 37 | + requestCount: number; |
| 38 | + /** |
| 39 | + * Whether dynamicLimits is enabled |
| 40 | + */ |
| 41 | + dynamicLimitsEnabled: boolean; |
| 42 | + /** |
| 43 | + * Dynamic limit to set (null means don't call setDynamicLimit) |
| 44 | + */ |
| 45 | + setDynamicLimit: number | null; |
| 46 | + /** |
| 47 | + * Expected results after consuming requestCount requests |
| 48 | + */ |
| 49 | + expected: ExpectedResult; |
| 50 | +}; |
| 51 | + |
| 52 | +const testCases: TestCase[] = [ |
| 53 | + // Case 1: dynamicLimits: true, setDynamicLimit called |
| 54 | + { |
| 55 | + name: "dynamicLimits enabled, dynamic limit set (lower than default)", |
| 56 | + defaultLimit: 10, |
| 57 | + requestCount: 3, |
| 58 | + dynamicLimitsEnabled: true, |
| 59 | + setDynamicLimit: 3, |
| 60 | + expected: { |
| 61 | + limit: 3, |
| 62 | + remaining: 0, |
| 63 | + success: true, // 3 requests with limit 3 should succeed |
| 64 | + dynamicLimit: 3, |
| 65 | + }, |
| 66 | + }, |
| 67 | + { |
| 68 | + name: "dynamicLimits enabled, dynamic limit set (exceeds limit)", |
| 69 | + defaultLimit: 10, |
| 70 | + requestCount: 5, |
| 71 | + dynamicLimitsEnabled: true, |
| 72 | + setDynamicLimit: 3, |
| 73 | + expected: { |
| 74 | + limit: 3, |
| 75 | + remaining: 0, |
| 76 | + success: false, // 5 requests with limit 3 should fail |
| 77 | + dynamicLimit: 3, |
| 78 | + }, |
| 79 | + }, |
| 80 | + // Case 2: dynamicLimits: true, setDynamicLimit not called |
| 81 | + { |
| 82 | + name: "dynamicLimits enabled, no dynamic limit set (uses default)", |
| 83 | + defaultLimit: 10, |
| 84 | + requestCount: 5, |
| 85 | + dynamicLimitsEnabled: true, |
| 86 | + setDynamicLimit: null, |
| 87 | + expected: { |
| 88 | + limit: 10, |
| 89 | + remaining: 5, |
| 90 | + success: true, // 5 requests with default limit 10 should succeed |
| 91 | + dynamicLimit: null, |
| 92 | + }, |
| 93 | + }, |
| 94 | + // Case 3: dynamicLimits: false, setDynamicLimit not called |
| 95 | + { |
| 96 | + name: "dynamicLimits disabled, no dynamic limit (uses default)", |
| 97 | + defaultLimit: 10, |
| 98 | + requestCount: 5, |
| 99 | + dynamicLimitsEnabled: false, |
| 100 | + setDynamicLimit: null, |
| 101 | + expected: { |
| 102 | + limit: 10, |
| 103 | + remaining: 5, |
| 104 | + success: true, // 5 requests with default limit 10 should succeed |
| 105 | + dynamicLimit: null, |
| 106 | + }, |
| 107 | + }, |
| 108 | +]; |
| 109 | + |
| 110 | +function run( |
| 111 | + limiterName: string, |
| 112 | + limiterBuilder: (limit: number) => Algorithm<RegionContext> |
| 113 | +) { |
| 114 | + describe(limiterName, () => { |
| 115 | + // Test error cases |
| 116 | + test("should throw error when setDynamicLimit called with dynamicLimits disabled", async () => { |
| 117 | + const ratelimit = new Ratelimit({ |
| 118 | + redis, |
| 119 | + limiter: limiterBuilder(10), |
| 120 | + prefix: crypto.randomUUID(), |
| 121 | + }); |
| 122 | + |
| 123 | + await expect(async () => { |
| 124 | + await ratelimit.setDynamicLimit({ limit: 100 }); |
| 125 | + }).toThrow("dynamicLimits must be enabled"); |
| 126 | + }); |
| 127 | + |
| 128 | + test("should throw error when getDynamicLimit called with dynamicLimits disabled", async () => { |
| 129 | + const ratelimit = new Ratelimit({ |
| 130 | + redis, |
| 131 | + limiter: limiterBuilder(10), |
| 132 | + prefix: crypto.randomUUID(), |
| 133 | + }); |
| 134 | + |
| 135 | + await expect(async () => { |
| 136 | + await ratelimit.getDynamicLimit(); |
| 137 | + }).toThrow("dynamicLimits must be enabled"); |
| 138 | + }); |
| 139 | + |
| 140 | + // Test all cases |
| 141 | + for (const tc of testCases) { |
| 142 | + test(tc.name, async () => { |
| 143 | + const prefix = crypto.randomUUID(); |
| 144 | + const ratelimit = new Ratelimit({ |
| 145 | + redis, |
| 146 | + limiter: limiterBuilder(tc.defaultLimit), |
| 147 | + prefix, |
| 148 | + dynamicLimits: tc.dynamicLimitsEnabled, |
| 149 | + ephemeralCache: false, // Disable cache for accurate testing |
| 150 | + }); |
| 151 | + |
| 152 | + const identifier = crypto.randomUUID(); |
| 153 | + |
| 154 | + // Set dynamic limit if specified |
| 155 | + if (tc.setDynamicLimit !== null) { |
| 156 | + await ratelimit.setDynamicLimit({ limit: tc.setDynamicLimit }); |
| 157 | + } |
| 158 | + |
| 159 | + // Verify getDynamicLimit before making requests |
| 160 | + if (tc.dynamicLimitsEnabled) { |
| 161 | + const { dynamicLimit } = await ratelimit.getDynamicLimit(); |
| 162 | + expect(dynamicLimit).toBe(tc.expected.dynamicLimit); |
| 163 | + } |
| 164 | + |
| 165 | + // Make requests using rate parameter |
| 166 | + const result = await ratelimit.limit(identifier, { rate: tc.requestCount }); |
| 167 | + |
| 168 | + // Verify result |
| 169 | + expect(result.success).toBe(tc.expected.success); |
| 170 | + expect(result.limit).toBe(tc.expected.limit); |
| 171 | + expect(result.remaining).toBe(tc.expected.remaining); |
| 172 | + |
| 173 | + // Verify getDynamicLimit after request |
| 174 | + if (tc.dynamicLimitsEnabled) { |
| 175 | + const { dynamicLimit } = await ratelimit.getDynamicLimit(); |
| 176 | + expect(dynamicLimit).toBe(tc.expected.dynamicLimit); |
| 177 | + } |
| 178 | + |
| 179 | + // Verify getRemaining after request |
| 180 | + const finalRemaining = await ratelimit.getRemaining(identifier); |
| 181 | + expect(finalRemaining.limit).toBe(tc.expected.limit); |
| 182 | + expect(finalRemaining.remaining).toBe(tc.expected.remaining); |
| 183 | + }); |
| 184 | + } |
| 185 | + |
| 186 | + // Test ephemeral cache behavior with dynamic limits |
| 187 | + const cacheTestCases = [ |
| 188 | + { |
| 189 | + name: "with cache enabled - should block via cache after dynamic limit is removed", |
| 190 | + ephemeralCache: undefined, // undefined means cache is enabled by default |
| 191 | + expectedSecondCallSuccess: false, |
| 192 | + expectedSecondCallReason: "cacheBlock" as RatelimitResponseType | undefined, |
| 193 | + }, |
| 194 | + { |
| 195 | + name: "with cache disabled - behavior after dynamic limit is removed", |
| 196 | + ephemeralCache: false as const, |
| 197 | + expectedSecondCallSuccess: undefined, // Will vary by algorithm |
| 198 | + expectedSecondCallReason: undefined as RatelimitResponseType | undefined, |
| 199 | + }, |
| 200 | + ] as const; |
| 201 | + |
| 202 | + for (const cacheTest of cacheTestCases) { |
| 203 | + test(cacheTest.name, async () => { |
| 204 | + const prefix = crypto.randomUUID(); |
| 205 | + const ratelimit = new Ratelimit({ |
| 206 | + redis, |
| 207 | + limiter: limiterBuilder(10), // default limit: 10 |
| 208 | + prefix, |
| 209 | + dynamicLimits: true, |
| 210 | + ephemeralCache: cacheTest.ephemeralCache, |
| 211 | + }); |
| 212 | + |
| 213 | + const identifier = crypto.randomUUID(); |
| 214 | + |
| 215 | + // Set dynamic limit to 3 (lower than default 10) |
| 216 | + await ratelimit.setDynamicLimit({ limit: 3 }); |
| 217 | + |
| 218 | + // Make a request with rate=5, which exceeds dynamic limit (3) but not default (10) |
| 219 | + const firstResult = await ratelimit.limit(identifier, { rate: 5 }); |
| 220 | + |
| 221 | + // First call should fail due to dynamic limit |
| 222 | + expect(firstResult.success).toBe(false); |
| 223 | + expect(firstResult.limit).toBe(3); |
| 224 | + expect(firstResult.remaining).toBe(0); |
| 225 | + |
| 226 | + // Remove the dynamic limit |
| 227 | + await ratelimit.setDynamicLimit({ limit: false }); |
| 228 | + |
| 229 | + // Verify dynamic limit is removed |
| 230 | + const { dynamicLimit } = await ratelimit.getDynamicLimit(); |
| 231 | + expect(dynamicLimit).toBeNull(); |
| 232 | + |
| 233 | + // Second call behavior depends on cache setting |
| 234 | + const secondResult = await ratelimit.limit(identifier); |
| 235 | + if (cacheTest.expectedSecondCallReason) { |
| 236 | + expect(secondResult.reason).toBe(cacheTest.expectedSecondCallReason); |
| 237 | + } else { |
| 238 | + expect(secondResult.reason).toBeUndefined(); |
| 239 | + } |
| 240 | + |
| 241 | + if (cacheTest.expectedSecondCallSuccess === undefined) { |
| 242 | + // When cache is disabled, behavior differs by algorithm |
| 243 | + if (limiterName === "tokenBucket") { |
| 244 | + // tokenBucket still fails because it has 0 tokens stored and needs refill time |
| 245 | + expect(secondResult.success).toBe(false); |
| 246 | + expect(secondResult.limit).toBe(10); |
| 247 | + expect(secondResult.remaining).toBe(0); |
| 248 | + } else { |
| 249 | + // fixedWindow/slidingWindow succeed because they track used tokens |
| 250 | + expect(secondResult.success).toBe(true); |
| 251 | + expect(secondResult.limit).toBe(10); |
| 252 | + expect(secondResult.remaining).toBe(4); // 10 - 5 (first) - 1 (second) = 4 |
| 253 | + } |
| 254 | + } else { |
| 255 | + expect(secondResult.success).toBe(cacheTest.expectedSecondCallSuccess); |
| 256 | + } |
| 257 | + |
| 258 | + if (cacheTest.expectedSecondCallSuccess === false && cacheTest.expectedSecondCallReason === "cacheBlock") { |
| 259 | + // Cache block case - no other checks needed |
| 260 | + } |
| 261 | + }); |
| 262 | + } |
| 263 | + }); |
| 264 | +} |
| 265 | + |
| 266 | +describe("Dynamic Limits", () => { |
| 267 | + run("fixedWindow", (limit) => Ratelimit.fixedWindow(limit, "1000 s")); |
| 268 | + run("slidingWindow", (limit) => Ratelimit.slidingWindow(limit, "1000 s")); |
| 269 | + run("tokenBucket", (limit) => Ratelimit.tokenBucket(limit, "1000 s", limit)); |
| 270 | +}); |
0 commit comments