From bfe1c61aa6392344b3ca5564136ff673abe5ae0e Mon Sep 17 00:00:00 2001 From: prismn Date: Sat, 30 May 2026 08:51:09 +0100 Subject: [PATCH] fix: add coverage gate and tests --- .github/workflows/ci.yml | 30 +++++ package.json | 20 +++ src/app.module.spec.ts | 35 ++++++ src/config/configuration.spec.ts | 71 +++++++++++ src/config/env.validation.spec.ts | 60 +++++++++ src/idempotency/cleanup.job.spec.ts | 32 +++++ src/idempotency/idempotency.decorator.spec.ts | 21 ++++ src/idempotency/idempotency.guard.spec.ts | 114 ++++++++++++++++++ .../idempotency.interceptor.spec.ts | 86 +++++++++++++ src/idempotency/idempotency.module.spec.ts | 7 ++ src/idempotency/idempotency.service.spec.ts | 102 ++++++++++++++++ src/wallet/wallets.controller.spec.ts | 25 ++++ src/wallet/wallets.module.spec.ts | 7 ++ src/wallet/wallets.service.spec.ts | 50 ++++++++ 14 files changed, 660 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 src/app.module.spec.ts create mode 100644 src/config/configuration.spec.ts create mode 100644 src/config/env.validation.spec.ts create mode 100644 src/idempotency/cleanup.job.spec.ts create mode 100644 src/idempotency/idempotency.decorator.spec.ts create mode 100644 src/idempotency/idempotency.guard.spec.ts create mode 100644 src/idempotency/idempotency.interceptor.spec.ts create mode 100644 src/idempotency/idempotency.module.spec.ts create mode 100644 src/idempotency/idempotency.service.spec.ts create mode 100644 src/wallet/wallets.controller.spec.ts create mode 100644 src/wallet/wallets.module.spec.ts create mode 100644 src/wallet/wallets.service.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..541301c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test with coverage + run: npm run test:cov + + - name: Build + run: npm run build diff --git a/package.json b/package.json index 5c70c73..7e5868f 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,26 @@ "collectCoverageFrom": [ "**/*.(t|j)s" ], + "coverageThreshold": { + "global": { + "branches": 8, + "functions": 45, + "lines": 55, + "statements": 55 + }, + "src/idempotency/**/*.ts": { + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 + }, + "src/wallet/**/*.ts": { + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 + } + }, "coverageDirectory": "../coverage", "testEnvironment": "node" } diff --git a/src/app.module.spec.ts b/src/app.module.spec.ts new file mode 100644 index 0000000..120b476 --- /dev/null +++ b/src/app.module.spec.ts @@ -0,0 +1,35 @@ +describe('AppModule', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + NODE_ENV: 'test', + DISABLE_BULL: 'true', + DB_HOST: 'localhost', + DB_PORT: '5432', + DB_USER: 'postgres', + DB_PASSWORD: 'postgres', + DB_NAME: 'nexafx', + JWT_SECRET: 'a'.repeat(32), + REFRESH_TOKEN_SECRET: 'b'.repeat(32), + OTP_SECRET: 'c'.repeat(32), + MAIL_HOST: 'smtp.example.com', + MAIL_PORT: '587', + MAIL_USER: 'mailer@example.com', + MAIL_PASSWORD: 'secret', + MAIL_FROM: 'noreply@example.com', + }; + jest.resetModules(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('loads with the test environment configuration', async () => { + const { AppModule } = await import('./app.module'); + + expect(AppModule).toBeDefined(); + }); +}); diff --git a/src/config/configuration.spec.ts b/src/config/configuration.spec.ts new file mode 100644 index 0000000..a300cc0 --- /dev/null +++ b/src/config/configuration.spec.ts @@ -0,0 +1,71 @@ +import configuration from './configuration'; + +describe('configuration factory', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + NODE_ENV: 'production', + PORT: '4001', + BODY_LIMIT_JSON: '12', + BODY_LIMIT_URLENCODED: '13', + DB_HOST: 'db.example.com', + DB_PORT: '5433', + DB_USER: 'nexa', + DB_PASSWORD: 'secret', + DB_NAME: 'nexafx_prod', + JWT_SECRET: 'a'.repeat(32), + REFRESH_TOKEN_SECRET: 'b'.repeat(32), + OTP_SECRET: 'c'.repeat(32), + MAIL_HOST: 'smtp.example.com', + MAIL_PORT: '587', + MAIL_USER: 'mailer@example.com', + MAIL_PASSWORD: 'secret', + MAIL_FROM: 'noreply@example.com', + REDIS_HOST: 'redis.example.com', + REDIS_PORT: '6380', + WALLET_ENCRYPTION_KEY: 'd'.repeat(64), + ARCHIVE_ENABLED: 'false', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('builds grouped config values from the environment', () => { + const config = configuration(); + + expect(config.app).toEqual({ + nodeEnv: 'production', + port: 4001, + isProduction: true, + isDevelopment: false, + isTest: false, + }); + expect(config.database).toMatchObject({ + host: 'db.example.com', + port: 5433, + username: 'nexa', + password: 'secret', + database: 'nexafx_prod', + ssl: false, + }); + expect(config.wallet.encryptionKey).toBe('d'.repeat(64)); + expect(config.archive.enabled).toBe(false); + expect(config.redis).toEqual({ + host: 'redis.example.com', + port: 6380, + password: undefined, + }); + }); + + it('rejects invalid wallet encryption keys', () => { + process.env.WALLET_ENCRYPTION_KEY = 'invalid'; + + expect(() => configuration()).toThrow( + 'WALLET_ENCRYPTION_KEY must be a valid 64-character hexadecimal string', + ); + }); +}); diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts new file mode 100644 index 0000000..461447d --- /dev/null +++ b/src/config/env.validation.spec.ts @@ -0,0 +1,60 @@ +import { validateEnv, validateWalletEncryptionKey } from './env.validation'; + +describe('env validation', () => { + const validEnv = { + NODE_ENV: 'production', + PORT: '4000', + BODY_LIMIT_JSON: '8', + BODY_LIMIT_URLENCODED: '9', + DB_HOST: 'localhost', + DB_PORT: '5432', + DB_USER: 'postgres', + DB_PASSWORD: 'password', + DB_NAME: 'nexafx', + DB_SSL: 'true', + JWT_SECRET: 'a'.repeat(32), + JWT_EXPIRY: '1800', + REFRESH_TOKEN_SECRET: 'b'.repeat(32), + REFRESH_TOKEN_EXPIRY: '7200', + OTP_SECRET: 'c'.repeat(32), + OTP_EXPIRY: '120', + MAIL_HOST: 'smtp.example.com', + MAIL_PORT: '587', + MAIL_USER: 'mailer@example.com', + MAIL_PASSWORD: 'secret', + MAIL_FROM: 'noreply@example.com', + MAIL_SECURE: 'true', + REDIS_HOST: 'redis', + REDIS_PORT: '6380', + REDIS_PASSWORD: 'redis-pass', + RATE_LIMIT_WINDOW_MS: '120000', + RATE_LIMIT_MAX_REQUESTS: '250', + ARCHIVE_ENABLED: 'false', + ARCHIVE_THRESHOLD_MONTHS: '6', + ARCHIVE_BATCH_SIZE: '100', + ARCHIVE_CRON: '0 */6 * * *', + }; + + it('parses a valid environment object', () => { + const parsed = validateEnv(validEnv); + + expect(parsed.NODE_ENV).toBe('production'); + expect(parsed.DB_PORT).toBe(5432); + expect(parsed.MAIL_SECURE).toBe(true); + expect(parsed.ARCHIVE_ENABLED).toBe(false); + }); + + it('throws a detailed error when required values are missing', () => { + expect(() => + validateEnv({ + DB_HOST: 'localhost', + }), + ).toThrow('Environment validation failed'); + }); + + it('validates wallet encryption keys', () => { + expect(validateWalletEncryptionKey('a'.repeat(64))).toBe(true); + expect(validateWalletEncryptionKey('not-hex')).toBe(false); + expect(validateWalletEncryptionKey('a'.repeat(63))).toBe(false); + }); +}); diff --git a/src/idempotency/cleanup.job.spec.ts b/src/idempotency/cleanup.job.spec.ts new file mode 100644 index 0000000..81474cb --- /dev/null +++ b/src/idempotency/cleanup.job.spec.ts @@ -0,0 +1,32 @@ +import { Logger } from '@nestjs/common'; +import { IdempotencyCleanupJob } from './cleanup.job'; +import { IdempotencyService } from './idempotency.service'; + +describe('IdempotencyCleanupJob', () => { + const cleanupMock = jest.fn(); + const idempotencyService = { + cleanup: cleanupMock, + } as unknown as IdempotencyService; + const job = new IdempotencyCleanupJob(idempotencyService); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('logs and delegates cleanup execution', async () => { + const logSpy = jest + .spyOn(Logger.prototype, 'log') + .mockImplementation(() => undefined); + cleanupMock.mockResolvedValue(3); + + await job.cleanupExpiredKeys(); + + expect(cleanupMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith('Running idempotency key cleanup...'); + expect(logSpy).toHaveBeenCalledWith( + 'Cleaned up 3 expired idempotency keys', + ); + + logSpy.mockRestore(); + }); +}); diff --git a/src/idempotency/idempotency.decorator.spec.ts b/src/idempotency/idempotency.decorator.spec.ts new file mode 100644 index 0000000..5af1fb7 --- /dev/null +++ b/src/idempotency/idempotency.decorator.spec.ts @@ -0,0 +1,21 @@ +import { IDEMPOTENCY_KEY, Idempotent } from './idempotency.decorator'; + +describe('Idempotent decorator', () => { + class TestController { + @Idempotent() + execute() { + return true; + } + } + + it('marks a handler as idempotent', () => { + const descriptor = Object.getOwnPropertyDescriptor( + TestController.prototype, + 'execute', + ); + + expect( + Reflect.getMetadata(IDEMPOTENCY_KEY, descriptor?.value as object), + ).toBe(true); + }); +}); diff --git a/src/idempotency/idempotency.guard.spec.ts b/src/idempotency/idempotency.guard.spec.ts new file mode 100644 index 0000000..5b76266 --- /dev/null +++ b/src/idempotency/idempotency.guard.spec.ts @@ -0,0 +1,114 @@ +import { + BadRequestException, + ExecutionContext, + UnprocessableEntityException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IdempotencyGuard } from './idempotency.guard'; +import { IdempotencyService } from './idempotency.service'; + +type RequestWithIdempotency = { + headers?: Record; + method?: string; + url?: string; + body?: Record; + idempotencyKey?: string; + requestHash?: string; + idempotencyResponse?: { statusCode: number; body: unknown }; +}; + +const createContext = (request: RequestWithIdempotency): ExecutionContext => + ({ + getHandler: () => undefined as never, + switchToHttp: () => ({ + getRequest: () => request, + }), + }) as unknown as ExecutionContext; + +describe('IdempotencyGuard', () => { + const reflectorGetMock = jest.fn(); + const hashRequestMock = jest.fn(); + const findByKeyMock = jest.fn(); + const reflector = { + get: reflectorGetMock, + } as unknown as Reflector; + const idempotencyService = { + hashRequest: hashRequestMock, + findByKey: findByKeyMock, + } as unknown as IdempotencyService; + const guard = new IdempotencyGuard(reflector, idempotencyService); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('allows non-idempotent handlers through', async () => { + reflectorGetMock.mockReturnValue(false); + + await expect(guard.canActivate(createContext({}))).resolves.toBe(true); + expect(hashRequestMock).not.toHaveBeenCalled(); + }); + + it('requires the idempotency key header', async () => { + reflectorGetMock.mockReturnValue(true); + + await expect( + guard.canActivate(createContext({ headers: {} })), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects short idempotency keys', async () => { + reflectorGetMock.mockReturnValue(true); + + await expect( + guard.canActivate( + createContext({ + headers: { 'idempotency-key': 'too-short' }, + }), + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects reused keys with different request payloads', async () => { + reflectorGetMock.mockReturnValue(true); + hashRequestMock.mockReturnValue('current-hash'); + findByKeyMock.mockResolvedValue({ + requestHash: 'old-hash', + }); + + await expect( + guard.canActivate( + createContext({ + headers: { 'idempotency-key': '1234567890abcdef' }, + method: 'POST', + url: '/wallets', + body: {}, + }), + ), + ).rejects.toBeInstanceOf(UnprocessableEntityException); + }); + + it('hydrates the request with cached response metadata', async () => { + reflectorGetMock.mockReturnValue(true); + hashRequestMock.mockReturnValue('current-hash'); + findByKeyMock.mockResolvedValue({ + requestHash: 'current-hash', + statusCode: 201, + response: { ok: true }, + }); + const request: RequestWithIdempotency = { + headers: { 'idempotency-key': '1234567890abcdef' }, + method: 'POST', + url: '/wallets', + body: { amount: 10 }, + }; + + await expect(guard.canActivate(createContext(request))).resolves.toBe(true); + expect(request.idempotencyKey).toBe('1234567890abcdef'); + expect(request.requestHash).toBe('current-hash'); + expect(request.idempotencyResponse).toEqual({ + statusCode: 201, + body: { ok: true }, + }); + }); +}); diff --git a/src/idempotency/idempotency.interceptor.spec.ts b/src/idempotency/idempotency.interceptor.spec.ts new file mode 100644 index 0000000..0b7a21b --- /dev/null +++ b/src/idempotency/idempotency.interceptor.spec.ts @@ -0,0 +1,86 @@ +import { CallHandler, ExecutionContext } from '@nestjs/common'; +import { lastValueFrom, of } from 'rxjs'; +import { IdempotencyInterceptor } from './idempotency.interceptor'; +import { IdempotencyService } from './idempotency.service'; + +type RequestWithReplay = { + idempotencyResponse?: { statusCode: number; body: unknown }; + idempotencyKey?: string; + requestHash?: string; +}; + +const createContext = ( + request: RequestWithReplay, + response: { status?: jest.Mock; statusCode: number }, +): ExecutionContext => + ({ + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => response, + }), + }) as unknown as ExecutionContext; + +describe('IdempotencyInterceptor', () => { + const storeMock = jest.fn(); + const idempotencyService = { + store: storeMock, + } as unknown as IdempotencyService; + const interceptor = new IdempotencyInterceptor(idempotencyService); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns a cached response immediately when present', async () => { + const response = { + status: jest.fn().mockReturnThis(), + statusCode: 200, + }; + const request = { + idempotencyResponse: { + statusCode: 202, + body: { ok: true }, + }, + }; + const handleMock = jest.fn(); + const next: Pick = { + handle: handleMock, + }; + + await expect( + lastValueFrom( + interceptor.intercept(createContext(request, response), next), + ), + ).resolves.toEqual({ ok: true }); + + expect(response.status).toHaveBeenCalledWith(202); + expect(handleMock).not.toHaveBeenCalled(); + }); + + it('stores successful responses for future replay', async () => { + const response = { + statusCode: 204, + }; + const request = { + idempotencyKey: '1234567890abcdef', + requestHash: 'hash', + }; + const handleMock = jest.fn(() => of({ ok: true })); + const next: Pick = { + handle: handleMock, + }; + + await expect( + lastValueFrom( + interceptor.intercept(createContext(request, response), next), + ), + ).resolves.toEqual({ ok: true }); + + expect(storeMock).toHaveBeenCalledWith( + '1234567890abcdef', + 'hash', + { ok: true }, + 204, + ); + }); +}); diff --git a/src/idempotency/idempotency.module.spec.ts b/src/idempotency/idempotency.module.spec.ts new file mode 100644 index 0000000..1b0b075 --- /dev/null +++ b/src/idempotency/idempotency.module.spec.ts @@ -0,0 +1,7 @@ +import { IdempotencyModule } from './idempotency.module'; + +describe('IdempotencyModule', () => { + it('is defined', () => { + expect(IdempotencyModule).toBeDefined(); + }); +}); diff --git a/src/idempotency/idempotency.service.spec.ts b/src/idempotency/idempotency.service.spec.ts new file mode 100644 index 0000000..e96aa6b --- /dev/null +++ b/src/idempotency/idempotency.service.spec.ts @@ -0,0 +1,102 @@ +import { IdempotencyKey } from './idempotency.entity'; +import { IdempotencyService } from './idempotency.service'; +import { Repository } from 'typeorm'; + +describe('IdempotencyService', () => { + const repository = { + findOne: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }; + + const service = new IdempotencyService( + repository as unknown as Repository, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('hashes identical requests consistently', () => { + const firstHash = service.hashRequest('POST', '/wallets', { amount: 10 }); + const secondHash = service.hashRequest('POST', '/wallets', { + amount: 10, + }); + const differentHash = service.hashRequest('GET', '/wallets', { + amount: 10, + }); + + expect(firstHash).toBe(secondHash); + expect(firstHash).not.toBe(differentHash); + }); + + it('finds a stored idempotency key', async () => { + const key = { + key: 'abc123', + } as IdempotencyKey; + + repository.findOne.mockResolvedValue(key); + + await expect(service.findByKey('abc123')).resolves.toBe(key); + expect(repository.findOne).toHaveBeenCalledWith({ + where: { key: 'abc123' }, + }); + }); + + it('stores responses with an expiration window', async () => { + const startedAt = Date.now(); + + await service.store('abc123', 'hash', { ok: true }, 201, 2); + + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'abc123', + requestHash: 'hash', + response: { ok: true }, + statusCode: 201, + }), + ); + + const saved = repository.save.mock.calls[0][0] as unknown as { + expiresAt: Date; + }; + expect(saved.expiresAt.getTime()).toBeGreaterThanOrEqual( + startedAt + 2 * 60 * 60 * 1000 - 1000, + ); + expect(saved.expiresAt.getTime()).toBeLessThanOrEqual( + startedAt + 2 * 60 * 60 * 1000 + 1000, + ); + }); + + it('defaults to a 24 hour expiration window', async () => { + const startedAt = Date.now(); + + await service.store('abc123', 'hash', { ok: true }, 201); + + const saved = repository.save.mock.calls[0][0] as unknown as { + expiresAt: Date; + }; + expect(saved.expiresAt.getTime()).toBeGreaterThanOrEqual( + startedAt + 24 * 60 * 60 * 1000 - 1000, + ); + expect(saved.expiresAt.getTime()).toBeLessThanOrEqual( + startedAt + 24 * 60 * 60 * 1000 + 1000, + ); + }); + + it('cleans up expired keys and returns the affected count', async () => { + repository.delete.mockResolvedValue({ affected: 4 }); + + await expect(service.cleanup()).resolves.toBe(4); + const deleteArgs = repository.delete.mock.calls[0][0] as { + expiresAt: unknown; + }; + expect(deleteArgs.expiresAt).toBeDefined(); + }); + + it('defaults to zero when the cleanup result has no affected count', async () => { + repository.delete.mockResolvedValue({}); + + await expect(service.cleanup()).resolves.toBe(0); + }); +}); diff --git a/src/wallet/wallets.controller.spec.ts b/src/wallet/wallets.controller.spec.ts new file mode 100644 index 0000000..5a23c6c --- /dev/null +++ b/src/wallet/wallets.controller.spec.ts @@ -0,0 +1,25 @@ +import { WalletsController } from './wallets.controller'; +import { WalletsService } from './wallets.service'; + +describe('WalletsController', () => { + const getBalancesForAccountMock = jest.fn(); + const walletsService = { + getBalancesForAccount: getBalancesForAccountMock, + } as unknown as WalletsService; + const controller = new WalletsController(walletsService); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns balances for a single account', () => { + getBalancesForAccountMock.mockReturnValue([ + { accountId: 'acct-1', currency: 'usd', balance: 12 }, + ]); + + expect(controller.getBalances('acct-1')).toEqual([ + { accountId: 'acct-1', currency: 'usd', balance: 12 }, + ]); + expect(getBalancesForAccountMock).toHaveBeenCalledWith('acct-1'); + }); +}); diff --git a/src/wallet/wallets.module.spec.ts b/src/wallet/wallets.module.spec.ts new file mode 100644 index 0000000..ced44d3 --- /dev/null +++ b/src/wallet/wallets.module.spec.ts @@ -0,0 +1,7 @@ +import { WalletsModule } from './wallets.module'; + +describe('WalletsModule', () => { + it('is defined', () => { + expect(WalletsModule).toBeDefined(); + }); +}); diff --git a/src/wallet/wallets.service.spec.ts b/src/wallet/wallets.service.spec.ts new file mode 100644 index 0000000..1eddc8e --- /dev/null +++ b/src/wallet/wallets.service.spec.ts @@ -0,0 +1,50 @@ +import { WalletsService } from './wallets.service'; + +describe('WalletsService', () => { + let service: WalletsService; + + beforeEach(() => { + service = new WalletsService(); + }); + + it('starts accounts at zero and normalizes currencies', () => { + expect(service.getBalance('acct-1', 'usd')).toEqual({ + accountId: 'acct-1', + currency: 'usd', + balance: 0, + }); + }); + + it('adjusts balances with two-decimal precision', () => { + expect(service.adjustBalance('acct-1', 'usd', 10.125)).toEqual({ + accountId: 'acct-1', + currency: 'usd', + balance: 10.13, + }); + + expect(service.adjustBalance('acct-1', 'usd', -0.03)).toEqual({ + accountId: 'acct-1', + currency: 'usd', + balance: 10.1, + }); + }); + + it('returns all balances for an account', () => { + service.adjustBalance('acct-1', 'usd', 10.125); + service.adjustBalance('acct-1', 'eur', 4); + service.adjustBalance('acct-2', 'usd', 5); + + expect(service.getBalancesForAccount('acct-1')).toEqual([ + { + accountId: 'acct-1', + currency: 'usd', + balance: 10.13, + }, + { + accountId: 'acct-1', + currency: 'eur', + balance: 4, + }, + ]); + }); +});