# 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; let mockEncryptionService: jest.Mocked; let mockAuditLogRepository: jest.Mocked; 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); }); 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; 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); }); 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 { 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 { return { userId: '12345', publicKey: generatePublicKey('a'), recoveryToken: 'valid-recovery-token', ...overrides, }; } // Create revoke share payload with defaults export function createRevokeSharePayload(overrides: Partial = {}): 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 ```