Files
stack/apps/api/src/common/throttler/throttler-storage.service.spec.ts
Jason Woltje 7ae92f3e1c fix(#338): Log ERROR on rate limiter fallback and track degraded mode
- 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>
2026-02-05 16:39:55 -06:00

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);
});
});
});