diff --git a/backend/src/unify-redis/app.module.ts b/backend/src/unify-redis/app.module.ts new file mode 100644 index 0000000..f075a11 --- /dev/null +++ b/backend/src/unify-redis/app.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TerminusModule } from '@nestjs/terminus'; +import { CacheModule } from './cache/cache.module'; +import { HealthController } from './health/health.controller'; +import { RedisModule } from './redis/redis.module'; +// Import your feature modules below as usual — they no longer need to create +// their own Redis clients. +// import { LeaderboardsModule } from './leaderboards/leaderboards.module'; + +@Module({ + imports: [ + // ── Config ────────────────────────────────────────────────────────────── + ConfigModule.forRoot({ + isGlobal: true, + cache: true, + }), + + // ── Redis — registered once, exported globally ─────────────────────────── + // All feature modules that previously imported their own redis.module.ts + // now rely on the REDIS_CLIENT token provided here. + RedisModule.forRootAsync(), + + // ── Cache — platform-level service available everywhere ────────────────── + // CacheService and CacheInterceptor are now injectable application-wide + // without re-importing CacheModule in every feature module. + CacheModule, + + // ── Health ─────────────────────────────────────────────────────────────── + TerminusModule, + + // ── Feature modules ────────────────────────────────────────────────────── + // LeaderboardsModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/backend/src/unify-redis/cache.interceptor.spec.ts b/backend/src/unify-redis/cache.interceptor.spec.ts new file mode 100644 index 0000000..75d8c0b --- /dev/null +++ b/backend/src/unify-redis/cache.interceptor.spec.ts @@ -0,0 +1,198 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { of } from 'rxjs'; +import { CacheInterceptor, CACHE_TTL_METADATA } from './cache.interceptor'; +import { CacheService } from './cache.service'; + +// --------------------------------------------------------------------------- +// Helper factories +// --------------------------------------------------------------------------- + +function makeContext(method = 'GET', path = '/leaderboard', query = {}): ExecutionContext { + const mockRequest = { method, path, query }; + const mockResponse = { setHeader: jest.fn() }; + const mockHandler = {}; + + return { + switchToHttp: () => ({ + getRequest: () => mockRequest, + getResponse: () => mockResponse, + }), + getHandler: () => mockHandler, + } as unknown as ExecutionContext; +} + +function makeHandler(returnValue: unknown): jest.Mock { + return jest.fn().mockReturnValue(of(returnValue)); +} + +const mockCacheService = (): jest.Mocked => + ({ + get: jest.fn(), + set: jest.fn(), + ping: jest.fn(), + metrics: { hits: 0, misses: 0, errors: 0, degraded: false }, + isConnected: true, + } as unknown as jest.Mocked); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('CacheInterceptor', () => { + let interceptor: CacheInterceptor; + let cache: jest.Mocked; + let reflector: Reflector; + + beforeEach(async () => { + cache = mockCacheService(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheInterceptor, + { provide: CacheService, useValue: cache }, + Reflector, + ], + }).compile(); + + interceptor = module.get(CacheInterceptor); + reflector = module.get(Reflector); + }); + + describe('non-GET requests', () => { + it('passes through POST requests without touching the cache', async () => { + const ctx = makeContext('POST', '/leaderboard'); + const next = { handle: makeHandler({ ok: true }) }; + + const result$ = await interceptor.intercept(ctx, next); + const emission = await new Promise((res) => result$.subscribe(res)); + + expect(emission).toEqual({ ok: true }); + expect(cache.get).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('passes through DELETE requests without touching the cache', async () => { + const ctx = makeContext('DELETE', '/cache/key'); + const next = { handle: makeHandler(null) }; + + const result$ = await interceptor.intercept(ctx, next); + await new Promise((res) => result$.subscribe(res)); + + expect(cache.get).not.toHaveBeenCalled(); + }); + }); + + describe('GET without @CacheResponse decorator', () => { + it('passes through without caching when no TTL metadata is present', async () => { + jest.spyOn(reflector, 'get').mockReturnValue(undefined); + const ctx = makeContext('GET', '/leaderboard'); + const next = { handle: makeHandler({ data: [] }) }; + + const result$ = await interceptor.intercept(ctx, next); + const emission = await new Promise((res) => result$.subscribe(res)); + + expect(emission).toEqual({ data: [] }); + expect(cache.get).not.toHaveBeenCalled(); + }); + }); + + describe('cache MISS — route decorated with @CacheResponse', () => { + beforeEach(() => { + jest.spyOn(reflector, 'get').mockImplementation((metadataKey) => { + if (metadataKey === CACHE_TTL_METADATA) return 60; + return undefined; // no custom key + }); + }); + + it('calls the handler, stores the result, and sets X-Cache: MISS', async () => { + cache.get.mockResolvedValue(null); + cache.set.mockResolvedValue(true); + + const ctx = makeContext('GET', '/leaderboard'); + const response = ctx.switchToHttp().getResponse() as { setHeader: jest.Mock }; + const next = { handle: makeHandler({ scores: [1, 2, 3] }) }; + + const result$ = await interceptor.intercept(ctx, next); + await new Promise((res) => result$.subscribe({ complete: res })); + + expect(cache.get).toHaveBeenCalledWith('http:/leaderboard'); + expect(cache.set).toHaveBeenCalledWith( + 'http:/leaderboard', + { scores: [1, 2, 3] }, + { ttl: 60 }, + ); + expect(response.setHeader).toHaveBeenCalledWith('X-Cache', 'MISS'); + }); + }); + + describe('cache HIT', () => { + beforeEach(() => { + jest.spyOn(reflector, 'get').mockImplementation((metadataKey) => { + if (metadataKey === CACHE_TTL_METADATA) return 60; + return undefined; + }); + }); + + it('returns cached value and sets X-Cache: HIT without calling handler', async () => { + const cached = { scores: [99, 88] }; + cache.get.mockResolvedValue(cached); + + const ctx = makeContext('GET', '/leaderboard'); + const response = ctx.switchToHttp().getResponse() as { setHeader: jest.Mock }; + const next = { handle: makeHandler({ scores: [] }) }; // should not be called + + const result$ = await interceptor.intercept(ctx, next); + const emission = await new Promise((res) => result$.subscribe(res)); + + expect(emission).toEqual(cached); + expect(next.handle).not.toHaveBeenCalled(); + expect(response.setHeader).toHaveBeenCalledWith('X-Cache', 'HIT'); + }); + }); + + describe('Redis degradation', () => { + beforeEach(() => { + jest.spyOn(reflector, 'get').mockImplementation((metadataKey) => { + if (metadataKey === CACHE_TTL_METADATA) return 60; + return undefined; + }); + }); + + it('falls through to the handler when cache.get throws', async () => { + cache.get.mockRejectedValue(new Error('ECONNREFUSED')); + const ctx = makeContext('GET', '/leaderboard'); + const next = { handle: makeHandler({ live: true }) }; + + const result$ = await interceptor.intercept(ctx, next); + const emission = await new Promise((res) => result$.subscribe(res)); + + expect(emission).toEqual({ live: true }); + }); + }); + + describe('cache key construction', () => { + beforeEach(() => { + jest.spyOn(reflector, 'get').mockImplementation((metadataKey) => { + if (metadataKey === CACHE_TTL_METADATA) return 30; + return undefined; + }); + }); + + it('incorporates query-string parameters into the cache key', async () => { + cache.get.mockResolvedValue(null); + cache.set.mockResolvedValue(true); + + const ctx = makeContext('GET', '/scores', { page: '2', limit: '20' }); + const next = { handle: makeHandler([]) }; + + await interceptor.intercept(ctx, next); + + const calledKey: string = (cache.get as jest.Mock).mock.calls[0][0]; + expect(calledKey).toContain('/scores'); + expect(calledKey).toContain('page=2'); + expect(calledKey).toContain('limit=20'); + }); + }); +}); diff --git a/backend/src/unify-redis/cache.interceptor.ts b/backend/src/unify-redis/cache.interceptor.ts new file mode 100644 index 0000000..c36dbcb --- /dev/null +++ b/backend/src/unify-redis/cache.interceptor.ts @@ -0,0 +1,81 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request, Response } from 'express'; +import { Observable, of, tap } from 'rxjs'; +import { CacheService } from './cache.service'; + +export const CACHE_TTL_METADATA = 'cache:ttl'; +export const CACHE_KEY_METADATA = 'cache:key'; + +/** + * Decorate a controller method with @CacheResponse(ttl) to have its JSON + * payload cached automatically. Falls through silently when Redis is degraded. + */ +export function CacheResponse(ttl = 60, key?: string) { + return (target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + Reflect.defineMetadata(CACHE_TTL_METADATA, ttl, descriptor.value); + if (key) Reflect.defineMetadata(CACHE_KEY_METADATA, key, descriptor.value); + return descriptor; + }; +} + +@Injectable() +export class CacheInterceptor implements NestInterceptor { + private readonly logger = new Logger(CacheInterceptor.name); + + constructor( + private readonly cache: CacheService, + private readonly reflector: Reflector, + ) {} + + async intercept(ctx: ExecutionContext, next: CallHandler): Promise> { + const request = ctx.switchToHttp().getRequest(); + + // Only cache idempotent reads + if (request.method !== 'GET') return next.handle(); + + const handler = ctx.getHandler(); + const ttl = this.reflector.get(CACHE_TTL_METADATA, handler); + + // Only cache routes decorated with @CacheResponse + if (!ttl) return next.handle(); + + const customKey = this.reflector.get(CACHE_KEY_METADATA, handler); + const cacheKey = customKey ?? this.buildKey(request); + + try { + const cached = await this.cache.get(cacheKey); + if (cached !== null) { + this.logger.debug(`Cache HIT: ${cacheKey}`); + const response = ctx.switchToHttp().getResponse(); + response.setHeader('X-Cache', 'HIT'); + return of(cached); + } + } catch { + // Redis unavailable — serve live + return next.handle(); + } + + return next.handle().pipe( + tap(async (data) => { + const stored = await this.cache.set(cacheKey, data, { ttl }); + if (stored) { + this.logger.debug(`Cache SET: ${cacheKey} (ttl=${ttl}s)`); + const response = ctx.switchToHttp().getResponse(); + response.setHeader('X-Cache', 'MISS'); + } + }), + ); + } + + private buildKey(req: Request): string { + const qs = new URLSearchParams(req.query as Record).toString(); + return `http:${req.path}${qs ? `?${qs}` : ''}`; + } +} diff --git a/backend/src/unify-redis/cache.module.ts b/backend/src/unify-redis/cache.module.ts new file mode 100644 index 0000000..13a6163 --- /dev/null +++ b/backend/src/unify-redis/cache.module.ts @@ -0,0 +1,24 @@ +import { Global, Module } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RedisModule } from '../redis/redis.module'; +import { CacheInterceptor } from './cache.interceptor'; +import { CacheService } from './cache.service'; + +/** + * CacheModule is marked @Global so that CacheService can be injected anywhere + * without repeating the import in every feature module. + * + * RedisModule is NOT imported here — it is registered once in AppModule via + * RedisModule.forRootAsync() which is already @Global itself. This module + * simply declares the service layer that wraps the shared Redis client. + */ +@Global() +@Module({ + providers: [ + CacheService, + CacheInterceptor, + Reflector, + ], + exports: [CacheService, CacheInterceptor], +}) +export class CacheModule {} diff --git a/backend/src/unify-redis/cache.service.spec.ts b/backend/src/unify-redis/cache.service.spec.ts new file mode 100644 index 0000000..9432510 --- /dev/null +++ b/backend/src/unify-redis/cache.service.spec.ts @@ -0,0 +1,292 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { REDIS_CLIENT } from '../redis/redis.constants'; +import { CacheService } from './cache.service'; + +// --------------------------------------------------------------------------- +// Shared mock factory — rebuilt fresh for each test suite section so state +// does not leak between specs. +// --------------------------------------------------------------------------- + +const mockRedis = () => ({ + status: 'ready' as string, + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + exists: jest.fn(), + ttl: jest.fn(), + keys: jest.fn(), + ping: jest.fn(), + quit: jest.fn(), +}); + +type MockRedis = ReturnType; + +async function createService(redis: MockRedis): Promise { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheService, + { provide: REDIS_CLIENT, useValue: redis }, + ], + }).compile(); + + return module.get(CacheService); +} + +// --------------------------------------------------------------------------- +// GET +// --------------------------------------------------------------------------- + +describe('CacheService.get()', () => { + let redis: MockRedis; + let service: CacheService; + + beforeEach(async () => { + redis = mockRedis(); + service = await createService(redis); + }); + + it('returns null and increments misses when key not found', async () => { + redis.get.mockResolvedValue(null); + const result = await service.get('missing-key'); + expect(result).toBeNull(); + expect(service.metrics.misses).toBe(1); + expect(service.metrics.hits).toBe(0); + }); + + it('deserializes JSON and increments hits on cache hit', async () => { + const payload = { score: 42, userId: 'abc' }; + redis.get.mockResolvedValue(JSON.stringify(payload)); + const result = await service.get('some-key'); + expect(result).toEqual(payload); + expect(service.metrics.hits).toBe(1); + }); + + it('returns null and increments errors when Redis throws', async () => { + redis.get.mockRejectedValue(new Error('ECONNREFUSED')); + const result = await service.get('broken-key'); + expect(result).toBeNull(); + expect(service.metrics.errors).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// SET +// --------------------------------------------------------------------------- + +describe('CacheService.set()', () => { + let redis: MockRedis; + let service: CacheService; + + beforeEach(async () => { + redis = mockRedis(); + service = await createService(redis); + }); + + it('calls redis.set without EX when no TTL is given', async () => { + redis.set.mockResolvedValue('OK'); + const ok = await service.set('key', { foo: 'bar' }); + expect(ok).toBe(true); + expect(redis.set).toHaveBeenCalledWith('key', JSON.stringify({ foo: 'bar' })); + }); + + it('calls redis.set with EX when TTL is specified', async () => { + redis.set.mockResolvedValue('OK'); + const ok = await service.set('key', 'hello', { ttl: 120 }); + expect(ok).toBe(true); + expect(redis.set).toHaveBeenCalledWith('key', '"hello"', 'EX', 120); + }); + + it('returns false and increments errors when Redis throws', async () => { + redis.set.mockRejectedValue(new Error('OOM')); + const ok = await service.set('key', 'value'); + expect(ok).toBe(false); + expect(service.metrics.errors).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// DEL +// --------------------------------------------------------------------------- + +describe('CacheService.del()', () => { + let redis: MockRedis; + let service: CacheService; + + beforeEach(async () => { + redis = mockRedis(); + service = await createService(redis); + }); + + it('returns the number of deleted keys on success', async () => { + redis.del.mockResolvedValue(2); + const count = await service.del('k1', 'k2'); + expect(count).toBe(2); + expect(redis.del).toHaveBeenCalledWith('k1', 'k2'); + }); + + it('returns 0 and increments errors when Redis throws', async () => { + redis.del.mockRejectedValue(new Error('READONLY')); + const count = await service.del('k1'); + expect(count).toBe(0); + expect(service.metrics.errors).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// EXISTS +// --------------------------------------------------------------------------- + +describe('CacheService.exists()', () => { + let redis: MockRedis; + let service: CacheService; + + beforeEach(async () => { + redis = mockRedis(); + service = await createService(redis); + }); + + it('returns true when key exists', async () => { + redis.exists.mockResolvedValue(1); + expect(await service.exists('key')).toBe(true); + }); + + it('returns false when key does not exist', async () => { + redis.exists.mockResolvedValue(0); + expect(await service.exists('key')).toBe(false); + }); + + it('returns false on Redis error', async () => { + redis.exists.mockRejectedValue(new Error('timeout')); + expect(await service.exists('key')).toBe(false); + expect(service.metrics.errors).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// TTL +// --------------------------------------------------------------------------- + +describe('CacheService.ttl()', () => { + let redis: MockRedis; + let service: CacheService; + + beforeEach(async () => { + redis = mockRedis(); + service = await createService(redis); + }); + + it('returns the remaining TTL in seconds', async () => { + redis.ttl.mockResolvedValue(300); + expect(await service.ttl('key')).toBe(300); + }); + + it('returns -2 on Redis error (key-does-not-exist sentinel)', async () => { + redis.ttl.mockRejectedValue(new Error('timeout')); + expect(await service.ttl('key')).toBe(-2); + expect(service.metrics.errors).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// RESET (pattern eviction) +// --------------------------------------------------------------------------- + +describe('CacheService.reset()', () => { + let redis: MockRedis; + let service: CacheService; + + beforeEach(async () => { + redis = mockRedis(); + service = await createService(redis); + }); + + it('deletes all keys matching a glob pattern', async () => { + redis.keys.mockResolvedValue(['prefix:1', 'prefix:2']); + redis.del.mockResolvedValue(2); + await service.reset('prefix:*'); + expect(redis.del).toHaveBeenCalledWith('prefix:1', 'prefix:2'); + }); + + it('does not call del when no keys match', async () => { + redis.keys.mockResolvedValue([]); + await service.reset('empty:*'); + expect(redis.del).not.toHaveBeenCalled(); + }); + + it('increments errors on Redis failure', async () => { + redis.keys.mockRejectedValue(new Error('timeout')); + await service.reset('any:*'); + expect(service.metrics.errors).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// PING & Health +// --------------------------------------------------------------------------- + +describe('CacheService.ping()', () => { + let redis: MockRedis; + let service: CacheService; + + beforeEach(async () => { + redis = mockRedis(); + service = await createService(redis); + }); + + it('returns true when Redis responds PONG', async () => { + redis.ping.mockResolvedValue('PONG'); + expect(await service.ping()).toBe(true); + }); + + it('returns false when Redis is unreachable', async () => { + redis.ping.mockRejectedValue(new Error('ECONNREFUSED')); + expect(await service.ping()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Metrics accumulation +// --------------------------------------------------------------------------- + +describe('CacheService metrics', () => { + it('correctly accumulates hits, misses and errors across operations', async () => { + const redis = mockRedis(); + const service = await createService(redis); + + redis.get.mockResolvedValueOnce('"a"'); // hit + redis.get.mockResolvedValueOnce(null); // miss + redis.get.mockRejectedValueOnce(new Error('x')); // error + + await service.get('k1'); + await service.get('k2'); + await service.get('k3'); + + expect(service.metrics).toEqual({ + hits: 1, + misses: 1, + errors: 1, + degraded: false, // status is 'ready' in mock + }); + }); + + it('reports degraded=true when redis.status is not "ready"', async () => { + const redis = mockRedis(); + redis.status = 'reconnecting'; + const service = await createService(redis); + expect(service.metrics.degraded).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +describe('CacheService.onModuleDestroy()', () => { + it('calls redis.quit on module teardown', async () => { + const redis = mockRedis(); + redis.quit.mockResolvedValue('OK'); + const service = await createService(redis); + await service.onModuleDestroy(); + expect(redis.quit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/unify-redis/cache.service.ts b/backend/src/unify-redis/cache.service.ts new file mode 100644 index 0000000..0b33f07 --- /dev/null +++ b/backend/src/unify-redis/cache.service.ts @@ -0,0 +1,154 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, +} from '@nestjs/common'; +import Redis from 'ioredis'; +import { REDIS_CLIENT } from '../redis/redis.constants'; + +export interface CacheSetOptions { + /** Time-to-live in seconds. Omit for no expiry. */ + ttl?: number; +} + +export interface CacheMetrics { + hits: number; + misses: number; + errors: number; + degraded: boolean; +} + +@Injectable() +export class CacheService implements OnModuleDestroy { + private readonly logger = new Logger(CacheService.name); + + private _hits = 0; + private _misses = 0; + private _errors = 0; + + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + // --------------------------------------------------------------------------- + // Core operations + // --------------------------------------------------------------------------- + + async get(key: string): Promise { + try { + const raw = await this.redis.get(key); + if (raw === null) { + this._misses++; + return null; + } + this._hits++; + return JSON.parse(raw) as T; + } catch (err) { + this._errors++; + this.logger.warn(`Cache GET failed for key "${key}": ${(err as Error).message}`); + return null; // degrade gracefully + } + } + + async set( + key: string, + value: T, + options?: CacheSetOptions, + ): Promise { + try { + const serialized = JSON.stringify(value); + if (options?.ttl) { + await this.redis.set(key, serialized, 'EX', options.ttl); + } else { + await this.redis.set(key, serialized); + } + return true; + } catch (err) { + this._errors++; + this.logger.warn(`Cache SET failed for key "${key}": ${(err as Error).message}`); + return false; // degrade gracefully + } + } + + async del(...keys: string[]): Promise { + try { + return await this.redis.del(...keys); + } catch (err) { + this._errors++; + this.logger.warn(`Cache DEL failed for keys [${keys.join(', ')}]: ${(err as Error).message}`); + return 0; + } + } + + async exists(key: string): Promise { + try { + const count = await this.redis.exists(key); + return count > 0; + } catch (err) { + this._errors++; + this.logger.warn(`Cache EXISTS failed for key "${key}": ${(err as Error).message}`); + return false; + } + } + + async ttl(key: string): Promise { + try { + return await this.redis.ttl(key); + } catch (err) { + this._errors++; + this.logger.warn(`Cache TTL failed for key "${key}": ${(err as Error).message}`); + return -2; // -2 = key does not exist (standard Redis sentinel) + } + } + + async reset(pattern: string): Promise { + try { + const keys = await this.redis.keys(pattern); + if (keys.length > 0) { + await this.redis.del(...keys); + this.logger.log(`Evicted ${keys.length} key(s) matching "${pattern}"`); + } + } catch (err) { + this._errors++; + this.logger.warn(`Cache RESET failed for pattern "${pattern}": ${(err as Error).message}`); + } + } + + // --------------------------------------------------------------------------- + // Health & observability + // --------------------------------------------------------------------------- + + async ping(): Promise { + try { + const result = await this.redis.ping(); + return result === 'PONG'; + } catch { + return false; + } + } + + get metrics(): CacheMetrics { + return { + hits: this._hits, + misses: this._misses, + errors: this._errors, + degraded: !this.isConnected, + }; + } + + get isConnected(): boolean { + return this.redis.status === 'ready'; + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + async onModuleDestroy(): Promise { + try { + await this.redis.quit(); + this.logger.log('Redis client disconnected cleanly'); + } catch (err) { + this.logger.error('Error during Redis disconnect', (err as Error).stack); + } + } +} diff --git a/backend/src/unify-redis/health.controller.spec.ts b/backend/src/unify-redis/health.controller.spec.ts new file mode 100644 index 0000000..4cdb729 --- /dev/null +++ b/backend/src/unify-redis/health.controller.spec.ts @@ -0,0 +1,83 @@ +import { HealthCheckService } from '@nestjs/terminus'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CacheService } from '../cache/cache.service'; +import { HealthController } from './health.controller'; + +const mockHealth = () => ({ + check: jest.fn(), +}); + +const mockCacheService = (connected: boolean, pingResult: boolean) => ({ + ping: jest.fn().mockResolvedValue(pingResult), + metrics: { hits: 10, misses: 2, errors: 1, degraded: !connected }, + isConnected: connected, +}); + +describe('HealthController', () => { + let controller: HealthController; + let health: jest.Mocked; + + async function buildModule( + connected = true, + pingResult = true, + ): Promise { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { provide: HealthCheckService, useValue: mockHealth() }, + { + provide: 'DiskHealthIndicator', + useValue: { checkStorage: jest.fn() }, + }, + { + provide: 'MemoryHealthIndicator', + useValue: { checkHeap: jest.fn() }, + }, + { + provide: 'HttpHealthIndicator', + useValue: { pingCheck: jest.fn() }, + }, + { provide: CacheService, useValue: mockCacheService(connected, pingResult) }, + ], + }) + .overrideProvider(HealthCheckService) + .useValue(mockHealth()) + .compile(); + + controller = module.get(HealthController); + health = module.get(HealthCheckService); + } + + describe('check() — Redis healthy', () => { + beforeEach(async () => buildModule(true, true)); + + it('calls HealthCheckService.check with an array of indicator functions', async () => { + health.check.mockResolvedValue({ status: 'ok', info: {}, error: {}, details: {} }); + await controller.check(); + expect(health.check).toHaveBeenCalledWith(expect.arrayContaining([expect.any(Function)])); + }); + }); + + describe('Redis health indicator', () => { + it('returns status: up when ping succeeds', async () => { + await buildModule(true, true); + health.check.mockImplementation(async (indicators) => { + // Invoke the first indicator (redis) + const result = await (indicators[0] as () => Promise)(); + return result as any; + }); + const result = await controller.check(); + expect(result).toMatchObject({ redis: { status: 'up' } }); + }); + + it('returns status: down when ping fails', async () => { + await buildModule(false, false); + health.check.mockImplementation(async (indicators) => { + const result = await (indicators[0] as () => Promise)(); + return result as any; + }); + const result = await controller.check(); + expect(result).toMatchObject({ redis: { status: 'down', degraded: true } }); + }); + }); +}); diff --git a/backend/src/unify-redis/health.controller.ts b/backend/src/unify-redis/health.controller.ts new file mode 100644 index 0000000..fd05eb6 --- /dev/null +++ b/backend/src/unify-redis/health.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get } from '@nestjs/common'; +import { + DiskHealthIndicator, + HealthCheck, + HealthCheckService, + HttpHealthIndicator, + MemoryHealthIndicator, +} from '@nestjs/terminus'; +import { CacheService } from '../cache/cache.service'; + +@Controller('health') +export class HealthController { + constructor( + private readonly health: HealthCheckService, + private readonly http: HttpHealthIndicator, + private readonly disk: DiskHealthIndicator, + private readonly memory: MemoryHealthIndicator, + private readonly cache: CacheService, + ) {} + + @Get() + @HealthCheck() + check() { + return this.health.check([ + () => this.redisHealthIndicator(), + () => this.memory.checkHeap('memory_heap', 150 * 1024 * 1024), + () => this.disk.checkStorage('storage', { path: '/', thresholdPercent: 0.9 }), + ]); + } + + // --------------------------------------------------------------------------- + // Redis health indicator (manual — avoids @nestjs/terminus RedisIndicator + // dependency on a specific Redis client type) + // --------------------------------------------------------------------------- + + private async redisHealthIndicator() { + const key = 'redis'; + const alive = await this.cache.ping(); + const metrics = this.cache.metrics; + + if (!alive) { + return { + [key]: { + status: 'down', + degraded: true, + metrics, + }, + }; + } + + return { + [key]: { + status: 'up', + metrics, + }, + }; + } +} diff --git a/backend/src/unify-redis/index.ts b/backend/src/unify-redis/index.ts new file mode 100644 index 0000000..3985cbf --- /dev/null +++ b/backend/src/unify-redis/index.ts @@ -0,0 +1,2 @@ +export * from './redis.module'; +export * from './redis.constants'; diff --git a/backend/src/unify-redis/redis.constants.ts b/backend/src/unify-redis/redis.constants.ts new file mode 100644 index 0000000..a5a9bb4 --- /dev/null +++ b/backend/src/unify-redis/redis.constants.ts @@ -0,0 +1,14 @@ +export const REDIS_CLIENT = Symbol('REDIS_CLIENT'); +export const REDIS_OPTIONS = Symbol('REDIS_OPTIONS'); + +export interface RedisModuleOptions { + host: string; + port: number; + password?: string; + db?: number; + keyPrefix?: string; + connectTimeout?: number; + maxRetriesPerRequest?: number; + enableReadyCheck?: boolean; + lazyConnect?: boolean; +} diff --git a/backend/src/unify-redis/redis.module.spec.ts b/backend/src/unify-redis/redis.module.spec.ts new file mode 100644 index 0000000..eed322a --- /dev/null +++ b/backend/src/unify-redis/redis.module.spec.ts @@ -0,0 +1,54 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import Redis from 'ioredis'; +import { REDIS_CLIENT, RedisModuleOptions } from './redis.constants'; +import { RedisModule } from './redis.module'; + +// --------------------------------------------------------------------------- +// We don't spin up a real Redis in unit tests — instead we verify the module +// wires the provider correctly and that the client type is correct. E2E tests +// in a CI environment with a Redis container cover actual connectivity. +// --------------------------------------------------------------------------- + +describe('RedisModule.forRoot()', () => { + const options: RedisModuleOptions = { + host: '127.0.0.1', + port: 6380, // non-standard port so tests fail fast instead of lingering + connectTimeout: 100, + maxRetriesPerRequest: 0, + lazyConnect: true, // don't actually connect during module setup + }; + + let module: TestingModule; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [RedisModule.forRoot(options)], + }).compile(); + }); + + afterAll(async () => { + const client = module.get(REDIS_CLIENT); + await client.disconnect(); + await module.close(); + }); + + it('registers the REDIS_CLIENT token', () => { + const client = module.get(REDIS_CLIENT); + expect(client).toBeDefined(); + }); + + it('injects an ioredis instance', () => { + const client = module.get(REDIS_CLIENT); + // ioredis instances expose these methods + expect(typeof client.get).toBe('function'); + expect(typeof client.set).toBe('function'); + expect(typeof client.del).toBe('function'); + expect(typeof client.quit).toBe('function'); + }); +}); + +describe('RedisModule token availability', () => { + it('REDIS_CLIENT is a Symbol', () => { + expect(typeof REDIS_CLIENT).toBe('symbol'); + }); +}); diff --git a/backend/src/unify-redis/redis.module.ts b/backend/src/unify-redis/redis.module.ts new file mode 100644 index 0000000..c18ced7 --- /dev/null +++ b/backend/src/unify-redis/redis.module.ts @@ -0,0 +1,106 @@ +import { DynamicModule, Global, Logger, Module, Provider } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; +import { REDIS_CLIENT, REDIS_OPTIONS, RedisModuleOptions } from './redis.constants'; + +@Global() +@Module({}) +export class RedisModule { + private static readonly logger = new Logger(RedisModule.name); + + /** + * Register the module synchronously. Preferred in AppModule where + * ConfigService is already available via forRootAsync below, but handy + * for tests that want a pre-built options object. + */ + static forRoot(options: RedisModuleOptions): DynamicModule { + const optionsProvider: Provider = { + provide: REDIS_OPTIONS, + useValue: options, + }; + + const clientProvider: Provider = { + provide: REDIS_CLIENT, + useFactory: () => RedisModule.createClient(options), + }; + + return { + module: RedisModule, + imports: [], + providers: [optionsProvider, clientProvider], + exports: [REDIS_CLIENT, REDIS_OPTIONS], + }; + } + + /** + * Register the module asynchronously — used in the real AppModule so that + * the options are resolved from ConfigService after the environment is loaded. + */ + static forRootAsync(): DynamicModule { + const clientProvider: Provider = { + provide: REDIS_CLIENT, + inject: [ConfigService], + useFactory: (config: ConfigService) => { + const options: RedisModuleOptions = { + host: config.get('REDIS_HOST', 'localhost'), + port: config.get('REDIS_PORT', 6379), + password: config.get('REDIS_PASSWORD'), + db: config.get('REDIS_DB', 0), + keyPrefix: config.get('REDIS_KEY_PREFIX', 'tip-tune:'), + connectTimeout: config.get('REDIS_CONNECT_TIMEOUT', 5_000), + maxRetriesPerRequest: config.get('REDIS_MAX_RETRIES', 3), + enableReadyCheck: true, + lazyConnect: false, + }; + return RedisModule.createClient(options); + }, + }; + + return { + module: RedisModule, + imports: [ConfigModule], + providers: [clientProvider], + exports: [REDIS_CLIENT], + }; + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + private static createClient(options: RedisModuleOptions): Redis { + const client = new Redis({ + host: options.host, + port: options.port, + password: options.password, + db: options.db ?? 0, + keyPrefix: options.keyPrefix ?? '', + connectTimeout: options.connectTimeout ?? 5_000, + maxRetriesPerRequest: options.maxRetriesPerRequest ?? 3, + enableReadyCheck: options.enableReadyCheck ?? true, + lazyConnect: options.lazyConnect ?? false, + }); + + client.on('connect', () => + RedisModule.logger.log(`Redis connected → ${options.host}:${options.port}`), + ); + + client.on('ready', () => + RedisModule.logger.log('Redis client ready'), + ); + + client.on('error', (err: Error) => + RedisModule.logger.error(`Redis error: ${err.message}`, err.stack), + ); + + client.on('close', () => + RedisModule.logger.warn('Redis connection closed'), + ); + + client.on('reconnecting', (delay: number) => + RedisModule.logger.warn(`Redis reconnecting in ${delay}ms`), + ); + + return client; + } +}