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

21 KiB

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

# 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

// 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

// 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

// 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:

// 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:

// 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

// 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

// 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)

# 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

# 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

# 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

# Generate coverage report
npm run test:cov

# View coverage in browser
open coverage/lcov-report/index.html

Coverage Configuration

// 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

    // 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

    // 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

    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)

    // 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

    // 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

    // 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

    // 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

# .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

{
  "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

# 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