diff --git a/package-lock.json b/package-lock.json index 9e92c53..934ed94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "fastify-plugin": "^5.1.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", + "node-cache": "^5.1.2", "pg": "^8.20.0" }, "bin": { @@ -39,6 +40,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.7.5", + "@types/node-cache": "^4.1.3", "@types/pg": "^8.20.0", "@vitest/coverage-v8": "^4.1.2", "gts": "^7.0.0", @@ -2526,6 +2528,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/node-cache/-/node-cache-4.1.3.tgz", + "integrity": "sha512-3hsqnv3H1zkOhjygJaJUYmgz5+FcPO3vejBX7cE9/cnuINOJYrzkfOnUCvpwGe9kMZANIHJA7J5pOdeyv52OEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -3522,6 +3534,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -6919,6 +6940,18 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", diff --git a/package.json b/package.json index c194cae..e5e3ef8 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.7.5", + "@types/node-cache": "^4.1.3", "@types/pg": "^8.20.0", "@vitest/coverage-v8": "^4.1.2", "gts": "^7.0.0", @@ -71,6 +72,7 @@ "fastify-plugin": "^5.1.0", "ioredis": "^5.10.1", "jsonwebtoken": "^9.0.3", + "node-cache": "^5.1.2", "pg": "^8.20.0" } } diff --git a/src/plugin/cache.ts b/src/plugin/cache.ts new file mode 100644 index 0000000..936b5b3 --- /dev/null +++ b/src/plugin/cache.ts @@ -0,0 +1,132 @@ +import {FastifyInstance} from 'fastify'; +import fp from 'fastify-plugin'; +import Redis from 'ioredis'; +import NodeCache from 'node-cache'; + +export interface ICache { + get(key: string): Promise; + set(key: string, value: T, ttlSeconds?: number): Promise; + delete(key: string): Promise; + has(key: string): Promise; + clear(): Promise; + raw: Redis | NodeCache; +} + +export default fp( + async (fastify: FastifyInstance) => { + let cacheWrapper: ICache; + const cacheConfig = fastify.appConfig?.cache_db; + + if ( + cacheConfig && + cacheConfig.engine === 'redis' && + cacheConfig.connection && + cacheConfig.connection.uri + ) { + // Use Redis + const redis = new Redis(cacheConfig.connection.uri, { + connectTimeout: cacheConfig.timeout ?? 5000, + retryStrategy: times => Math.min(times * 50, 2000), + maxRetriesPerRequest: 3, + }); + + redis.on('error', err => { + fastify.log.error(`Redis connection error: ${err.message}`); + }); + + redis.on('connect', () => { + fastify.log.info('Redis connection established'); + }); + + redis.on('ready', () => { + fastify.log.info('Redis client ready'); + }); + + try { + await redis.ping(); + fastify.log.info( + `Redis connection successful to ${cacheConfig.connection.uri}`, + ); + } catch (err) { + fastify.log.error(`Failed to connect to Redis: ${err}`); + throw err; + } + + cacheWrapper = { + async get(key: string) { + const val = await redis.get(key); + if (!val) return null; + try { + return JSON.parse(val) as T; + } catch { + return val as unknown as T; + } + }, + async set(key: string, value: T, ttlSeconds?: number) { + const strVal = + typeof value === 'string' ? value : JSON.stringify(value); + if (ttlSeconds) { + await redis.set(key, strVal, 'EX', ttlSeconds); + } else { + await redis.set(key, strVal); + } + }, + async delete(key: string) { + await redis.del(key); + }, + async has(key: string) { + const exists = await redis.exists(key); + return exists > 0; + }, + async clear() { + await redis.flushdb(); + }, + raw: redis, + }; + + fastify.addHook('onClose', async () => { + fastify.log.info('Closing Redis connection...'); + await redis.quit(); + fastify.log.info('Redis connection closed.'); + }); + } else { + // Use node-cache + fastify.log.info('Using node-cache for caching'); + const nodeCache = new NodeCache(); + + cacheWrapper = { + async get(key: string) { + const val = nodeCache.get(key); + return val === undefined ? null : val; + }, + async set(key: string, value: T, ttlSeconds?: number) { + if (ttlSeconds) { + nodeCache.set(key, value, ttlSeconds); + } else { + nodeCache.set(key, value); + } + }, + async delete(key: string) { + nodeCache.del(key); + }, + async has(key: string) { + return nodeCache.has(key); + }, + async clear() { + nodeCache.flushAll(); + }, + raw: nodeCache, + }; + + fastify.addHook('onClose', async () => { + fastify.log.info('Closing node-cache...'); + nodeCache.close(); + }); + } + + fastify.decorate('cache', cacheWrapper); + }, + { + name: 'cache-plugin', + }, +); diff --git a/src/plugin/rate-limit.ts b/src/plugin/rate-limit.ts index 3c8c0c3..1ae5391 100644 --- a/src/plugin/rate-limit.ts +++ b/src/plugin/rate-limit.ts @@ -1,7 +1,6 @@ import rateLimit, {FastifyRateLimitOptions} from '@fastify/rate-limit'; import {FastifyInstance} from 'fastify'; import fp from 'fastify-plugin'; -import Redis from 'ioredis'; import {RateLimitConfig} from '@/interfaces/config'; @@ -9,12 +8,11 @@ import {parseDuration} from '@/utils/duration'; export interface RateLimitPluginOptions { rateLimit: RateLimitConfig; - redis?: Redis; } export default fp( async (fastify: FastifyInstance, opts: RateLimitPluginOptions) => { - const {rateLimit: config, redis} = opts; + const {rateLimit: config} = opts; // If rate limiting is disabled, skip registration if (!config.enabled) { @@ -26,12 +24,44 @@ export default fp( // Parse the time window from duration string to milliseconds const windowMs = parseDuration(config.timeWindow); - // Prepare rate limit options + class CustomCacheStore { + constructor(private options: unknown) {} + + incr( + key: string, + cb: ( + err: Error | null, + result?: {current: number; ttl: number}, + ) => void, + ) { + fastify.cache + .get<{current: number; expiresAt: number}>(key) + .then(val => { + const now = Date.now(); + if (!val || val.expiresAt < now) { + val = {current: 1, expiresAt: now + windowMs}; + } else { + val.current += 1; + } + fastify.cache + .set(key, val, Math.ceil((val.expiresAt - now) / 1000)) + .then(() => { + cb(null, {current: val.current, ttl: val.expiresAt - now}); + }) + .catch(err => cb(err)); + }) + .catch(err => cb(err)); + } + + child() { + return this; + } + } + const rateLimitOpts: FastifyRateLimitOptions = { max: config.max, timeWindow: windowMs, - cache: config.useRedis && redis ? 1000 : 10000, - redis: config.useRedis && redis ? redis : undefined, + store: CustomCacheStore, }; // Register the rate-limit plugin diff --git a/src/plugin/redis.ts b/src/plugin/redis.ts deleted file mode 100644 index b8f3894..0000000 --- a/src/plugin/redis.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {FastifyInstance} from 'fastify'; -import fp from 'fastify-plugin'; -import Redis from 'ioredis'; - -import {CacheDbConfig} from '@/interfaces/config'; - -export default fp( - async (fastify: FastifyInstance, opts: CacheDbConfig) => { - try { - const redis = new Redis(opts.connection.uri, { - connectTimeout: opts.timeout ?? 5000, - retryStrategy: times => { - const delay = Math.min(times * 50, 2000); - return delay; - }, - maxRetriesPerRequest: 3, - }); - - // Listen for connection errors - redis.on('error', err => { - fastify.log.error(`Redis connection error: ${err.message}`); - }); - - redis.on('connect', () => { - fastify.log.info('Redis connection established'); - }); - - redis.on('ready', () => { - fastify.log.info('Redis client ready'); - }); - - // Test connection - await redis.ping(); - fastify.log.info(`Redis connection successful to ${opts.connection.uri}`); - - // attach to fastify - fastify.decorate('redis', redis); - - // cleanup on shutdown - fastify.addHook('onClose', async () => { - fastify.log.info('Closing Redis connection...'); - await redis.quit(); - fastify.log.info('Redis connection closed.'); - }); - } catch (err) { - fastify.log.error(`Failed to connect to Redis: ${err}`); - throw err; - } - }, - { - name: 'redis-plugin', - }, -); diff --git a/src/server.ts b/src/server.ts index 43357c0..51e884b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,9 +9,9 @@ import Fastify, { import migrateDatabase from '@/migrator'; import authPlugin from '@/plugin/auth'; +import cachePlugin from '@/plugin/cache'; import dbPlugin from '@/plugin/database'; import rateLimitPlugin from '@/plugin/rate-limit'; -import redisPlugin from '@/plugin/redis'; import responsePlugin from '@/plugin/response'; import sspPlugin from '@/plugin/ssp'; import webhookPlugin from '@/plugin/webhook'; @@ -118,20 +118,13 @@ export async function startServer( // config-driven DB await app.register(dbPlugin, config.database); - // config-driven Redis cache (if configured) - if (config.cache_db) { - await app.register(redisPlugin, config.cache_db); - } + // config-driven cache (Redis or NodeCache) + await app.register(cachePlugin); // config-driven rate limit if (config.application.rateLimit) { - const redis = - config.cache_db && config.application.rateLimit.useRedis - ? app.redis - : undefined; await app.register(rateLimitPlugin, { rateLimit: config.application.rateLimit, - redis, }); } diff --git a/src/types/fastify.d.ts b/src/types/fastify.d.ts index e64c789..3667ee1 100644 --- a/src/types/fastify.d.ts +++ b/src/types/fastify.d.ts @@ -1,8 +1,6 @@ import '@fastify/jwt'; import 'fastify'; -import type Redis from 'ioredis'; - import {DatabaseQuery, StructuredResponse} from '@/interfaces'; declare module '@fastify/jwt' { @@ -27,7 +25,7 @@ declare module 'fastify' { data: T, raw_data?: R, ) => StructuredResponse; - redis?: Redis; + cache: import('@/plugin/cache').ICache; jwt: import('@fastify/jwt').JWT; appConfig: AppConfig; callWebhook: ( diff --git a/tests/helpers/test-app.ts b/tests/helpers/test-app.ts index 82a4c3f..a632ddb 100644 --- a/tests/helpers/test-app.ts +++ b/tests/helpers/test-app.ts @@ -1,6 +1,7 @@ import Fastify, {FastifyInstance} from 'fastify'; import authPlugin from '@/plugin/auth'; +import cachePlugin from '@/plugin/cache'; import databasePlugin from '@/plugin/database'; import responsePlugin from '@/plugin/response'; import sspPlugin from '@/plugin/ssp'; @@ -67,6 +68,7 @@ export async function createTestApp( fastify.appConfig = appConfig; await fastify.register(databasePlugin, dbConfig); + await fastify.register(cachePlugin); await fastify.register(responsePlugin); await fastify.register(sspPlugin); await fastify.register(webhookPlugin); diff --git a/tests/plugin/cache.test.ts b/tests/plugin/cache.test.ts new file mode 100644 index 0000000..c4c181f --- /dev/null +++ b/tests/plugin/cache.test.ts @@ -0,0 +1,201 @@ +import Fastify, {FastifyInstance} from 'fastify'; +import Redis from 'ioredis'; +import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; + +import cachePlugin from '@/plugin/cache'; + +import {AppConfig, CacheDbConfig} from '@/interfaces/config'; + +// Mock ioredis +vi.mock('ioredis', () => { + const RedisMock = vi.fn().mockImplementation( + class { + on = vi.fn(); + ping = vi.fn().mockResolvedValue('PONG'); + get = vi.fn(); + set = vi.fn(); + del = vi.fn(); + exists = vi.fn(); + flushdb = vi.fn(); + quit = vi.fn(); + } as unknown as () => unknown, + ); + return {default: RedisMock}; +}); + +describe('cache plugin', () => { + let app: FastifyInstance; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + }); + + describe('NodeCache', () => { + it('uses node-cache when no cache_db is provided', async () => { + app = Fastify(); + app.appConfig = {} as unknown as AppConfig; + await app.register(cachePlugin); + await app.ready(); + + expect(app.hasDecorator('cache')).toBe(true); + expect(app.cache.raw.constructor.name).toBe('NodeCache'); + }); + + it('performs node-cache operations', async () => { + app = Fastify(); + app.appConfig = {} as unknown as AppConfig; + await app.register(cachePlugin); + await app.ready(); + + expect(await app.cache.get('foo')).toBeNull(); + + await app.cache.set('foo', 'bar'); + expect(await app.cache.get('foo')).toBe('bar'); + + await app.cache.set('foo2', 'bar2', 100); + expect(await app.cache.get('foo2')).toBe('bar2'); + + expect(await app.cache.has('foo')).toBe(true); + + await app.cache.delete('foo'); + expect(await app.cache.has('foo')).toBe(false); + + await app.cache.set('key', 'val'); + await app.cache.clear(); + expect(await app.cache.get('key')).toBeNull(); + }); + }); + + describe('Redis Cache', () => { + beforeEach(() => { + app = Fastify(); + const mockConfig: CacheDbConfig = { + engine: 'redis', + connection: { + uri: 'redis://localhost:6379', + }, + }; + app.appConfig = {cache_db: mockConfig} as unknown as AppConfig; + + // Need to setup vi.mocked so we can control the instance methods properly, + // but since we mocked the constructor we can intercept via vi.mocked + (Redis as unknown as Mock).mockClear(); + }); + + it('uses redis when cache_db engine is redis', async () => { + const mockPing = vi.fn().mockResolvedValue('PONG'); + (Redis as unknown as Mock).mockImplementation( + class { + on = vi.fn(); + ping = mockPing; + quit = vi.fn(); + } as unknown as () => unknown, + ); + + await app.register(cachePlugin); + await app.ready(); + + expect(app.hasDecorator('cache')).toBe(true); + expect(mockPing).toHaveBeenCalled(); + + const MockRedisConstructor = vi.mocked(Redis); + const mockCalls = MockRedisConstructor.mock.calls as unknown as Array< + [string, {retryStrategy?: (times: number) => number}] + >; + const args = mockCalls[0]; + const retryStrategy = args[1]?.retryStrategy; + + expect(retryStrategy?.(10)).toBe(500); + expect(retryStrategy?.(100)).toBe(2000); + }); + + it('throws error when redis connection fails', async () => { + const mockPing = vi + .fn() + .mockRejectedValue(new Error('Connection Failed')); + (Redis as unknown as Mock).mockImplementation( + class { + on = vi.fn(); + ping = mockPing; + quit = vi.fn(); + } as unknown as () => unknown, + ); + + await expect(app.register(cachePlugin)).rejects.toThrow( + 'Connection Failed', + ); + }); + + it('performs redis operations', async () => { + const mockGet = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('"bar"') + .mockResolvedValueOnce('invalid json'); + const mockSet = vi.fn().mockResolvedValue('OK'); + const mockExists = vi + .fn() + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0); + const mockDel = vi.fn().mockResolvedValue(1); + const mockFlushDb = vi.fn().mockResolvedValue('OK'); + + (Redis as unknown as Mock).mockImplementation( + class { + on = (event: string, cb: (err?: Error) => void) => { + if (event === 'error') cb(new Error('test error')); + if (event === 'connect') cb(); + if (event === 'ready') cb(); + }; + ping = vi.fn().mockResolvedValue('PONG'); + get = mockGet; + set = mockSet; + del = mockDel; + exists = mockExists; + flushdb = mockFlushDb; + quit = vi.fn(); + } as unknown as () => unknown, + ); + + await app.register(cachePlugin); + await app.ready(); + + // get missing + expect(await app.cache.get('foo')).toBeNull(); + // get stringified + expect(await app.cache.get('foo')).toBe('bar'); + // get fallback non-json + expect(await app.cache.get('foo')).toBe('invalid json'); + + // set object + await app.cache.set('obj', {a: 1}); + expect(mockSet).toHaveBeenCalledWith('obj', '{"a":1}'); + + // set string + await app.cache.set('str', 'val'); + expect(mockSet).toHaveBeenCalledWith('str', 'val'); + + // set with ttl + await app.cache.set('ttl', 'val', 10); + expect(mockSet).toHaveBeenCalledWith('ttl', 'val', 'EX', 10); + + // exists + expect(await app.cache.has('exist')).toBe(true); + expect(await app.cache.has('miss')).toBe(false); + + // del + await app.cache.delete('key'); + expect(mockDel).toHaveBeenCalledWith('key'); + + // clear + await app.cache.clear(); + expect(mockFlushDb).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/plugin/rate-limit.test.ts b/tests/plugin/rate-limit.test.ts index 1de37f9..33b7cfb 100644 --- a/tests/plugin/rate-limit.test.ts +++ b/tests/plugin/rate-limit.test.ts @@ -1,19 +1,12 @@ import Fastify from 'fastify'; -import Redis from 'ioredis'; +import NodeCache from 'node-cache'; import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {ICache} from '@/plugin/cache'; import rateLimitPlugin, {RateLimitPluginOptions} from '@/plugin/rate-limit'; import {RateLimitConfig} from '@/interfaces/config'; -// // Mock @fastify/rate-limit -// const mockRegisterCall = vi.fn(); -// vi.mock('@fastify/rate-limit', () => { -// return { -// default: vi.fn(), -// }; -// }); - const {mockParseDuration} = vi.hoisted(() => { return { mockParseDuration: vi.fn((timeWindow: string) => { @@ -43,7 +36,7 @@ vi.mock('@/utils/duration', () => ({ describe('rate-limit plugin', () => { let rateLimitConfig: RateLimitConfig; - let mockRedis: Redis; + let mockCache: ICache; beforeEach(() => { vi.clearAllMocks(); @@ -54,10 +47,14 @@ describe('rate-limit plugin', () => { useRedis: false, }; - mockRedis = { - ping: vi.fn().mockResolvedValue('PONG'), - defineCommand: vi.fn(), - } as unknown as Redis; + mockCache = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + has: vi.fn().mockResolvedValue(false), + clear: vi.fn().mockResolvedValue(undefined), + raw: {} as unknown as NodeCache, + }; }); it('skips registration if rate limiting is disabled', async () => { @@ -74,13 +71,13 @@ describe('rate-limit plugin', () => { await fastify.register(rateLimitPlugin, config); await fastify.ready(); - // Verify parseDuration was not called expect(mockParseDuration).not.toHaveBeenCalled(); await fastify.close(); }); it('registers rate-limit plugin when enabled', async () => { const fastify = Fastify(); + fastify.decorate('cache', mockCache); const config: RateLimitPluginOptions = { rateLimit: rateLimitConfig, }; @@ -88,52 +85,13 @@ describe('rate-limit plugin', () => { await fastify.register(rateLimitPlugin, config); await fastify.ready(); - // Verify parseDuration was called with correct timeWindow - expect(mockParseDuration).toHaveBeenCalledWith('15m'); - await fastify.close(); - }); - - it('uses in-memory store when useRedis is false', async () => { - const fastify = Fastify(); - const config: RateLimitPluginOptions = { - rateLimit: { - enabled: true, - max: 100, - timeWindow: '15m', - useRedis: false, - }, - }; - - await fastify.register(rateLimitPlugin, config); - await fastify.ready(); - - // Verify parseDuration was called - expect(mockParseDuration).toHaveBeenCalledWith('15m'); - await fastify.close(); - }); - - it('attempts to use Redis store when useRedis is true and redis is available', async () => { - const fastify = Fastify(); - const config: RateLimitPluginOptions = { - rateLimit: { - enabled: true, - max: 100, - timeWindow: '15m', - useRedis: true, - }, - redis: mockRedis, - }; - - await fastify.register(rateLimitPlugin, config); - await fastify.ready(); - - // Verify parseDuration was called expect(mockParseDuration).toHaveBeenCalledWith('15m'); await fastify.close(); }); it('parses time window duration correctly (minutes)', async () => { const fastify = Fastify(); + fastify.decorate('cache', mockCache); const config: RateLimitPluginOptions = { rateLimit: { enabled: true, @@ -146,15 +104,14 @@ describe('rate-limit plugin', () => { await fastify.register(rateLimitPlugin, config); await fastify.ready(); - // Verify parseDuration was called and returns correct value expect(mockParseDuration).toHaveBeenCalledWith('15m'); - // 15 minutes = 900000 ms expect(mockParseDuration('15m')).toBe(900000); await fastify.close(); }); it('parses time window duration correctly (seconds)', async () => { const fastify = Fastify(); + fastify.decorate('cache', mockCache); const config: RateLimitPluginOptions = { rateLimit: { enabled: true, @@ -167,15 +124,14 @@ describe('rate-limit plugin', () => { await fastify.register(rateLimitPlugin, config); await fastify.ready(); - // Verify parseDuration was called expect(mockParseDuration).toHaveBeenCalledWith('30s'); - // 30 seconds = 30000 ms expect(mockParseDuration('30s')).toBe(30000); await fastify.close(); }); it('parses time window duration correctly (hours)', async () => { const fastify = Fastify(); + fastify.decorate('cache', mockCache); const config: RateLimitPluginOptions = { rateLimit: { enabled: true, @@ -188,15 +144,14 @@ describe('rate-limit plugin', () => { await fastify.register(rateLimitPlugin, config); await fastify.ready(); - // Verify parseDuration was called expect(mockParseDuration).toHaveBeenCalledWith('1h'); - // 1 hour = 3600000 ms expect(mockParseDuration('1h')).toBe(3600000); await fastify.close(); }); it('parses time window duration correctly (days)', async () => { const fastify = Fastify(); + fastify.decorate('cache', mockCache); const config: RateLimitPluginOptions = { rateLimit: { enabled: true, @@ -209,15 +164,14 @@ describe('rate-limit plugin', () => { await fastify.register(rateLimitPlugin, config); await fastify.ready(); - // Verify parseDuration was called expect(mockParseDuration).toHaveBeenCalledWith('7d'); - // 7 days = 604800000 ms expect(mockParseDuration('7d')).toBe(604800000); await fastify.close(); }); it('sets correct max threshold', async () => { const fastify = Fastify(); + fastify.decorate('cache', mockCache); const config: RateLimitPluginOptions = { rateLimit: { enabled: true, @@ -230,13 +184,13 @@ describe('rate-limit plugin', () => { await fastify.register(rateLimitPlugin, config); await fastify.ready(); - // Verify parseDuration was called with correct timeWindow expect(mockParseDuration).toHaveBeenCalledWith('1h'); await fastify.close(); }); it('throws error on invalid time window format', async () => { const fastify = Fastify(); + fastify.decorate('cache', mockCache); const config: RateLimitPluginOptions = { rateLimit: { enabled: true, @@ -246,38 +200,17 @@ describe('rate-limit plugin', () => { }, }; - // Mock parseDuration to throw for invalid format mockParseDuration.mockImplementationOnce(() => { throw new Error('Invalid duration: invalid'); }); await expect(fastify.register(rateLimitPlugin, config)).rejects.toThrow(); - - await fastify.close(); - }); - - it('does not use redis when useRedis is true but redis not provided', async () => { - const fastify = Fastify(); - const config: RateLimitPluginOptions = { - rateLimit: { - enabled: true, - max: 100, - timeWindow: '15m', - useRedis: true, - }, - // redis is intentionally not provided - }; - - await fastify.register(rateLimitPlugin, config); - await fastify.ready(); - - // Verify parseDuration was called - expect(mockParseDuration).toHaveBeenCalledWith('15m'); await fastify.close(); }); it('logs rate limit configuration', async () => { const fastify = Fastify(); + fastify.decorate('cache', mockCache); const logInfoSpy = vi.spyOn(fastify.log, 'info'); const config: RateLimitPluginOptions = { rateLimit: { @@ -319,4 +252,212 @@ describe('rate-limit plugin', () => { expect(loggedMessage).toBeDefined(); await fastify.close(); }); + + describe('CustomCacheStore', () => { + it('increments rate limit in cache successfully', async () => { + const fastify = Fastify(); + + const mockState: Record = + {}; + const mockCacheStore: ICache = { + get: vi + .fn() + .mockImplementation(async (key: string): Promise => { + return (mockState[key] as unknown as T) || null; + }), + set: vi.fn().mockImplementation(async (key: string, val: unknown) => { + mockState[key] = val as {current: number; expiresAt: number}; + }), + delete: vi.fn().mockResolvedValue(undefined), + has: vi.fn().mockResolvedValue(false), + clear: vi.fn().mockResolvedValue(undefined), + raw: {} as unknown as NodeCache, + }; + + fastify.decorate('cache', mockCacheStore); + + const config: RateLimitPluginOptions = { + rateLimit: { + enabled: true, + max: 2, + timeWindow: '1m', + useRedis: false, + }, + }; + + await fastify.register(rateLimitPlugin, config); + + fastify.get('/', async () => 'ok'); + + await fastify.ready(); + + // First request (should initialize) + let res = await fastify.inject({method: 'GET', url: '/'}); + expect(res.statusCode).toBe(200); + expect(res.headers['x-ratelimit-remaining']).toBe('1'); + + // Second request (should increment) + res = await fastify.inject({method: 'GET', url: '/'}); + expect(res.statusCode).toBe(200); + expect(res.headers['x-ratelimit-remaining']).toBe('0'); + + // Third request (should block) + res = await fastify.inject({method: 'GET', url: '/'}); + expect(res.statusCode).toBe(429); + + await fastify.close(); + }); + + it('handles cache get error gracefully', async () => { + const fastify = Fastify(); + + const mockCacheStore: ICache = { + get: vi.fn().mockRejectedValue(new Error('Cache GET failed')), + set: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + has: vi.fn().mockResolvedValue(false), + clear: vi.fn().mockResolvedValue(undefined), + raw: {} as unknown as NodeCache, + }; + + fastify.decorate('cache', mockCacheStore); + + const config: RateLimitPluginOptions = { + rateLimit: { + enabled: true, + max: 2, + timeWindow: '1m', + useRedis: false, + }, + }; + + await fastify.register(rateLimitPlugin, config); + fastify.get('/', async () => 'ok'); + await fastify.ready(); + + // Request should fail due to internal cache error + const res = await fastify.inject({method: 'GET', url: '/'}); + expect(res.statusCode).toBe(500); + + await fastify.close(); + }); + + it('handles cache set error gracefully', async () => { + const fastify = Fastify(); + + const mockCacheStore: ICache = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockRejectedValue(new Error('Cache SET failed')), + delete: vi.fn().mockResolvedValue(undefined), + has: vi.fn().mockResolvedValue(false), + clear: vi.fn().mockResolvedValue(undefined), + raw: {} as unknown as NodeCache, + }; + + fastify.decorate('cache', mockCacheStore); + + const config: RateLimitPluginOptions = { + rateLimit: { + enabled: true, + max: 2, + timeWindow: '1m', + useRedis: false, + }, + }; + + await fastify.register(rateLimitPlugin, config); + fastify.get('/', async () => 'ok'); + await fastify.ready(); + + // Request should fail due to internal cache set error + const res = await fastify.inject({method: 'GET', url: '/'}); + expect(res.statusCode).toBe(500); + + await fastify.close(); + }); + + it('resets rate limit if expired', async () => { + const fastify = Fastify(); + + const mockState: Record = { + 'rate-limit-127.0.0.1': {current: 5, expiresAt: Date.now() - 10000}, // Expired + }; + + const mockCacheStore: ICache = { + get: vi + .fn() + .mockImplementation(async (key: string): Promise => { + return (mockState[key] as unknown as T) || null; + }), + set: vi.fn().mockImplementation(async (key: string, val: unknown) => { + mockState[key] = val as {current: number; expiresAt: number}; + }), + delete: vi.fn().mockResolvedValue(undefined), + has: vi.fn().mockResolvedValue(false), + clear: vi.fn().mockResolvedValue(undefined), + raw: {} as unknown as NodeCache, + }; + + fastify.decorate('cache', mockCacheStore); + + const config: RateLimitPluginOptions = { + rateLimit: { + enabled: true, + max: 2, + timeWindow: '1m', + useRedis: false, + }, + }; + + await fastify.register(rateLimitPlugin, config); + + fastify.get('/', async () => 'ok'); + + await fastify.ready(); + + // Request should succeed and reset current to 1 + const res = await fastify.inject({method: 'GET', url: '/'}); + expect(res.statusCode).toBe(200); + expect(res.headers['x-ratelimit-remaining']).toBe('1'); + + await fastify.close(); + }); + + it('returns child instance correctly', async () => { + const fastify = Fastify(); + + let StoreClass: unknown; + const originalRegister = fastify.register; + fastify.register = function ( + this: unknown, + ...args: Parameters + ) { + const opts = args[1]; + if (opts && typeof opts === 'object' && 'store' in opts) { + StoreClass = (opts as {store: unknown}).store; + } + return originalRegister.apply(this, args); + } as unknown as typeof fastify.register; + + const config: RateLimitPluginOptions = { + rateLimit: { + enabled: true, + max: 2, + timeWindow: '1m', + useRedis: false, + }, + }; + + await fastify.register(rateLimitPlugin, config); + await fastify.ready(); + + const StoreClassConstructor = StoreClass as new ( + opts: Record, + ) => {child: () => unknown}; + const storeInstance = new StoreClassConstructor({}); + expect(storeInstance.child()).toBe(storeInstance); + + await fastify.close(); + }); + }); }); diff --git a/tests/plugin/redis.test.ts b/tests/plugin/redis.test.ts deleted file mode 100644 index 0116196..0000000 --- a/tests/plugin/redis.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import Fastify from 'fastify'; -import Redis from 'ioredis'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; - -import redisPlugin from '@/plugin/redis'; - -import {CacheDbConfig} from '@/interfaces/config'; - -// Mock ioredis -// Mock ioredis - -vi.mock('ioredis', () => { - const RedisMock = vi.fn(function ( - this: Record, - url: string, - ) { - this.url = url; - this.ping = vi.fn().mockResolvedValue('PONG'); - this.quit = vi.fn().mockResolvedValue(undefined); - this.defineCommand = vi.fn(); - this.on = vi.fn().mockReturnValue(this); - return this; - }); - return {default: RedisMock}; -}); - -describe('redis plugin', () => { - let redisConfig: CacheDbConfig; - - beforeEach(() => { - vi.clearAllMocks(); - redisConfig = { - engine: 'redis', - connection: { - uri: 'redis://localhost:6379', - }, - timeout: 5000, - }; - }); - - it('decorates fastify with redis decorator', async () => { - const fastify = Fastify(); - await fastify.register(redisPlugin, redisConfig); - await fastify.ready(); - - expect(fastify.hasDecorator('redis')).toBe(true); - expect(fastify.redis).toBeDefined(); - await fastify.close(); - }); - - it('uses provided timeout for connection', async () => { - const fastify = Fastify(); - const config: CacheDbConfig = { - engine: 'redis', - connection: { - uri: 'redis://localhost:6379', - }, - timeout: 10000, - }; - - await fastify.register(redisPlugin, config); - await fastify.ready(); - - // Verify timeout was used - expect(fastify.redis).toBeDefined(); - await fastify.close(); - }); - - it('uses default timeout if not provided', async () => { - const fastify = Fastify(); - const config: CacheDbConfig = { - engine: 'redis', - connection: { - uri: 'redis://localhost:6379', - }, - }; - - await fastify.register(redisPlugin, config); - await fastify.ready(); - - expect(fastify.redis).toBeDefined(); - await fastify.close(); - }); - - it('registers onClose hook to quit Redis connection', async () => { - const fastify = Fastify(); - await fastify.register(redisPlugin, redisConfig); - await fastify.ready(); - - const redis = fastify.redis; - expect(redis).toBeDefined(); - - if (redis) { - await fastify.close(); - expect(redis.quit).toHaveBeenCalled(); - } - }); - - it('logs connection events', async () => { - const fastify = Fastify(); - const logInfoSpy = vi.spyOn(fastify.log, 'info'); - - await fastify.register(redisPlugin, redisConfig); - await fastify.ready(); - - // Check that connection success was logged - const successLog = logInfoSpy.mock.calls.find((call: unknown[]) => - (call[0] as string)?.includes?.('Redis connection successful'), - ); - expect(successLog).toBeDefined(); - - await fastify.close(); - }); - - it('accepts different Redis connection strings', async () => { - const fastify = Fastify(); - const config: CacheDbConfig = { - engine: 'redis', - connection: { - uri: 'redis://:password@redis.example.com:6379/2', - }, - timeout: 5000, - }; - - await fastify.register(redisPlugin, config); - await fastify.ready(); - - expect(fastify.redis).toBeDefined(); - await fastify.close(); - }); - - it('handles Redis connection failure gracefully', async () => { - const fastify = Fastify(); - - // Mock Redis to throw on ping - const RedisMock = vi.mocked(Redis); - RedisMock.mockImplementationOnce(function (this: Record) { - this.url = ''; - this.ping = vi.fn().mockRejectedValue(new Error('Connection failed')); - this.on = vi.fn().mockReturnValue(this); - return this; - } as unknown as () => Redis); - - await expect(fastify.register(redisPlugin, redisConfig)).rejects.toThrow( - 'Connection failed', - ); - - await fastify.close(); - }); - - it('calls Redis ping to test connection', async () => { - const fastify = Fastify(); - await fastify.register(redisPlugin, redisConfig); - await fastify.ready(); - - // Verify ping was called - expect(fastify.redis?.ping).toHaveBeenCalled(); - await fastify.close(); - }); - - it('registers multiple onClose hooks for cleanup', async () => { - const fastify = Fastify(); - const addHookSpy = vi.spyOn(fastify, 'addHook'); - - await fastify.register(redisPlugin, redisConfig); - await fastify.ready(); - - // Verify onClose hook was registered - const closeHook = addHookSpy.mock.calls.find( - (call: unknown[]) => call[0] === 'onClose', - ); - expect(closeHook).toBeDefined(); - await fastify.close(); - }); - - it('logs errors when Redis emits error event', async () => { - const fastify = Fastify(); - const logErrorSpy = vi.spyOn(fastify.log, 'error'); - - await fastify.register(redisPlugin, redisConfig); - await fastify.ready(); - - const redis = fastify.redis; - if (redis) { - // Manually trigger error handler - const errorHandler = ( - (redis.on as unknown as ReturnType).mock.calls.find( - (call: unknown[]) => call[0] === 'error', - ) as unknown[] | undefined - )?.[1] as (err: Error) => void; - if (errorHandler) { - errorHandler(new Error('Test error')); - // Verify error was logged - expect(logErrorSpy).toHaveBeenCalled(); - } - } - - await fastify.close(); - }); - - it('verifies the retryStrategy logic', async () => { - const fastify = Fastify(); - await fastify.register(redisPlugin, redisConfig); - await fastify.ready(); - - // Get the constructor options - const RedisMock = vi.mocked(Redis); - const options = (RedisMock.mock.calls as unknown[][])[0][1] as { - retryStrategy: (times: number) => number; - }; - const retryStrategy = options.retryStrategy; - - expect(retryStrategy(1)).toBe(50); - expect(retryStrategy(10)).toBe(500); - expect(retryStrategy(100)).toBe(2000); // 100 * 50 = 5000, capped at 2000 - - await fastify.close(); - }); - - it('triggers connect and ready events', async () => { - const fastify = Fastify(); - const logInfoSpy = vi.spyOn(fastify.log, 'info'); - - await fastify.register(redisPlugin, redisConfig); - await fastify.ready(); - - const redis = fastify.redis; - if (redis) { - // Trigger connect event - const connectHandler = ( - (redis.on as unknown as ReturnType).mock.calls.find( - (call: unknown[]) => call[0] === 'connect', - ) as unknown[] | undefined - )?.[1] as () => void; - if (connectHandler) connectHandler(); - - // Trigger ready event - const readyHandler = ( - (redis.on as unknown as ReturnType).mock.calls.find( - (call: unknown[]) => call[0] === 'ready', - ) as unknown[] | undefined - )?.[1] as () => void; - if (readyHandler) readyHandler(); - - // Verify logging - expect(logInfoSpy).toHaveBeenCalledWith('Redis connection established'); - expect(logInfoSpy).toHaveBeenCalledWith('Redis client ready'); - } - - await fastify.close(); - }); -}); diff --git a/tests/server.test.ts b/tests/server.test.ts index dc580c1..5e90596 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -214,7 +214,7 @@ describe('Server', () => { it('should register plugins and routes', async () => { await runStart('dev', false, true); - expect(mockApp.register).toHaveBeenCalledTimes(6); + expect(mockApp.register).toHaveBeenCalledTimes(7); expect(migrateDatabase).toHaveBeenCalledWith(mockConfig); expect(registerRoutes).toHaveBeenCalledWith(mockApp, mockConfig); @@ -232,7 +232,7 @@ describe('Server', () => { } as unknown as AppConfig; await startServer(disabledSwaggerConfig, 3000, 'prod'); - expect(mockApp.register).toHaveBeenCalledTimes(4); + expect(mockApp.register).toHaveBeenCalledTimes(5); }); it('should not register routes if models are missing/empty', async () => { @@ -372,37 +372,6 @@ describe('Server', () => { }); describe('Redis and Rate Limit Configuration', () => { - it('should register redis plugin when cache_db is configured', async () => { - const configWithRedis: AppConfig = { - ...mockConfig, - cache_db: { - engine: 'redis', - connection: {uri: 'redis://localhost:6379'}, - timeout: 5000, - }, - }; - - const registerMock = mockApp.register; - await startServer(configWithRedis, 3000, 'dev'); - - // Verify redis plugin was registered - const redisRegistration = registerMock.mock.calls.find( - (call: unknown[]) => (call[1] as {engine?: string})?.engine === 'redis', - ); - expect(redisRegistration).toBeDefined(); - }); - - it('should not register redis plugin when cache_db is not configured', async () => { - const registerMock = mockApp.register; - await startServer(mockConfig, 3000, 'dev'); - - // Verify redis plugin was not registered - const redisRegistration = registerMock.mock.calls.find( - (call: unknown[]) => (call[1] as {engine?: string})?.engine === 'redis', - ); - expect(redisRegistration).toBeUndefined(); - }); - it('should register rate-limit plugin when rateLimit is configured', async () => { const configWithRateLimit: AppConfig = { ...mockConfig,