From c5ca937246cc1f16a8d30cd2c1465d7890a4ff97 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:38:46 +0400 Subject: [PATCH 01/13] test(rate-limiter): add unit tests for RuleManagerService --- .../services/rule-manager.service.spec.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/rate-limiter/src/rate-limiter/services/rule-manager.service.spec.ts diff --git a/apps/rate-limiter/src/rate-limiter/services/rule-manager.service.spec.ts b/apps/rate-limiter/src/rate-limiter/services/rule-manager.service.spec.ts new file mode 100644 index 0000000..9b42cbc --- /dev/null +++ b/apps/rate-limiter/src/rate-limiter/services/rule-manager.service.spec.ts @@ -0,0 +1,50 @@ +import { NotFoundException } from '@nestjs/common'; +import { RuleManagerService } from './rule-manager.service'; + +describe('RuleManagerService', () => { + let service: RuleManagerService; + + beforeEach(() => { + service = new RuleManagerService(); + service.onModuleInit(); + }); + + describe('getRule', () => { + it('should return default rule with token-bucket config', () => { + const rule = service.getRule('default'); + + expect(rule).toEqual({ + algorithm: 'token-bucket', + limit: 100, + windowSeconds: 60, + }); + }); + + it('should return api-requests rule with high-limit token-bucket config', () => { + const rule = service.getRule('api-requests'); + + expect(rule).toEqual({ + algorithm: 'token-bucket', + limit: 1000, + windowSeconds: 3600, + }); + }); + + it('should return strict rule with sliding-window-log config', () => { + const rule = service.getRule('strict'); + + expect(rule).toEqual({ + algorithm: 'sliding-window-log', + limit: 10, + windowSeconds: 60, + }); + }); + + it('should throw NotFoundException for unknown rule ID', () => { + expect(() => service.getRule('non-existent')).toThrow(NotFoundException); + expect(() => service.getRule('non-existent')).toThrow( + 'Rate limit rule not found: non-existent', + ); + }); + }); +}); From 553a3c5b61a205325afa17b24bcd83295382843d Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:39:45 +0400 Subject: [PATCH 02/13] test(rate-limiter): add unit tests for AlgorithmManagerService --- .../algorithm-manager.service.spec.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 apps/rate-limiter/src/rate-limiter/services/algorithm-manager.service.spec.ts diff --git a/apps/rate-limiter/src/rate-limiter/services/algorithm-manager.service.spec.ts b/apps/rate-limiter/src/rate-limiter/services/algorithm-manager.service.spec.ts new file mode 100644 index 0000000..9a00392 --- /dev/null +++ b/apps/rate-limiter/src/rate-limiter/services/algorithm-manager.service.spec.ts @@ -0,0 +1,57 @@ +import { NotFoundException } from '@nestjs/common'; +import type { IRateLimitAlgorithm } from '../algorithms/base'; +import type { AlgorithmType } from '../rate-limiter.types'; +import { AlgorithmManagerService } from './algorithm-manager.service'; + +describe('AlgorithmManagerService', () => { + let service: AlgorithmManagerService; + + // Minimal stubs — just need to be distinguishable objects + const fixedWindow = { + name: 'fixed-window', + } as unknown as IRateLimitAlgorithm; + const slidingWindowLog = { + name: 'sliding-window-log', + } as unknown as IRateLimitAlgorithm; + const slidingWindowCounter = { + name: 'sliding-window-counter', + } as unknown as IRateLimitAlgorithm; + const tokenBucket = { + name: 'token-bucket', + } as unknown as IRateLimitAlgorithm; + + beforeEach(() => { + service = new AlgorithmManagerService( + fixedWindow as any, + slidingWindowLog as any, + slidingWindowCounter as any, + tokenBucket as any, + ); + service.onModuleInit(); + }); + + describe('getAlgorithm', () => { + const cases: { type: AlgorithmType; expected: IRateLimitAlgorithm }[] = [ + { type: 'fixed-window', expected: fixedWindow }, + { type: 'sliding-window-log', expected: slidingWindowLog }, + { type: 'sliding-window-counter', expected: slidingWindowCounter }, + { type: 'token-bucket', expected: tokenBucket }, + ]; + + it.each(cases)( + 'should return $type algorithm after init', + ({ type, expected }) => { + expect(service.getAlgorithm(type)).toBe(expected); + }, + ); + + it('should throw NotFoundException for unknown algorithm type', () => { + expect(() => service.getAlgorithm('unknown' as AlgorithmType)).toThrow( + NotFoundException, + ); + expect(() => service.getAlgorithm('unknown' as AlgorithmType)).toThrow( + 'Rate limiting algorithm not found: unknown', + ); + }); + }); +}); From 1854dbec17cf3673658f3d1fb2008e2f5c31f40b Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:42:57 +0400 Subject: [PATCH 03/13] test(rate-limiter): add unit tests for ClientIdentifierService --- .../client-identifier.service.spec.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 apps/rate-limiter/src/rate-limiter/services/client-identifier.service.spec.ts diff --git a/apps/rate-limiter/src/rate-limiter/services/client-identifier.service.spec.ts b/apps/rate-limiter/src/rate-limiter/services/client-identifier.service.spec.ts new file mode 100644 index 0000000..16368d0 --- /dev/null +++ b/apps/rate-limiter/src/rate-limiter/services/client-identifier.service.spec.ts @@ -0,0 +1,130 @@ +import { ExecutionContext } from '@nestjs/common'; +import { ClientIdentifierService } from './client-identifier.service'; + +function createMockContext(request: Record): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; +} + +describe('ClientIdentifierService', () => { + let service: ClientIdentifierService; + + beforeEach(() => { + service = new ClientIdentifierService(); + }); + + describe('identifyClient', () => { + describe('priority order', () => { + it('should prefer X-User-ID over X-API-Key and IP', () => { + const ctx = createMockContext({ + headers: { + 'x-user-id': 'user-123', + 'x-api-key': 'key-456', + 'x-forwarded-for': '10.0.0.1', + }, + ip: '192.168.1.1', + }); + + expect(service.identifyClient(ctx)).toBe('user:user-123'); + }); + + it('should prefer X-API-Key over IP when no user ID', () => { + const ctx = createMockContext({ + headers: { + 'x-api-key': 'key-456', + 'x-forwarded-for': '10.0.0.1', + }, + ip: '192.168.1.1', + }); + + expect(service.identifyClient(ctx)).toBe('api:key-456'); + }); + + it('should fall back to IP when no user ID or API key', () => { + const ctx = createMockContext({ + headers: {}, + ip: '192.168.1.1', + }); + + expect(service.identifyClient(ctx)).toBe('ip:192.168.1.1'); + }); + }); + + describe('user ID extraction', () => { + it('should return user: from X-User-ID header', () => { + const ctx = createMockContext({ + headers: { 'x-user-id': 'abc-123' }, + }); + + expect(service.identifyClient(ctx)).toBe('user:abc-123'); + }); + }); + + describe('API key extraction', () => { + it('should return api: from X-API-Key header', () => { + const ctx = createMockContext({ + headers: { 'x-api-key': 'sk-test-key' }, + }); + + expect(service.identifyClient(ctx)).toBe('api:sk-test-key'); + }); + }); + + describe('IP extraction', () => { + it('should use first IP from X-Forwarded-For', () => { + const ctx = createMockContext({ + headers: { 'x-forwarded-for': '10.0.0.1, 10.0.0.2, 10.0.0.3' }, + }); + + expect(service.identifyClient(ctx)).toBe('ip:10.0.0.1'); + }); + + it('should trim whitespace from X-Forwarded-For IP', () => { + const ctx = createMockContext({ + headers: { 'x-forwarded-for': ' 10.0.0.1 , 10.0.0.2' }, + }); + + expect(service.identifyClient(ctx)).toBe('ip:10.0.0.1'); + }); + + it('should fall back to request.ip', () => { + const ctx = createMockContext({ + headers: {}, + ip: '172.16.0.1', + }); + + expect(service.identifyClient(ctx)).toBe('ip:172.16.0.1'); + }); + + it('should fall back to request.socket.remoteAddress', () => { + const ctx = createMockContext({ + headers: {}, + socket: { remoteAddress: '::1' }, + }); + + expect(service.identifyClient(ctx)).toBe('ip:::1'); + }); + }); + + describe('null cases', () => { + it('should return null when no identification method succeeds', () => { + const ctx = createMockContext({ + headers: {}, + }); + + expect(service.identifyClient(ctx)).toBeNull(); + }); + + it('should return null for empty X-Forwarded-For with no other sources', () => { + const ctx = createMockContext({ + headers: { 'x-forwarded-for': '' }, + }); + + expect(service.identifyClient(ctx)).toBeNull(); + }); + }); + }); +}); From 49a7d8b95f4eae96f58418d9ca36ae8e7cbd926f Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:43:52 +0400 Subject: [PATCH 04/13] test(rate-limiter): add unit tests for RateLimiterService --- .../services/rate-limiter.service.spec.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/rate-limiter/src/rate-limiter/services/rate-limiter.service.spec.ts diff --git a/apps/rate-limiter/src/rate-limiter/services/rate-limiter.service.spec.ts b/apps/rate-limiter/src/rate-limiter/services/rate-limiter.service.spec.ts new file mode 100644 index 0000000..7f54f2b --- /dev/null +++ b/apps/rate-limiter/src/rate-limiter/services/rate-limiter.service.spec.ts @@ -0,0 +1,96 @@ +import { NotFoundException } from '@nestjs/common'; +import type { IRateLimitAlgorithm } from '../algorithms/base'; +import type { RateLimitResult } from '../rate-limiter.types'; +import { AlgorithmManagerService } from './algorithm-manager.service'; +import { RateLimiterService } from './rate-limiter.service'; +import { RuleManagerService } from './rule-manager.service'; + +describe('RateLimiterService', () => { + let service: RateLimiterService; + let ruleManager: jest.Mocked; + let algorithmManager: jest.Mocked; + + const mockResult: RateLimitResult = { + allowed: true, + limit: 100, + remaining: 99, + resetTime: Date.now() + 60_000, + }; + + const mockAlgorithm: jest.Mocked = { + increment: jest.fn().mockResolvedValue(mockResult), + }; + + beforeEach(() => { + ruleManager = { + getRule: jest.fn().mockReturnValue({ + algorithm: 'token-bucket', + limit: 100, + windowSeconds: 60, + }), + } as any; + + algorithmManager = { + getAlgorithm: jest.fn().mockReturnValue(mockAlgorithm), + } as any; + + service = new RateLimiterService(ruleManager, algorithmManager); + mockAlgorithm.increment.mockClear(); + }); + + describe('check', () => { + it('should look up rule by ruleId', async () => { + await service.check('client-1', 'default'); + + expect(ruleManager.getRule).toHaveBeenCalledWith('default'); + }); + + it('should look up algorithm by rule algorithm type', async () => { + await service.check('client-1', 'default'); + + expect(algorithmManager.getAlgorithm).toHaveBeenCalledWith( + 'token-bucket', + ); + }); + + it('should call algorithm.increment with clientId and rule', async () => { + const rule = { + algorithm: 'token-bucket' as const, + limit: 100, + windowSeconds: 60, + }; + + await service.check('client-1', 'default'); + + expect(mockAlgorithm.increment).toHaveBeenCalledWith('client-1', rule); + }); + + it('should return the result from algorithm.increment', async () => { + const result = await service.check('client-1', 'default'); + + expect(result).toEqual(mockResult); + }); + + it('should propagate NotFoundException from rule manager', async () => { + ruleManager.getRule.mockImplementation(() => { + throw new NotFoundException('Rate limit rule not found: bad-rule'); + }); + + await expect(service.check('client-1', 'bad-rule')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should propagate NotFoundException from algorithm manager', async () => { + algorithmManager.getAlgorithm.mockImplementation(() => { + throw new NotFoundException( + 'Rate limiting algorithm not found: bad-algo', + ); + }); + + await expect(service.check('client-1', 'default')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); From 291897c694e6cdb408c55cc9452715fea743c911 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:45:12 +0400 Subject: [PATCH 05/13] test(rate-limiter): add unit tests for RateLimitGuard --- .../guards/rate-limit.guard.spec.ts | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 apps/rate-limiter/src/rate-limiter/guards/rate-limit.guard.spec.ts diff --git a/apps/rate-limiter/src/rate-limiter/guards/rate-limit.guard.spec.ts b/apps/rate-limiter/src/rate-limiter/guards/rate-limit.guard.spec.ts new file mode 100644 index 0000000..935948a --- /dev/null +++ b/apps/rate-limiter/src/rate-limiter/guards/rate-limit.guard.spec.ts @@ -0,0 +1,156 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RateLimitExceededException } from '../exceptions'; +import type { RateLimitResult } from '../rate-limiter.types'; +import { ClientIdentifierService } from '../services/client-identifier.service'; +import { RateLimiterService } from '../services/rate-limiter.service'; +import { RateLimitGuard } from './rate-limit.guard'; + +describe('RateLimitGuard', () => { + let guard: RateLimitGuard; + let reflector: jest.Mocked; + let rateLimiterService: jest.Mocked; + let clientIdentifierService: jest.Mocked; + + const mockSetHeader = jest.fn(); + + const mockContext: ExecutionContext = { + getHandler: jest.fn(), + switchToHttp: () => ({ + getRequest: () => ({}), + getResponse: () => ({ setHeader: mockSetHeader }), + }), + } as unknown as ExecutionContext; + + const allowedResult: RateLimitResult = { + allowed: true, + limit: 100, + remaining: 99, + resetTime: 1711353600000, // fixed timestamp for predictable testing + }; + + const deniedResult: RateLimitResult = { + allowed: false, + limit: 100, + remaining: 0, + resetTime: 1711353600000, + }; + + beforeEach(() => { + reflector = { + get: jest.fn(), + } as any; + + rateLimiterService = { + check: jest.fn().mockResolvedValue(allowedResult), + } as any; + + clientIdentifierService = { + identifyClient: jest.fn().mockReturnValue('user:test-123'), + } as any; + + guard = new RateLimitGuard( + reflector, + rateLimiterService, + clientIdentifierService, + ); + + mockSetHeader.mockClear(); + }); + + describe('canActivate', () => { + it('should return true when no @RateLimit() decorator', async () => { + reflector.get.mockReturnValue(undefined); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(true); + expect(rateLimiterService.check).not.toHaveBeenCalled(); + }); + + it('should return true (fail-open) when client cannot be identified', async () => { + reflector.get.mockReturnValue({ ruleId: 'default' }); + clientIdentifierService.identifyClient.mockReturnValue(null); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(true); + expect(rateLimiterService.check).not.toHaveBeenCalled(); + }); + + it('should call rateLimiterService.check with clientId and ruleId', async () => { + reflector.get.mockReturnValue({ ruleId: 'strict' }); + + await guard.canActivate(mockContext); + + expect(rateLimiterService.check).toHaveBeenCalledWith( + 'user:test-123', + 'strict', + ); + }); + + it('should return true when rate limit check allows', async () => { + reflector.get.mockReturnValue({ ruleId: 'default' }); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(true); + }); + + it('should throw RateLimitExceededException when not allowed', async () => { + reflector.get.mockReturnValue({ ruleId: 'default' }); + rateLimiterService.check.mockResolvedValue(deniedResult); + + await expect(guard.canActivate(mockContext)).rejects.toThrow( + RateLimitExceededException, + ); + }); + + it('should propagate errors from rateLimiterService.check', async () => { + reflector.get.mockReturnValue({ ruleId: 'default' }); + rateLimiterService.check.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(guard.canActivate(mockContext)).rejects.toThrow( + 'ECONNREFUSED', + ); + }); + + it('should propagate RateLimitExceededException from check', async () => { + reflector.get.mockReturnValue({ ruleId: 'default' }); + const exception = new RateLimitExceededException(deniedResult); + rateLimiterService.check.mockRejectedValue(exception); + + await expect(guard.canActivate(mockContext)).rejects.toThrow( + RateLimitExceededException, + ); + }); + }); + + describe('rate limit headers', () => { + beforeEach(() => { + reflector.get.mockReturnValue({ ruleId: 'default' }); + }); + + it('should set X-RateLimit-Limit header', async () => { + await guard.canActivate(mockContext); + + expect(mockSetHeader).toHaveBeenCalledWith('X-RateLimit-Limit', 100); + }); + + it('should set X-RateLimit-Remaining header', async () => { + await guard.canActivate(mockContext); + + expect(mockSetHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', 99); + }); + + it('should set X-RateLimit-Reset as Unix seconds (not ms)', async () => { + await guard.canActivate(mockContext); + + // resetTime is 1711353600000ms → 1711353600 seconds + expect(mockSetHeader).toHaveBeenCalledWith( + 'X-RateLimit-Reset', + Math.floor(1711353600000 / 1000), + ); + }); + }); +}); From 32e5707b5561acfcd5fa89eba9a65dff28ecc4e1 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:48:31 +0400 Subject: [PATCH 06/13] test(rate-limiter): add unit tests for exectRedisScriptSha --- .../base/redis-script.helper.spec.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 apps/rate-limiter/src/rate-limiter/algorithms/base/redis-script.helper.spec.ts diff --git a/apps/rate-limiter/src/rate-limiter/algorithms/base/redis-script.helper.spec.ts b/apps/rate-limiter/src/rate-limiter/algorithms/base/redis-script.helper.spec.ts new file mode 100644 index 0000000..7627c1d --- /dev/null +++ b/apps/rate-limiter/src/rate-limiter/algorithms/base/redis-script.helper.spec.ts @@ -0,0 +1,121 @@ +import { exectRedisScriptSha } from './redis-script.helper'; + +describe('exectRedisScriptSha', () => { + let redis: { + scriptLoad: jest.Mock; + evalSha: jest.Mock; + }; + + const script = 'return redis.call("GET", KEYS[1])'; + const keys = ['rate-limit:client-1']; + const args = ['100', '60']; + + beforeEach(() => { + redis = { + scriptLoad: jest.fn().mockResolvedValue('sha-abc123'), + evalSha: jest.fn().mockResolvedValue('result-value'), + }; + }); + + describe('script loading', () => { + it('should load script SHA when scriptMeta.sha is null', async () => { + const scriptMeta = { sha: null, script }; + + await exectRedisScriptSha(redis as any, scriptMeta, keys, args); + + expect(redis.scriptLoad).toHaveBeenCalledWith(script); + }); + + it('should skip scriptLoad when SHA is already set', async () => { + const scriptMeta = { sha: 'existing-sha', script }; + + await exectRedisScriptSha(redis as any, scriptMeta, keys, args); + + expect(redis.scriptLoad).not.toHaveBeenCalled(); + }); + + it('should persist loaded SHA on scriptMeta for reuse', async () => { + const scriptMeta = { sha: null as string | null, script }; + + await exectRedisScriptSha(redis as any, scriptMeta, keys, args); + + expect(scriptMeta.sha).toBe('sha-abc123'); + }); + }); + + describe('script execution', () => { + it('should call evalSha with loaded SHA, keys, and args', async () => { + const scriptMeta = { sha: null, script }; + + await exectRedisScriptSha(redis as any, scriptMeta, keys, args); + + expect(redis.evalSha).toHaveBeenCalledWith('sha-abc123', { + keys, + arguments: args, + }); + }); + + it('should return the result from evalSha', async () => { + const scriptMeta = { sha: 'existing-sha', script }; + + const result = await exectRedisScriptSha( + redis as any, + scriptMeta, + keys, + args, + ); + + expect(result).toBe('result-value'); + }); + }); + + describe('NOSCRIPT error recovery', () => { + it('should reload script and retry on NOSCRIPT error', async () => { + const scriptMeta = { sha: 'stale-sha' as string | null, script }; + + redis.evalSha + .mockRejectedValueOnce(new Error('NOSCRIPT No matching script')) + .mockResolvedValueOnce('recovered-value'); + redis.scriptLoad.mockResolvedValue('new-sha'); + + const result = await exectRedisScriptSha( + redis as any, + scriptMeta, + keys, + args, + ); + + expect(result).toBe('recovered-value'); + expect(redis.scriptLoad).toHaveBeenCalledWith(script); + expect(redis.evalSha).toHaveBeenCalledTimes(2); + }); + + it('should update scriptMeta.sha after NOSCRIPT recovery', async () => { + const scriptMeta = { sha: 'stale-sha' as string | null, script }; + + redis.evalSha + .mockRejectedValueOnce(new Error('NOSCRIPT No matching script')) + .mockResolvedValueOnce('ok'); + redis.scriptLoad.mockResolvedValue('fresh-sha'); + + await exectRedisScriptSha(redis as any, scriptMeta, keys, args); + + expect(scriptMeta.sha).toBe('fresh-sha'); + }); + }); + + describe('non-NOSCRIPT errors', () => { + it('should re-throw non-NOSCRIPT errors without retry', async () => { + const scriptMeta = { sha: 'existing-sha', script }; + + redis.evalSha.mockRejectedValue(new Error('WRONGTYPE Operation')); + + await expect( + exectRedisScriptSha(redis as any, scriptMeta, keys, args), + ).rejects.toThrow('WRONGTYPE Operation'); + + expect(redis.scriptLoad).not.toHaveBeenCalled(); + expect(redis.evalSha).toHaveBeenCalledTimes(1); + }); + }); +}); From ad198938b46ed349de8d9b309b8227e90394d872 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:51:13 +0400 Subject: [PATCH 07/13] fix(url-shortener): convert jest configs to CTS to fix __dirname in ESM --- apps/url-shortener/{jest.config.ts => jest.config.cts} | 5 +++-- .../{jest.int.config.ts => jest.int.config.cts} | 5 +++-- apps/url-shortener/package.json | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) rename apps/url-shortener/{jest.config.ts => jest.config.cts} (88%) rename apps/url-shortener/{jest.int.config.ts => jest.int.config.cts} (87%) diff --git a/apps/url-shortener/jest.config.ts b/apps/url-shortener/jest.config.cts similarity index 88% rename from apps/url-shortener/jest.config.ts rename to apps/url-shortener/jest.config.cts index b724295..d17dc65 100644 --- a/apps/url-shortener/jest.config.ts +++ b/apps/url-shortener/jest.config.cts @@ -1,4 +1,5 @@ -import { readFileSync } from 'fs'; +/* eslint-disable */ +const { readFileSync } = require('fs'); // Reading the SWC compilation config for the spec files const swcJestConfig = JSON.parse( @@ -8,7 +9,7 @@ const swcJestConfig = JSON.parse( // Disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves swcJestConfig.swcrc = false; -export default { +module.exports = { displayName: '@apps/url-shortener', preset: '../../jest.preset.js', testEnvironment: 'node', diff --git a/apps/url-shortener/jest.int.config.ts b/apps/url-shortener/jest.int.config.cts similarity index 87% rename from apps/url-shortener/jest.int.config.ts rename to apps/url-shortener/jest.int.config.cts index 02a2b89..136a933 100644 --- a/apps/url-shortener/jest.int.config.ts +++ b/apps/url-shortener/jest.int.config.cts @@ -1,4 +1,5 @@ -import { readFileSync } from 'fs'; +/* eslint-disable */ +const { readFileSync } = require('fs'); // Reading the SWC compilation config for the spec files const swcJestConfig = JSON.parse( @@ -8,7 +9,7 @@ const swcJestConfig = JSON.parse( // Disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves swcJestConfig.swcrc = false; -export default { +module.exports = { displayName: '@apps/url-shortener', preset: '../../jest.preset.js', testEnvironment: 'node', diff --git a/apps/url-shortener/package.json b/apps/url-shortener/package.json index 832527c..6934ee8 100644 --- a/apps/url-shortener/package.json +++ b/apps/url-shortener/package.json @@ -48,7 +48,7 @@ "test:int": { "executor": "@nx/jest:jest", "options": { - "jestConfig": "apps/url-shortener/jest.int.config.ts", + "jestConfig": "apps/url-shortener/jest.int.config.cts", "passWithNoTests": true } }, From cc742ba89e13e4e3507b71714e9d293bb18677fc Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:51:22 +0400 Subject: [PATCH 08/13] test(url-shortener): add unit tests for UrlRepository --- .../url/repositories/url.repository.spec.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 apps/url-shortener/src/url/repositories/url.repository.spec.ts diff --git a/apps/url-shortener/src/url/repositories/url.repository.spec.ts b/apps/url-shortener/src/url/repositories/url.repository.spec.ts new file mode 100644 index 0000000..910f7d1 --- /dev/null +++ b/apps/url-shortener/src/url/repositories/url.repository.spec.ts @@ -0,0 +1,66 @@ +import { UrlNotFoundExceptions } from '../exceptions/url.exceptions'; +import { UrlRepository } from './url.repository'; + +describe('UrlRepository', () => { + let repository: UrlRepository; + let prisma: { + shortendUrls: { + create: jest.Mock; + findUnique: jest.Mock; + }; + }; + + beforeEach(() => { + prisma = { + shortendUrls: { + create: jest.fn().mockResolvedValue(undefined), + findUnique: jest.fn(), + }, + }; + + repository = new UrlRepository(prisma as any); + }); + + describe('saveUrlMapping', () => { + it('should call prisma.shortendUrls.create with id and url', async () => { + await repository.saveUrlMapping(42, 'https://example.com'); + + expect(prisma.shortendUrls.create).toHaveBeenCalledWith({ + data: { id: 42, url: 'https://example.com' }, + }); + }); + }); + + describe('getUrlById', () => { + it('should return url when record exists', async () => { + prisma.shortendUrls.findUnique.mockResolvedValue({ + url: 'https://example.com', + }); + + const result = await repository.getUrlById(42); + + expect(result).toBe('https://example.com'); + }); + + it('should query with correct where and select', async () => { + prisma.shortendUrls.findUnique.mockResolvedValue({ + url: 'https://example.com', + }); + + await repository.getUrlById(42); + + expect(prisma.shortendUrls.findUnique).toHaveBeenCalledWith({ + where: { id: 42 }, + select: { url: true }, + }); + }); + + it('should throw UrlNotFoundExceptions when record not found', async () => { + prisma.shortendUrls.findUnique.mockResolvedValue(null); + + await expect(repository.getUrlById(999)).rejects.toThrow( + UrlNotFoundExceptions, + ); + }); + }); +}); From 7aa950c7ca634ee9938519478318352e7bef9941 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:52:00 +0400 Subject: [PATCH 09/13] test(url-shortener): add unit tests for ShortenUrl command handler --- .../url/commands/shorten-url/service.spec.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 apps/url-shortener/src/url/commands/shorten-url/service.spec.ts diff --git a/apps/url-shortener/src/url/commands/shorten-url/service.spec.ts b/apps/url-shortener/src/url/commands/shorten-url/service.spec.ts new file mode 100644 index 0000000..524216c --- /dev/null +++ b/apps/url-shortener/src/url/commands/shorten-url/service.spec.ts @@ -0,0 +1,112 @@ +import { ConfigService } from '@nestjs/config'; +import { CounterService } from '../../../counter/counter.service'; +import { IdObfuscatorService } from '../../app-services/id-obfuscator.service'; +import { NumberHasherService } from '../../app-services/number-hasher.service'; +import { UrlRepository } from '../../repositories/url.repository'; +import { Command, CommandOutput, Service } from './service'; + +describe('ShortenUrl.Service', () => { + let service: Service; + let counterService: jest.Mocked; + let idObfuscatorService: jest.Mocked; + let numberHasherService: jest.Mocked; + let urlRepository: jest.Mocked; + let configService: jest.Mocked; + + beforeEach(() => { + counterService = { + getNextCount: jest.fn().mockResolvedValue(42), + } as any; + + idObfuscatorService = { + obfuscate: jest.fn().mockReturnValue(987654), + } as any; + + numberHasherService = { + encode: jest.fn().mockReturnValue('aBcDeFg'), + } as any; + + urlRepository = { + saveUrlMapping: jest.fn().mockResolvedValue(undefined), + } as any; + + configService = { + get: jest.fn().mockReturnValue('https://short.url'), + } as any; + + service = new Service( + counterService, + idObfuscatorService, + numberHasherService, + urlRepository, + configService, + ); + }); + + describe('execute', () => { + const cmd = new Command({ url: 'https://example.com/long-url' }); + + it('should get next counter value', async () => { + await service.execute(cmd); + + expect(counterService.getNextCount).toHaveBeenCalled(); + }); + + it('should obfuscate the counter value', async () => { + await service.execute(cmd); + + expect(idObfuscatorService.obfuscate).toHaveBeenCalledWith(42); + }); + + it('should encode the obfuscated ID with length 7', async () => { + await service.execute(cmd); + + expect(numberHasherService.encode).toHaveBeenCalledWith(987654, 7); + }); + + it('should save mapping with original counter ID and input URL', async () => { + await service.execute(cmd); + + expect(urlRepository.saveUrlMapping).toHaveBeenCalledWith( + 42, + 'https://example.com/long-url', + ); + }); + + it('should return CommandOutput with full short URL', async () => { + const result = await service.execute(cmd); + + expect(result).toBeInstanceOf(CommandOutput); + expect(result.shortUrl).toBe('https://short.url/l/aBcDeFg'); + }); + + it('should use SHORTENER_BASE_URL from config', async () => { + await service.execute(cmd); + + expect(configService.get).toHaveBeenCalledWith( + 'SHORTENER_BASE_URL', + 'http://localhost:3000', + ); + }); + + it('should propagate counter service errors', async () => { + counterService.getNextCount.mockRejectedValue( + new Error('Redis connection failed'), + ); + + await expect(service.execute(cmd)).rejects.toThrow( + 'Redis connection failed', + ); + }); + + it('should propagate repository errors', async () => { + urlRepository.saveUrlMapping.mockRejectedValue( + new Error('Unique constraint violation'), + ); + + await expect(service.execute(cmd)).rejects.toThrow( + 'Unique constraint violation', + ); + }); + }); +}); From f608b258543c15ee3c583f87434042b058be59e4 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:52:55 +0400 Subject: [PATCH 10/13] test(url-shortener): add unit tests for GetRealUrl query handler --- .../url/queries/get-real-url/service.spec.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/url-shortener/src/url/queries/get-real-url/service.spec.ts diff --git a/apps/url-shortener/src/url/queries/get-real-url/service.spec.ts b/apps/url-shortener/src/url/queries/get-real-url/service.spec.ts new file mode 100644 index 0000000..7d00bcb --- /dev/null +++ b/apps/url-shortener/src/url/queries/get-real-url/service.spec.ts @@ -0,0 +1,77 @@ +import { IdObfuscatorService } from '../../app-services/id-obfuscator.service'; +import { NumberHasherService } from '../../app-services/number-hasher.service'; +import { UrlNotFoundExceptions } from '../../exceptions/url.exceptions'; +import { UrlRepository } from '../../repositories/url.repository'; +import { Query, QueryOutput, Service } from './service'; + +describe('GetRealUrl.Service', () => { + let service: Service; + let idObfuscatorService: jest.Mocked; + let numberHasherService: jest.Mocked; + let urlRepository: jest.Mocked; + + beforeEach(() => { + numberHasherService = { + decode: jest.fn().mockReturnValue(987654), + } as any; + + idObfuscatorService = { + deobfuscate: jest.fn().mockReturnValue(42), + } as any; + + urlRepository = { + getUrlById: jest.fn().mockResolvedValue('https://example.com/long-url'), + } as any; + + service = new Service( + idObfuscatorService, + numberHasherService, + urlRepository, + ); + }); + + describe('execute', () => { + const query = new Query({ shortUrlId: 'aBcDeFg' }); + + it('should decode the short URL ID', async () => { + await service.execute(query); + + expect(numberHasherService.decode).toHaveBeenCalledWith('aBcDeFg'); + }); + + it('should deobfuscate the decoded numeric ID', async () => { + await service.execute(query); + + expect(idObfuscatorService.deobfuscate).toHaveBeenCalledWith(987654); + }); + + it('should look up URL by deobfuscated ID', async () => { + await service.execute(query); + + expect(urlRepository.getUrlById).toHaveBeenCalledWith(42); + }); + + it('should return QueryOutput with the resolved URL', async () => { + const result = await service.execute(query); + + expect(result).toBeInstanceOf(QueryOutput); + expect(result.url).toBe('https://example.com/long-url'); + }); + + it('should propagate UrlNotFoundExceptions from repository', async () => { + urlRepository.getUrlById.mockRejectedValue(new UrlNotFoundExceptions()); + + await expect(service.execute(query)).rejects.toThrow( + UrlNotFoundExceptions, + ); + }); + + it('should propagate decode errors from NumberHasherService', async () => { + numberHasherService.decode.mockImplementation(() => { + throw new Error('Invalid character'); + }); + + await expect(service.execute(query)).rejects.toThrow('Invalid character'); + }); + }); +}); From 729345b3f8b4143f7b9c7411af72766c56ab6291 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:53:32 +0400 Subject: [PATCH 11/13] test(url-shortener): add unit tests for BatchCounterService --- .../base/batch-counter.service.spec.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 apps/url-shortener/src/counter/implementations/base/batch-counter.service.spec.ts diff --git a/apps/url-shortener/src/counter/implementations/base/batch-counter.service.spec.ts b/apps/url-shortener/src/counter/implementations/base/batch-counter.service.spec.ts new file mode 100644 index 0000000..64fb2b9 --- /dev/null +++ b/apps/url-shortener/src/counter/implementations/base/batch-counter.service.spec.ts @@ -0,0 +1,84 @@ +import { BatchCounterService } from './batch-counter.service'; + +class TestBatchCounter extends BatchCounterService { + reserveBatch = jest.fn(); + + constructor(batchSize: number) { + super(batchSize); + } +} + +describe('BatchCounterService', () => { + let counter: TestBatchCounter; + + beforeEach(() => { + counter = new TestBatchCounter(3); + counter.reserveBatch.mockResolvedValue([1, 2, 3]); + }); + + describe('getNextCount', () => { + it('should call reserveBatch when queue is empty', async () => { + await counter.getNextCount(); + + expect(counter.reserveBatch).toHaveBeenCalledWith(3); + }); + + it('should return items from queue in FIFO order', async () => { + const first = await counter.getNextCount(); + const second = await counter.getNextCount(); + const third = await counter.getNextCount(); + + expect(first).toBe(1); + expect(second).toBe(2); + expect(third).toBe(3); + }); + + it('should not call reserveBatch again while queue has items', async () => { + await counter.getNextCount(); + await counter.getNextCount(); + + expect(counter.reserveBatch).toHaveBeenCalledTimes(1); + }); + + it('should call reserveBatch again when queue is exhausted', async () => { + counter.reserveBatch + .mockResolvedValueOnce([1, 2, 3]) + .mockResolvedValueOnce([4, 5, 6]); + + // Exhaust first batch + await counter.getNextCount(); + await counter.getNextCount(); + await counter.getNextCount(); + + // Should trigger second batch + const fourth = await counter.getNextCount(); + + expect(counter.reserveBatch).toHaveBeenCalledTimes(2); + expect(fourth).toBe(4); + }); + + it('should deduplicate concurrent reserveBatch calls', async () => { + let resolveFirst: (value: number[]) => void; + counter.reserveBatch.mockImplementation( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + + // Fire two concurrent getNextCount calls + const p1 = counter.getNextCount(); + const p2 = counter.getNextCount(); + + // Only one reserveBatch should be in flight + expect(counter.reserveBatch).toHaveBeenCalledTimes(1); + + // Resolve with enough items for both + resolveFirst!([10, 20]); + + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toBe(10); + expect(r2).toBe(20); + }); + }); +}); From abea4366943d245948f0f9fed8ebb68c933a8ea8 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Thu, 26 Mar 2026 00:57:20 +0400 Subject: [PATCH 12/13] fix(rate-limiter): convert jest int config to CTS to fix __dirname in ESM --- .../rate-limiter/{jest.int.config.ts => jest.int.config.cts} | 5 +++-- apps/rate-limiter/package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) rename apps/rate-limiter/{jest.int.config.ts => jest.int.config.cts} (87%) diff --git a/apps/rate-limiter/jest.int.config.ts b/apps/rate-limiter/jest.int.config.cts similarity index 87% rename from apps/rate-limiter/jest.int.config.ts rename to apps/rate-limiter/jest.int.config.cts index bf5dbd3..ad6c8f8 100644 --- a/apps/rate-limiter/jest.int.config.ts +++ b/apps/rate-limiter/jest.int.config.cts @@ -1,4 +1,5 @@ -import { readFileSync } from 'fs'; +/* eslint-disable */ +const { readFileSync } = require('fs'); // Reading the SWC compilation config for the spec files const swcJestConfig = JSON.parse( @@ -8,7 +9,7 @@ const swcJestConfig = JSON.parse( // Disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves swcJestConfig.swcrc = false; -export default { +module.exports = { displayName: '@apps/rate-limiter', preset: '../../jest.preset.js', testEnvironment: 'node', diff --git a/apps/rate-limiter/package.json b/apps/rate-limiter/package.json index 54b8b64..16b58a9 100644 --- a/apps/rate-limiter/package.json +++ b/apps/rate-limiter/package.json @@ -83,7 +83,7 @@ "test:int": { "executor": "@nx/jest:jest", "options": { - "jestConfig": "apps/rate-limiter/jest.int.config.ts", + "jestConfig": "apps/rate-limiter/jest.int.config.cts", "passWithNoTests": true } }, From b7100ae8239f63355d8a9b3ecb8a45bcaf9e1e25 Mon Sep 17 00:00:00 2001 From: oniani1 Date: Fri, 27 Mar 2026 23:12:57 +0400 Subject: [PATCH 13/13] test(rate-limiter): remove redundant RateLimitExceededException propagation test The guard itself throws RateLimitExceededException based on result.allowed; rateLimiterService.check never throws it. Error propagation is already covered by the ECONNREFUSED test. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/rate-limiter/guards/rate-limit.guard.spec.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/rate-limiter/src/rate-limiter/guards/rate-limit.guard.spec.ts b/apps/rate-limiter/src/rate-limiter/guards/rate-limit.guard.spec.ts index 935948a..2d7af6e 100644 --- a/apps/rate-limiter/src/rate-limiter/guards/rate-limit.guard.spec.ts +++ b/apps/rate-limiter/src/rate-limiter/guards/rate-limit.guard.spec.ts @@ -114,16 +114,6 @@ describe('RateLimitGuard', () => { 'ECONNREFUSED', ); }); - - it('should propagate RateLimitExceededException from check', async () => { - reflector.get.mockReturnValue({ ruleId: 'default' }); - const exception = new RateLimitExceededException(deniedResult); - rateLimiterService.check.mockRejectedValue(exception); - - await expect(guard.canActivate(mockContext)).rejects.toThrow( - RateLimitExceededException, - ); - }); }); describe('rate limit headers', () => {