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
JavaScript
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