- Log at ERROR level when falling back to in-memory storage - Track and expose degraded mode status for health checks - Add isUsingFallback() method to check fallback state - Add getHealthStatus() method for health check endpoints - Add comprehensive tests for fallback behavior and health status Refs #338 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
258 lines
9.0 KiB
TypeScript
258 lines
9.0 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach, Mock } from "vitest";
|
|
import { ThrottlerValkeyStorageService } from "./throttler-storage.service";
|
|
|
|
// Create a mock Redis class
|
|
const createMockRedis = (
|
|
options: {
|
|
shouldFailConnect?: boolean;
|
|
error?: Error;
|
|
} = {}
|
|
): Record<string, Mock> => ({
|
|
connect: vi.fn().mockImplementation(() => {
|
|
if (options.shouldFailConnect) {
|
|
return Promise.reject(options.error ?? new Error("Connection refused"));
|
|
}
|
|
return Promise.resolve();
|
|
}),
|
|
ping: vi.fn().mockResolvedValue("PONG"),
|
|
quit: vi.fn().mockResolvedValue("OK"),
|
|
multi: vi.fn().mockReturnThis(),
|
|
incr: vi.fn().mockReturnThis(),
|
|
pexpire: vi.fn().mockReturnThis(),
|
|
exec: vi.fn().mockResolvedValue([
|
|
[null, 1],
|
|
[null, 1],
|
|
]),
|
|
get: vi.fn().mockResolvedValue("5"),
|
|
});
|
|
|
|
// Mock ioredis module
|
|
vi.mock("ioredis", () => {
|
|
return {
|
|
default: vi.fn().mockImplementation(() => createMockRedis({ shouldFailConnect: true })),
|
|
};
|
|
});
|
|
|
|
describe("ThrottlerValkeyStorageService", () => {
|
|
let service: ThrottlerValkeyStorageService;
|
|
let loggerErrorSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
service = new ThrottlerValkeyStorageService();
|
|
|
|
// Spy on logger methods - access the private logger
|
|
const logger = (
|
|
service as unknown as { logger: { error: () => void; log: () => void; warn: () => void } }
|
|
).logger;
|
|
loggerErrorSpy = vi.spyOn(logger, "error").mockImplementation(() => undefined);
|
|
vi.spyOn(logger, "log").mockImplementation(() => undefined);
|
|
vi.spyOn(logger, "warn").mockImplementation(() => undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("initialization and fallback behavior", () => {
|
|
it("should start in fallback mode before initialization", () => {
|
|
// Before onModuleInit is called, useRedis is false by default
|
|
expect(service.isUsingFallback()).toBe(true);
|
|
});
|
|
|
|
it("should log ERROR when Redis connection fails", async () => {
|
|
const newService = new ThrottlerValkeyStorageService();
|
|
const newLogger = (
|
|
newService as unknown as { logger: { error: () => void; log: () => void } }
|
|
).logger;
|
|
const newErrorSpy = vi.spyOn(newLogger, "error").mockImplementation(() => undefined);
|
|
vi.spyOn(newLogger, "log").mockImplementation(() => undefined);
|
|
|
|
await newService.onModuleInit();
|
|
|
|
// Verify ERROR was logged (not WARN)
|
|
expect(newErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Failed to connect to Valkey for rate limiting")
|
|
);
|
|
expect(newErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("DEGRADED MODE: Falling back to in-memory rate limiting storage")
|
|
);
|
|
});
|
|
|
|
it("should log message indicating rate limits will not be shared", async () => {
|
|
const newService = new ThrottlerValkeyStorageService();
|
|
const newLogger = (
|
|
newService as unknown as { logger: { error: () => void; log: () => void } }
|
|
).logger;
|
|
const newErrorSpy = vi.spyOn(newLogger, "error").mockImplementation(() => undefined);
|
|
vi.spyOn(newLogger, "log").mockImplementation(() => undefined);
|
|
|
|
await newService.onModuleInit();
|
|
|
|
expect(newErrorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Rate limits will not be shared across API instances")
|
|
);
|
|
});
|
|
|
|
it("should be in fallback mode when Redis connection fails", async () => {
|
|
const newService = new ThrottlerValkeyStorageService();
|
|
const newLogger = (
|
|
newService as unknown as { logger: { error: () => void; log: () => void } }
|
|
).logger;
|
|
vi.spyOn(newLogger, "error").mockImplementation(() => undefined);
|
|
vi.spyOn(newLogger, "log").mockImplementation(() => undefined);
|
|
|
|
await newService.onModuleInit();
|
|
|
|
expect(newService.isUsingFallback()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("isUsingFallback()", () => {
|
|
it("should return true when in memory fallback mode", () => {
|
|
// Default state is fallback mode
|
|
expect(service.isUsingFallback()).toBe(true);
|
|
});
|
|
|
|
it("should return boolean type", () => {
|
|
const result = service.isUsingFallback();
|
|
expect(typeof result).toBe("boolean");
|
|
});
|
|
});
|
|
|
|
describe("getHealthStatus()", () => {
|
|
it("should return degraded status when in fallback mode", () => {
|
|
// Default state is fallback mode
|
|
const status = service.getHealthStatus();
|
|
|
|
expect(status).toEqual({
|
|
healthy: true,
|
|
mode: "memory",
|
|
degraded: true,
|
|
message: expect.stringContaining("in-memory fallback"),
|
|
});
|
|
});
|
|
|
|
it("should indicate degraded mode message includes lack of sharing", () => {
|
|
const status = service.getHealthStatus();
|
|
|
|
expect(status.message).toContain("not shared across instances");
|
|
});
|
|
|
|
it("should always report healthy even in degraded mode", () => {
|
|
// In degraded mode, the service is still functional
|
|
const status = service.getHealthStatus();
|
|
expect(status.healthy).toBe(true);
|
|
});
|
|
|
|
it("should have correct structure for health checks", () => {
|
|
const status = service.getHealthStatus();
|
|
|
|
expect(status).toHaveProperty("healthy");
|
|
expect(status).toHaveProperty("mode");
|
|
expect(status).toHaveProperty("degraded");
|
|
expect(status).toHaveProperty("message");
|
|
});
|
|
|
|
it("should report mode as memory when in fallback", () => {
|
|
const status = service.getHealthStatus();
|
|
expect(status.mode).toBe("memory");
|
|
});
|
|
|
|
it("should report degraded as true when in fallback", () => {
|
|
const status = service.getHealthStatus();
|
|
expect(status.degraded).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("getHealthStatus() with Redis (unit test via internal state)", () => {
|
|
it("should return non-degraded status when Redis is available", () => {
|
|
// Manually set the internal state to simulate Redis being available
|
|
// This tests the method logic without requiring actual Redis connection
|
|
const testService = new ThrottlerValkeyStorageService();
|
|
|
|
// Access private property for testing (this is acceptable for unit testing)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(testService as any).useRedis = true;
|
|
|
|
const status = testService.getHealthStatus();
|
|
|
|
expect(status).toEqual({
|
|
healthy: true,
|
|
mode: "redis",
|
|
degraded: false,
|
|
message: expect.stringContaining("Redis storage"),
|
|
});
|
|
});
|
|
|
|
it("should report distributed mode message when Redis is available", () => {
|
|
const testService = new ThrottlerValkeyStorageService();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(testService as any).useRedis = true;
|
|
|
|
const status = testService.getHealthStatus();
|
|
|
|
expect(status.message).toContain("distributed mode");
|
|
});
|
|
|
|
it("should report isUsingFallback as false when Redis is available", () => {
|
|
const testService = new ThrottlerValkeyStorageService();
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(testService as any).useRedis = true;
|
|
|
|
expect(testService.isUsingFallback()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("in-memory fallback operations", () => {
|
|
it("should increment correctly in fallback mode", async () => {
|
|
const result = await service.increment("test-key", 60000, 10, 0, "default");
|
|
|
|
expect(result.totalHits).toBe(1);
|
|
expect(result.isBlocked).toBe(false);
|
|
});
|
|
|
|
it("should accumulate hits in fallback mode", async () => {
|
|
await service.increment("test-key", 60000, 10, 0, "default");
|
|
await service.increment("test-key", 60000, 10, 0, "default");
|
|
const result = await service.increment("test-key", 60000, 10, 0, "default");
|
|
|
|
expect(result.totalHits).toBe(3);
|
|
});
|
|
|
|
it("should return correct blocked status when limit exceeded", async () => {
|
|
// Make 3 requests with limit of 2
|
|
await service.increment("test-key", 60000, 2, 1000, "default");
|
|
await service.increment("test-key", 60000, 2, 1000, "default");
|
|
const result = await service.increment("test-key", 60000, 2, 1000, "default");
|
|
|
|
expect(result.totalHits).toBe(3);
|
|
expect(result.isBlocked).toBe(true);
|
|
expect(result.timeToBlockExpire).toBe(1000);
|
|
});
|
|
|
|
it("should return 0 for get on non-existent key in fallback mode", async () => {
|
|
const result = await service.get("non-existent-key");
|
|
expect(result).toBe(0);
|
|
});
|
|
|
|
it("should return correct timeToExpire in response", async () => {
|
|
const ttl = 30000;
|
|
const result = await service.increment("test-key", ttl, 10, 0, "default");
|
|
|
|
expect(result.timeToExpire).toBe(ttl);
|
|
});
|
|
|
|
it("should isolate different keys in fallback mode", async () => {
|
|
await service.increment("key-1", 60000, 10, 0, "default");
|
|
await service.increment("key-1", 60000, 10, 0, "default");
|
|
const result1 = await service.increment("key-1", 60000, 10, 0, "default");
|
|
|
|
const result2 = await service.increment("key-2", 60000, 10, 0, "default");
|
|
|
|
expect(result1.totalHits).toBe(3);
|
|
expect(result2.totalHits).toBe(1);
|
|
});
|
|
});
|
|
});
|