/** * Unit tests for src/repositories/TokenRepository.ts * Uses mocked pg.Pool and Redis client — no real infrastructure. */ import { Pool } from 'pg'; import { RedisClientType } from 'redis'; import { TokenRepository } from '../../../src/repositories/TokenRepository'; jest.mock('pg', () => ({ Pool: jest.fn().mockImplementation(() => ({ query: jest.fn(), connect: jest.fn(), })), })); // ─── helpers ───────────────────────────────────────────────────────────────── function buildMockRedis(): jest.Mocked> { return { get: jest.fn(), set: jest.fn(), incr: jest.fn(), expire: jest.fn(), }; } // ─── suite ─────────────────────────────────────────────────────────────────── describe('TokenRepository', () => { let pool: jest.Mocked; let redis: ReturnType; let repo: TokenRepository; beforeEach(() => { jest.clearAllMocks(); pool = new Pool() as jest.Mocked; redis = buildMockRedis(); repo = new TokenRepository(pool, redis as unknown as RedisClientType); }); // ── addToRevocationList ─────────────────────────────────────────────────────── describe('addToRevocationList()', () => { it('should write to Redis with correct key and TTL, then insert to DB', async () => { redis.set.mockResolvedValue('OK'); (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 }); const jti = 'test-jti-001'; const expiresAt = new Date(Date.now() + 3600_000); // 1 hour from now await repo.addToRevocationList(jti, expiresAt); // Redis set call expect(redis.set).toHaveBeenCalledTimes(1); const [redisKey, value, options] = redis.set.mock.calls[0] as [string, string, { EX: number }]; expect(redisKey).toBe(`revoked:${jti}`); expect(value).toBe('1'); expect(options.EX).toBeGreaterThan(0); // DB insert call expect(pool.query).toHaveBeenCalledTimes(1); const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]]; expect(sql).toContain('INSERT INTO token_revocations'); expect(params).toContain(jti); expect(params).toContain(expiresAt); }); it('should use a minimum TTL of 1 second for already-expired tokens', async () => { redis.set.mockResolvedValue('OK'); (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 }); const jti = 'expired-jti'; const expiresAt = new Date(Date.now() - 5000); // already expired await repo.addToRevocationList(jti, expiresAt); const [, , options] = redis.set.mock.calls[0] as [string, string, { EX: number }]; expect(options.EX).toBe(1); }); }); // ── isRevoked ───────────────────────────────────────────────────────────────── describe('isRevoked()', () => { it('should return true immediately when Redis has the key', async () => { redis.get.mockResolvedValue('1'); const result = await repo.isRevoked('revoked-jti'); expect(result).toBe(true); // DB should NOT be queried expect(pool.query).not.toHaveBeenCalled(); }); it('should fall back to DB and return true when found there', async () => { redis.get.mockResolvedValue(null); (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ jti: 'db-revoked-jti', expires_at: new Date(), revoked_at: new Date() }], rowCount: 1, }); const result = await repo.isRevoked('db-revoked-jti'); expect(redis.get).toHaveBeenCalledTimes(1); expect(pool.query).toHaveBeenCalledTimes(1); expect(result).toBe(true); }); it('should return false when neither Redis nor DB has the key', async () => { redis.get.mockResolvedValue(null); (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 }); const result = await repo.isRevoked('valid-jti'); expect(result).toBe(false); }); }); // ── incrementMonthlyCount ───────────────────────────────────────────────────── describe('incrementMonthlyCount()', () => { it('should increment the Redis key and return the new count', async () => { redis.incr.mockResolvedValue(5); redis.expire.mockResolvedValue(true); const count = await repo.incrementMonthlyCount('client-001'); expect(redis.incr).toHaveBeenCalledTimes(1); const [key] = redis.incr.mock.calls[0] as [string]; expect(key).toMatch(/^monthly:tokens:client-001:/); expect(count).toBe(5); }); it('should set TTL when count becomes 1 (first token of the month)', async () => { redis.incr.mockResolvedValue(1); redis.expire.mockResolvedValue(true); await repo.incrementMonthlyCount('client-new'); expect(redis.expire).toHaveBeenCalledTimes(1); const [, ttl] = redis.expire.mock.calls[0] as [string, number]; expect(ttl).toBeGreaterThan(0); }); it('should NOT set TTL when count is greater than 1', async () => { redis.incr.mockResolvedValue(10); await repo.incrementMonthlyCount('client-existing'); expect(redis.expire).not.toHaveBeenCalled(); }); }); // ── getMonthlyCount ─────────────────────────────────────────────────────────── describe('getMonthlyCount()', () => { it('should return the count from Redis', async () => { redis.get.mockResolvedValue('42'); const count = await repo.getMonthlyCount('client-001'); expect(redis.get).toHaveBeenCalledTimes(1); const [key] = redis.get.mock.calls[0] as [string]; expect(key).toMatch(/^monthly:tokens:client-001:/); expect(count).toBe(42); }); it('should return 0 when the Redis key does not exist', async () => { redis.get.mockResolvedValue(null); const count = await repo.getMonthlyCount('client-no-tokens'); expect(count).toBe(0); }); }); });