rwadurian/backend/services/backup-service/docs/TESTING.md

810 lines
21 KiB
Markdown

# Backup Service Testing Guide
## Overview
The backup-service implements a comprehensive testing strategy with three levels:
1. **Unit Tests** - Test individual components in isolation
2. **Integration Tests** - Test component interactions with real database
3. **E2E Tests** - Test complete API workflows
---
## Test Structure
```
test/
├── unit/ # Unit tests (37 tests)
│ ├── api/
│ │ ├── backup-share.controller.spec.ts
│ │ └── health.controller.spec.ts
│ ├── application/
│ │ ├── store-backup-share.handler.spec.ts
│ │ ├── get-backup-share.handler.spec.ts
│ │ └── revoke-share.handler.spec.ts
│ ├── domain/
│ │ ├── backup-share.entity.spec.ts
│ │ └── value-objects.spec.ts
│ ├── infrastructure/
│ │ └── aes-encryption.service.spec.ts
│ └── shared/
│ ├── audit-log.interceptor.spec.ts
│ ├── global-exception.filter.spec.ts
│ └── service-auth.guard.spec.ts
├── integration/ # Integration tests
│ ├── backup-share-repository.integration.spec.ts
│ └── audit-log-repository.integration.spec.ts
├── e2e/ # E2E tests (20 tests)
│ ├── backup-share.e2e-spec.ts # Real database
│ └── backup-share-mock.e2e-spec.ts # Mocked services
├── setup/ # Test infrastructure
│ ├── global-setup.ts
│ ├── global-teardown.ts
│ ├── jest-e2e-setup.ts
│ ├── jest-mock-setup.ts
│ └── test-database.helper.ts
└── utils/ # Test utilities
├── mock-prisma.service.ts
└── test-utils.ts
```
---
## Running Tests
### Quick Commands
```bash
# Run all unit tests
npm run test:unit
# Run E2E tests with mocked services (fast)
npm run test:e2e:mock
# Run E2E tests with real database
npm run test:e2e:db
# Run all tests (unit + mock E2E)
npm run test:all
# Generate coverage report
npm run test:cov
# Watch mode for development
npm run test:watch
```
### Test Configurations
| Config File | Purpose | Command |
|------------|---------|---------|
| `jest.config.js` | Unit tests | `npm run test:unit` |
| `test/jest-e2e-mock.json` | E2E with mocks | `npm run test:e2e:mock` |
| `test/jest-e2e-db.json` | E2E with real DB | `npm run test:e2e:db` |
---
## Unit Testing
### Philosophy
- Test each component in isolation
- Mock all dependencies
- Focus on business logic and edge cases
- Fast execution (< 5 seconds total)
### Example: Testing a Handler
```typescript
// test/unit/application/store-backup-share.handler.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { StoreBackupShareHandler } from '../../../src/application/commands/store-backup-share/store-backup-share.handler';
import { StoreBackupShareCommand } from '../../../src/application/commands/store-backup-share/store-backup-share.command';
import { BackupShareRepository } from '../../../src/domain/repositories/backup-share.repository.interface';
import { AesEncryptionService } from '../../../src/infrastructure/crypto/aes-encryption.service';
import { AuditLogRepository } from '../../../src/infrastructure/persistence/repositories/audit-log.repository';
describe('StoreBackupShareHandler', () => {
let handler: StoreBackupShareHandler;
let mockRepository: jest.Mocked<BackupShareRepository>;
let mockEncryptionService: jest.Mocked<AesEncryptionService>;
let mockAuditLogRepository: jest.Mocked<AuditLogRepository>;
beforeEach(async () => {
mockRepository = {
save: jest.fn(),
findByUserId: jest.fn(),
findByPublicKey: jest.fn(),
// ... other methods
};
mockEncryptionService = {
encrypt: jest.fn().mockResolvedValue({
encrypted: 'encrypted-data',
keyId: 'key-v1',
}),
};
mockAuditLogRepository = {
log: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
StoreBackupShareHandler,
{ provide: 'BackupShareRepository', useValue: mockRepository },
{ provide: AesEncryptionService, useValue: mockEncryptionService },
{ provide: AuditLogRepository, useValue: mockAuditLogRepository },
],
}).compile();
handler = module.get<StoreBackupShareHandler>(StoreBackupShareHandler);
});
describe('execute', () => {
it('should store backup share successfully', async () => {
// Arrange
const command = new StoreBackupShareCommand(
'12345',
1001,
'02' + 'a'.repeat(64),
'encrypted-share-data',
'identity-service',
'127.0.0.1'
);
mockRepository.findByUserId.mockResolvedValue(null);
mockRepository.findByPublicKey.mockResolvedValue(null);
mockRepository.save.mockResolvedValue({
shareId: BigInt(1),
// ... other fields
});
// Act
const result = await handler.execute(command);
// Assert
expect(result.shareId).toBe('1');
expect(mockRepository.save).toHaveBeenCalled();
expect(mockEncryptionService.encrypt).toHaveBeenCalledWith('encrypted-share-data');
expect(mockAuditLogRepository.log).toHaveBeenCalled();
});
it('should throw error if share already exists for user', async () => {
// Arrange
const command = new StoreBackupShareCommand(/*...*/);
mockRepository.findByUserId.mockResolvedValue({ /* existing share */ });
// Act & Assert
await expect(handler.execute(command)).rejects.toThrow('SHARE_ALREADY_EXISTS');
});
});
});
```
### Example: Testing a Controller
```typescript
// test/unit/api/backup-share.controller.spec.ts
describe('BackupShareController', () => {
let controller: BackupShareController;
let mockService: jest.Mocked<BackupShareApplicationService>;
beforeEach(async () => {
mockService = {
storeBackupShare: jest.fn(),
getBackupShare: jest.fn(),
revokeShare: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [BackupShareController],
providers: [
{ provide: BackupShareApplicationService, useValue: mockService },
],
}).compile();
controller = module.get<BackupShareController>(BackupShareController);
});
describe('storeShare', () => {
it('should return success response', async () => {
// Arrange
const dto: StoreShareDto = {
userId: '12345',
accountSequence: 1001,
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'test-data',
};
const mockRequest = {
sourceService: 'identity-service',
sourceIp: '127.0.0.1',
};
mockService.storeBackupShare.mockResolvedValue({ shareId: '1' });
// Act
const result = await controller.storeShare(dto, mockRequest);
// Assert
expect(result.success).toBe(true);
expect(result.shareId).toBe('1');
});
});
});
```
### Example: Testing Domain Entity
```typescript
// test/unit/domain/backup-share.entity.spec.ts
describe('BackupShare Entity', () => {
describe('create', () => {
it('should create a valid backup share', () => {
const share = BackupShare.create({
userId: BigInt(12345),
accountSequence: BigInt(1001),
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'encrypted-data',
encryptionKeyId: 'key-v1',
});
expect(share.userId).toBe(BigInt(12345));
expect(share.status).toBe(BackupShareStatus.ACTIVE);
expect(share.partyIndex).toBe(2);
expect(share.accessCount).toBe(0);
});
it('should throw error for invalid public key length', () => {
expect(() => BackupShare.create({
// ... with short publicKey
publicKey: 'short',
})).toThrow();
});
});
describe('revoke', () => {
it('should mark share as revoked', () => {
const share = BackupShare.create({/*...*/});
share.revoke('ROTATION');
expect(share.status).toBe(BackupShareStatus.REVOKED);
expect(share.revokedAt).toBeDefined();
});
});
describe('recordAccess', () => {
it('should increment access count', () => {
const share = BackupShare.create({/*...*/});
share.recordAccess();
share.recordAccess();
expect(share.accessCount).toBe(2);
expect(share.lastAccessedAt).toBeDefined();
});
});
});
```
---
## E2E Testing
### Mock E2E Tests
Fast tests using mocked Prisma service:
```typescript
// test/e2e/backup-share-mock.e2e-spec.ts
describe('BackupShare E2E (Mocked)', () => {
let app: INestApplication;
let mockPrismaService: MockPrismaService;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaService)
.useClass(MockPrismaService)
.compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
mockPrismaService = app.get(MockPrismaService);
});
it('should store backup share successfully', async () => {
mockPrismaService.backupShare.findUnique.mockResolvedValue(null);
mockPrismaService.backupShare.create.mockResolvedValue({
shareId: BigInt(1),
// ... mock data
});
const response = await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', generateServiceToken('identity-service'))
.send({
userId: '12345',
accountSequence: 1001,
publicKey: '02' + 'a'.repeat(64),
encryptedShareData: 'test-data',
})
.expect(201);
expect(response.body.success).toBe(true);
});
});
```
### Real Database E2E Tests
Tests with actual PostgreSQL database:
```typescript
// test/e2e/backup-share.e2e-spec.ts
describe('BackupShare E2E (Real Database)', () => {
let app: INestApplication;
let prisma: PrismaService;
let serviceToken: string;
beforeAll(async () => {
process.env.SERVICE_JWT_SECRET = TEST_SERVICE_JWT_SECRET;
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
await app.init();
prisma = app.get(PrismaService);
serviceToken = generateServiceToken('identity-service');
});
beforeEach(async () => {
// Clean database before each test
await prisma.shareAccessLog.deleteMany();
await prisma.backupShare.deleteMany();
});
afterAll(async () => {
await app?.close();
});
describe('Complete Workflow', () => {
it('should complete full lifecycle: store -> retrieve -> revoke', async () => {
const publicKey = generatePublicKey('x');
const payload = createStoreSharePayload({
userId: '50001',
accountSequence: 50001,
publicKey,
});
// Step 1: Store
const storeResponse = await request(app.getHttpServer())
.post('/backup-share/store')
.set('X-Service-Token', serviceToken)
.send(payload)
.expect(201);
expect(storeResponse.body.success).toBe(true);
expect(storeResponse.body.shareId).toBeDefined();
// Step 2: Retrieve
const retrieveResponse = await request(app.getHttpServer())
.post('/backup-share/retrieve')
.set('X-Service-Token', serviceToken)
.send(createRetrieveSharePayload({ userId: '50001', publicKey }))
.expect(200);
expect(retrieveResponse.body.success).toBe(true);
expect(retrieveResponse.body.partyIndex).toBe(2);
// Step 3: Revoke
const revokeResponse = await request(app.getHttpServer())
.post('/backup-share/revoke')
.set('X-Service-Token', serviceToken)
.send(createRevokeSharePayload({ userId: '50001', publicKey, reason: 'ROTATION' }))
.expect(200);
expect(revokeResponse.body.success).toBe(true);
// Step 4: Verify cannot retrieve after revoke
await request(app.getHttpServer())
.post('/backup-share/retrieve')
.set('X-Service-Token', serviceToken)
.send(createRetrieveSharePayload({ userId: '50001', publicKey }))
.expect(400);
});
});
});
```
---
## Test Utilities
### test-utils.ts
```typescript
// test/utils/test-utils.ts
import jwt from 'jsonwebtoken';
export const TEST_SERVICE_JWT_SECRET = 'test-super-secret-service-jwt-key-for-e2e-testing';
export const TEST_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
export const TEST_ENCRYPTION_KEY_ID = 'test-key-v1';
// Generate service JWT token
export function generateServiceToken(
service: string,
secret: string = TEST_SERVICE_JWT_SECRET,
expiresIn: string = '1h'
): string {
return jwt.sign({ service }, secret, { expiresIn });
}
// Generate expired token for testing
export function generateExpiredServiceToken(
service: string,
secret: string = TEST_SERVICE_JWT_SECRET
): string {
return jwt.sign({ service }, secret, { expiresIn: '-1h' });
}
// Generate valid public key (66 chars for compressed)
export function generatePublicKey(prefix: string = 'a'): string {
return '02' + prefix.repeat(64);
}
// Create store share payload with defaults
export function createStoreSharePayload(overrides: Partial<StoreShareDto> = {}): StoreShareDto {
return {
userId: '12345',
accountSequence: 1001,
publicKey: generatePublicKey('a'),
encryptedShareData: 'test-encrypted-share-data',
...overrides,
};
}
// Create retrieve share payload with defaults
export function createRetrieveSharePayload(overrides: Partial<RetrieveShareDto> = {}): RetrieveShareDto {
return {
userId: '12345',
publicKey: generatePublicKey('a'),
recoveryToken: 'valid-recovery-token',
...overrides,
};
}
// Create revoke share payload with defaults
export function createRevokeSharePayload(overrides: Partial<RevokeShareDto> = {}): RevokeShareDto {
return {
userId: '12345',
publicKey: generatePublicKey('a'),
reason: 'ROTATION',
...overrides,
};
}
```
### Mock Prisma Service
```typescript
// test/utils/mock-prisma.service.ts
export class MockPrismaService {
backupShare = {
create: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
};
shareAccessLog = {
create: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
deleteMany: jest.fn(),
};
$connect = jest.fn();
$disconnect = jest.fn();
}
```
---
## Running E2E Tests with Real Database
### Using WSL (Windows)
```bash
# 1. Start test database in WSL
wsl docker run -d \
--name backup-service-test-db \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=testpassword \
-e POSTGRES_DB=rwa_backup_test \
-p 5434:5432 \
postgres:15-alpine
# 2. Wait for database to be ready
wsl docker logs -f backup-service-test-db
# 3. Run tests (from Windows)
npm run test:e2e:db
# 4. Cleanup
wsl docker stop backup-service-test-db
wsl docker rm backup-service-test-db
```
### Using Docker Compose
```bash
# Start test database
npm run db:test:up
# Run tests
npm run test:e2e:db
# Stop and cleanup
npm run db:test:down
```
### Manual Setup
```bash
# 1. Set environment variables
export DATABASE_URL="postgresql://postgres:testpassword@localhost:5434/rwa_backup_test?schema=public"
export APP_ENV=test
export SERVICE_JWT_SECRET="test-super-secret-service-jwt-key-for-e2e-testing"
export BACKUP_ENCRYPTION_KEY="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
export BACKUP_ENCRYPTION_KEY_ID="test-key-v1"
# 2. Push schema to test database
npx prisma db push
# 3. Run tests
npm run test:e2e:db
```
---
## Test Coverage
### Current Coverage
| Category | Files | Tests |
|----------|-------|-------|
| Unit Tests | 10 | 37 |
| Mock E2E Tests | 1 | 21 |
| Real DB E2E Tests | 1 | 20 |
| **Total** | **12** | **78** |
### Coverage Report
```bash
# Generate coverage report
npm run test:cov
# View coverage in browser
open coverage/lcov-report/index.html
```
### Coverage Configuration
```json
// In package.json
"jest": {
"coveragePathIgnorePatterns": [
"/node_modules/",
".module.ts",
"main.ts"
],
"collectCoverageFrom": [
"src/**/*.ts",
"!src/**/*.module.ts",
"!src/main.ts"
]
}
```
---
## Writing Good Tests
### Do's
1. **Test behavior, not implementation**
```typescript
// Good: Tests what the method does
it('should encrypt share data before storing', async () => {
await handler.execute(command);
expect(mockEncryption.encrypt).toHaveBeenCalledWith('original-data');
});
// Bad: Tests internal implementation details
it('should call private method _processData', () => { /* ... */ });
```
2. **Use descriptive test names**
```typescript
// Good
it('should return 401 when service token is missing', () => {});
it('should increment access count on each retrieve', () => {});
// Bad
it('test1', () => {});
it('works correctly', () => {});
```
3. **Arrange-Act-Assert pattern**
```typescript
it('should store backup share successfully', async () => {
// Arrange
const command = createStoreCommand();
mockRepository.findByUserId.mockResolvedValue(null);
// Act
const result = await handler.execute(command);
// Assert
expect(result.shareId).toBeDefined();
});
```
4. **One assertion per test (when possible)**
```typescript
// Good: Focused tests
it('should return success true', () => { /* ... */ });
it('should return the share ID', () => { /* ... */ });
// Acceptable for E2E: Multiple assertions for a workflow
it('should complete full lifecycle', () => { /* ... */ });
```
### Don'ts
1. **Don't test external libraries**
```typescript
// Bad: Testing that jwt.sign works
it('should sign JWT token', () => {
const token = jwt.sign({}, 'secret');
expect(jwt.verify(token, 'secret')).toBeDefined();
});
```
2. **Don't share state between tests**
```typescript
// Bad: Tests depend on each other
let sharedData;
it('test 1', () => { sharedData = 'value'; });
it('test 2', () => { expect(sharedData).toBe('value'); });
```
3. **Don't make tests slow**
```typescript
// Bad: Real network calls
it('should fetch data', async () => {
const result = await fetch('https://api.example.com');
});
// Good: Mock external services
it('should fetch data', async () => {
mockFetch.mockResolvedValue({ data: 'test' });
});
```
---
## CI/CD Integration
### GitHub Actions Example
```yaml
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run test:unit
e2e-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: rwa_backup_test
ports:
- 5434:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx prisma generate
- run: npx prisma db push
env:
DATABASE_URL: postgresql://postgres:testpassword@localhost:5434/rwa_backup_test
- run: npm run test:e2e:db
env:
DATABASE_URL: postgresql://postgres:testpassword@localhost:5434/rwa_backup_test
SERVICE_JWT_SECRET: test-secret
BACKUP_ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
BACKUP_ENCRYPTION_KEY_ID: test-key-v1
```
---
## Debugging Tests
### VSCode Debug Configuration
```json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": [
"--runInBand",
"--no-cache",
"${fileBasenameNoExtension}"
],
"console": "integratedTerminal"
}
]
}
```
### Running Single Test
```bash
# Run specific test file
npm test -- backup-share.entity.spec.ts
# Run tests matching pattern
npm test -- --testNamePattern="should store"
# Run with verbose output
npm test -- --verbose
```