UNPKG

ratelite

Version:

A simple, modern, and flexible Redis-based rate limiter for Express.js — built for real-world APIs, SaaS, and edge-compatible environments.

171 lines 7.04 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { createRateLimiter } from "../limiter"; // Centralized mock functions and data stores const mockIncr = jest.fn(); const mockExpire = jest.fn(); const mockTtl = jest.fn(); const redisStore = {}; const redisTTLStore = {}; // Helper to reset mocks before each test const clearMocks = () => { mockIncr.mockClear(); mockExpire.mockClear(); mockTtl.mockClear(); for (const key in redisStore) delete redisStore[key]; for (const key in redisTTLStore) delete redisTTLStore[key]; // Reset to default happy-path implementation mockIncr.mockImplementation((key) => __awaiter(void 0, void 0, void 0, function* () { redisStore[key] = (redisStore[key] || 0) + 1; return redisStore[key]; })); mockTtl.mockImplementation((key) => __awaiter(void 0, void 0, void 0, function* () { const expireTime = redisTTLStore[key]; if (!expireTime || expireTime <= Date.now()) { return -2; // Key does not exist or has no TTL } return Math.ceil((expireTime - Date.now()) / 1000); })); mockExpire.mockImplementation((key, seconds) => __awaiter(void 0, void 0, void 0, function* () { redisTTLStore[key] = Date.now() + seconds * 1000; })); }; // Mock the Redis client to use the centralized functions jest.mock("@upstash/redis", () => { return { Redis: jest.fn().mockImplementation(() => ({ incr: mockIncr, expire: mockExpire, ttl: mockTtl, })), }; }); describe("createRateLimiter", () => { let req; let res; let next; beforeEach(() => { clearMocks(); req = { ip: "127.0.0.1", }; res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), }; next = jest.fn(); }); it("should allow requests under the limit", () => __awaiter(void 0, void 0, void 0, function* () { const limiter = createRateLimiter({ redisUrl: "mock_url", redisToken: "mock_token", maxRequests: 5, windowInSeconds: 60, }); yield limiter(req, res, next); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); })); it("should block requests over the limit", () => __awaiter(void 0, void 0, void 0, function* () { const limiter = createRateLimiter({ redisUrl: "mock_url", redisToken: "mock_token", maxRequests: 1, windowInSeconds: 60, }); // First request should be allowed yield limiter(req, res, next); expect(next).toHaveBeenCalledTimes(1); // Second request should be blocked yield limiter(req, res, next); expect(res.status).toHaveBeenCalledWith(429); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: "Too many requests. Please try again later.", })); expect(next).toHaveBeenCalledTimes(1); // next should not be called again })); it("should set the Retry-After header when rate limited", () => __awaiter(void 0, void 0, void 0, function* () { const limiter = createRateLimiter({ redisUrl: "mock_url", redisToken: "mock_token", maxRequests: 1, windowInSeconds: 60, }); yield limiter(req, res, next); yield limiter(req, res, next); expect(res.set).toHaveBeenCalledWith("Retry-After", expect.any(String)); })); it("should use the custom keyFn if provided", () => __awaiter(void 0, void 0, void 0, function* () { const keyFn = jest.fn((req) => req.headers["x-api-key"]); const limiter = createRateLimiter({ redisUrl: "mock_url", redisToken: "mock_token", maxRequests: 1, windowInSeconds: 60, keyFn, }); req.headers = { "x-api-key": "test-key" }; yield limiter(req, res, next); expect(keyFn).toHaveBeenCalledWith(req); expect(mockIncr).toHaveBeenCalledWith("rate:test-key"); })); it("should call onLimitReached when the limit is exceeded", () => __awaiter(void 0, void 0, void 0, function* () { const onLimitReached = jest.fn((req, res) => { res.status(418).json({ message: "I'm a teapot" }); }); const limiter = createRateLimiter({ redisUrl: "mock_url", redisToken: "mock_token", maxRequests: 1, windowInSeconds: 60, onLimitReached, }); yield limiter(req, res, next); yield limiter(req, res, next); expect(onLimitReached).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(418); expect(res.json).toHaveBeenCalledWith({ message: "I'm a teapot" }); })); it("should handle Redis errors gracefully with onError", () => __awaiter(void 0, void 0, void 0, function* () { const redisError = new Error("Redis connection failed"); mockIncr.mockRejectedValueOnce(redisError); const onError = jest.fn(); const limiter = createRateLimiter({ redisUrl: "mock_url", redisToken: "mock_token", maxRequests: 5, windowInSeconds: 60, onError, }); yield limiter(req, res, next); expect(onError).toHaveBeenCalledWith(redisError, req, res); expect(next).not.toHaveBeenCalled(); })); it("should handle Redis errors with default handler if onError is not provided", () => __awaiter(void 0, void 0, void 0, function* () { const redisError = new Error("Redis connection failed"); mockIncr.mockRejectedValueOnce(redisError); const limiter = createRateLimiter({ redisUrl: "mock_url", redisToken: "mock_token", maxRequests: 5, windowInSeconds: 60, }); yield limiter(req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: "Rate limiting failed. Please try again later.", })); expect(next).not.toHaveBeenCalled(); })); }); //# sourceMappingURL=limiter.test.js.map