Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
35 changes: 35 additions & 0 deletions src/app.module.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
71 changes: 71 additions & 0 deletions src/config/configuration.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
60 changes: 60 additions & 0 deletions src/config/env.validation.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
32 changes: 32 additions & 0 deletions src/idempotency/cleanup.job.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
21 changes: 21 additions & 0 deletions src/idempotency/idempotency.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
114 changes: 114 additions & 0 deletions src/idempotency/idempotency.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
method?: string;
url?: string;
body?: Record<string, unknown>;
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 },
});
});
});
Loading
Loading