# Presence Service 测试文档

## 1. 测试策略概述

本服务采用**测试金字塔**策略，包含三个层次的自动化测试：

```
        ┌───────────────┐
        │    E2E 测试    │  ← 少量，验证完整流程
        │   (20 tests)   │
        ├───────────────┤
        │   集成测试      │  ← 适量，验证模块协作
        │  (22 tests)    │
        ├───────────────┤
        │   单元测试      │  ← 大量，验证核心逻辑
        │  (123 tests)   │
        └───────────────┘
            测试金字塔
```

### 测试统计

| 测试类型 | 套件数 | 用例数 | 覆盖范围 |
|---------|-------|-------|---------|
| 单元测试 | 9 | 123 | 领域层 |
| 集成测试 | 3 | 22 | 应用层 |
| E2E 测试 | 3 | 20 | API 层 |
| **总计** | **15** | **165** | - |

---

## 2. 测试架构

### 2.1 目录结构

```
test/
├── setup.ts                    # 全局测试设置
├── unit/                       # 单元测试
│   ├── domain/
│   │   ├── entities/
│   │   │   ├── event-log.entity.spec.ts
│   │   │   └── online-snapshot.entity.spec.ts
│   │   ├── aggregates/
│   │   │   └── daily-active-stats.aggregate.spec.ts
│   │   ├── value-objects/
│   │   │   ├── install-id.vo.spec.ts
│   │   │   ├── event-name.vo.spec.ts
│   │   │   └── time-window.vo.spec.ts
│   │   └── services/
│   │       ├── online-detection.service.spec.ts
│   │       └── dau-calculation.service.spec.ts
│   └── shared/
│       └── filters/
│           └── global-exception.filter.spec.ts
├── integration/                # 集成测试
│   └── application/
│       ├── commands/
│       │   └── record-heartbeat.handler.spec.ts
│       └── queries/
│           ├── get-online-count.handler.spec.ts
│           └── get-online-history.handler.spec.ts
└── e2e/                        # E2E 测试
    ├── setup-e2e.ts            # E2E 测试设置
    ├── health.e2e-spec.ts
    ├── presence.e2e-spec.ts
    └── analytics.e2e-spec.ts
```

### 2.2 Jest 配置

```javascript
// jest.config.js
const baseConfig = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: '.',
  transform: {
    '^.+\\.(t|j)s$': 'ts-jest',
  },
  testEnvironment: 'node',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
};

module.exports = {
  ...baseConfig,
  collectCoverageFrom: ['src/**/*.(t|j)s', '!src/main.ts', '!src/**/*.module.ts'],
  coverageDirectory: './coverage',
  projects: [
    {
      ...baseConfig,
      displayName: 'unit',
      testMatch: ['<rootDir>/test/unit/**/*.spec.ts'],
      setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
    },
    {
      ...baseConfig,
      displayName: 'integration',
      testMatch: ['<rootDir>/test/integration/**/*.spec.ts'],
      setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
    },
    {
      ...baseConfig,
      displayName: 'e2e',
      testMatch: ['<rootDir>/test/e2e/**/*.(spec|e2e-spec).ts'],
      setupFilesAfterEnv: ['<rootDir>/test/setup.ts', '<rootDir>/test/e2e/setup-e2e.ts'],
    },
  ],
};
```

---

## 3. 单元测试

### 3.1 测试目标

单元测试覆盖**领域层**的核心业务逻辑：
- 值对象的创建和校验
- 实体的行为和状态变化
- 聚合根的业务规则
- 领域服务的计算逻辑

### 3.2 测试原则

1. **隔离性**: 不依赖外部服务（数据库、Redis、网络）
2. **快速性**: 毫秒级执行
3. **可重复性**: 每次执行结果一致
4. **自包含**: 不依赖其他测试的状态

### 3.3 示例：值对象测试

```typescript
// test/unit/domain/value-objects/install-id.vo.spec.ts
import { InstallId } from '@/domain/value-objects/install-id.vo';

describe('InstallId', () => {
  describe('fromString', () => {
    it('should create InstallId with valid value', () => {
      const installId = InstallId.fromString('valid-install-id-123');
      expect(installId.value).toBe('valid-install-id-123');
    });

    it('should throw error for empty value', () => {
      expect(() => InstallId.fromString('')).toThrow();
    });

    it('should throw error for value shorter than 8 characters', () => {
      expect(() => InstallId.fromString('short')).toThrow();
    });

    it('should throw error for value longer than 64 characters', () => {
      const longValue = 'a'.repeat(65);
      expect(() => InstallId.fromString(longValue)).toThrow();
    });

    it('should throw error for value with invalid characters', () => {
      expect(() => InstallId.fromString('invalid@id#123')).toThrow();
    });
  });

  describe('equals', () => {
    it('should return true for same value', () => {
      const id1 = InstallId.fromString('test-install-id');
      const id2 = InstallId.fromString('test-install-id');
      expect(id1.equals(id2)).toBe(true);
    });

    it('should return false for different values', () => {
      const id1 = InstallId.fromString('test-install-id-1');
      const id2 = InstallId.fromString('test-install-id-2');
      expect(id1.equals(id2)).toBe(false);
    });
  });
});
```

### 3.4 示例：领域服务测试

```typescript
// test/unit/domain/services/online-detection.service.spec.ts
import { OnlineDetectionService } from '@/domain/services/online-detection.service';

describe('OnlineDetectionService', () => {
  let service: OnlineDetectionService;

  beforeEach(() => {
    service = new OnlineDetectionService();
  });

  describe('isOnline', () => {
    it('should return true when heartbeat is within window', () => {
      const now = Date.now();
      const lastHeartbeat = new Date(now - 60 * 1000); // 1 minute ago
      const windowSeconds = 180; // 3 minutes

      expect(service.isOnline(lastHeartbeat, windowSeconds)).toBe(true);
    });

    it('should return false when heartbeat is outside window', () => {
      const now = Date.now();
      const lastHeartbeat = new Date(now - 200 * 1000); // 200 seconds ago
      const windowSeconds = 180; // 3 minutes

      expect(service.isOnline(lastHeartbeat, windowSeconds)).toBe(false);
    });

    it('should return true for heartbeat exactly at window boundary', () => {
      const now = Date.now();
      const lastHeartbeat = new Date(now - 180 * 1000); // exactly 3 minutes ago
      const windowSeconds = 180;

      expect(service.isOnline(lastHeartbeat, windowSeconds)).toBe(true);
    });
  });

  describe('calculateThresholdTime', () => {
    it('should calculate correct threshold time', () => {
      jest.useFakeTimers();
      const now = new Date('2025-01-01T12:00:00Z');
      jest.setSystemTime(now);

      const threshold = service.calculateThresholdTime(180);

      expect(threshold.getTime()).toBe(now.getTime() - 180 * 1000);

      jest.useRealTimers();
    });
  });
});
```

### 3.5 示例：聚合根测试

```typescript
// test/unit/domain/aggregates/daily-active-stats.aggregate.spec.ts
import { DailyActiveStats } from '@/domain/aggregates/daily-active-stats/daily-active-stats.aggregate';

describe('DailyActiveStats', () => {
  describe('create', () => {
    it('should create new stats with initial values', () => {
      const day = new Date('2025-01-01');
      const stats = DailyActiveStats.create(day);

      expect(stats.day).toEqual(day);
      expect(stats.dauCount).toBe(0);
      expect(stats.version).toBe(1);
    });
  });

  describe('updateStats', () => {
    it('should update dau count and increment version', () => {
      const stats = DailyActiveStats.create(new Date('2025-01-01'));
      const byProvince = new Map([['广东省', 1000]]);
      const byCity = new Map([['深圳市', 500]]);

      stats.updateStats(5000, byProvince, byCity);

      expect(stats.dauCount).toBe(5000);
      expect(stats.dauByProvince.get('广东省')).toBe(1000);
      expect(stats.dauByCity.get('深圳市')).toBe(500);
      expect(stats.version).toBe(2);
    });

    it('should throw error for negative count', () => {
      const stats = DailyActiveStats.create(new Date('2025-01-01'));

      expect(() => stats.updateStats(-1, new Map(), new Map())).toThrow();
    });
  });

  describe('reconstitute', () => {
    it('should reconstitute from persistence data', () => {
      const data = {
        day: new Date('2025-01-01'),
        dauCount: 5000,
        dauByProvince: new Map([['广东省', 1000]]),
        dauByCity: new Map([['深圳市', 500]]),
        calculatedAt: new Date(),
        version: 3,
      };

      const stats = DailyActiveStats.reconstitute(data);

      expect(stats.dauCount).toBe(5000);
      expect(stats.version).toBe(3);
    });
  });
});
```

---

## 4. 集成测试

### 4.1 测试目标

集成测试验证**应用层**的 Command/Query Handler：
- Handler 与 Mock 仓储的协作
- 业务流程的正确性
- 事务边界

### 4.2 测试策略

- Mock 外部依赖（仓储、Redis、Kafka）
- 使用 NestJS Testing Module
- 验证 Handler 的输入输出

### 4.3 示例：Command Handler 测试

```typescript
// test/integration/application/commands/record-heartbeat.handler.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { RecordHeartbeatHandler } from '@/application/commands/record-heartbeat/record-heartbeat.handler';
import { RecordHeartbeatCommand } from '@/application/commands/record-heartbeat/record-heartbeat.command';
import { PresenceRedisRepository } from '@/infrastructure/redis/presence-redis.repository';
import { KafkaEventPublisher } from '@/infrastructure/kafka/kafka-event.publisher';

describe('RecordHeartbeatHandler', () => {
  let handler: RecordHeartbeatHandler;
  let mockRedisRepo: jest.Mocked<PresenceRedisRepository>;
  let mockKafkaPublisher: jest.Mocked<KafkaEventPublisher>;

  beforeEach(async () => {
    mockRedisRepo = {
      updateUserPresence: jest.fn(),
    } as any;

    mockKafkaPublisher = {
      publish: jest.fn(),
    } as any;

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        RecordHeartbeatHandler,
        { provide: PresenceRedisRepository, useValue: mockRedisRepo },
        { provide: KafkaEventPublisher, useValue: mockKafkaPublisher },
      ],
    }).compile();

    handler = module.get(RecordHeartbeatHandler);
  });

  describe('execute', () => {
    it('should update presence and publish event', async () => {
      const command = new RecordHeartbeatCommand(
        BigInt(12345),
        'test-install-id',
        '1.0.0',
        Date.now(),
      );

      await handler.execute(command);

      expect(mockRedisRepo.updateUserPresence).toHaveBeenCalledWith(
        '12345',
        expect.any(Number),
      );
      expect(mockKafkaPublisher.publish).toHaveBeenCalled();
    });

    it('should handle missing userId gracefully', async () => {
      const command = new RecordHeartbeatCommand(
        undefined,
        'test-install-id',
        '1.0.0',
        Date.now(),
      );

      await handler.execute(command);

      expect(mockRedisRepo.updateUserPresence).toHaveBeenCalledWith(
        'test-install-id', // fallback to installId
        expect.any(Number),
      );
    });
  });
});
```

### 4.4 示例：Query Handler 测试

```typescript
// test/integration/application/queries/get-online-history.handler.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { GetOnlineHistoryHandler } from '@/application/queries/get-online-history/get-online-history.handler';
import { GetOnlineHistoryQuery } from '@/application/queries/get-online-history/get-online-history.query';
import { ONLINE_SNAPSHOT_REPOSITORY } from '@/domain/repositories/online-snapshot.repository.interface';
import { OnlineSnapshot } from '@/domain/entities/online-snapshot.entity';

describe('GetOnlineHistoryHandler', () => {
  let handler: GetOnlineHistoryHandler;
  let mockRepo: any;

  beforeEach(async () => {
    mockRepo = {
      findByTimeRange: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        GetOnlineHistoryHandler,
        { provide: ONLINE_SNAPSHOT_REPOSITORY, useValue: mockRepo },
      ],
    }).compile();

    handler = module.get(GetOnlineHistoryHandler);
  });

  describe('execute', () => {
    it('should return online history with summary', async () => {
      const snapshots = [
        createSnapshot(new Date('2025-01-01T12:00:00Z'), 1000),
        createSnapshot(new Date('2025-01-01T12:05:00Z'), 1200),
        createSnapshot(new Date('2025-01-01T12:10:00Z'), 1100),
      ];
      mockRepo.findByTimeRange.mockResolvedValue(snapshots);

      const query = new GetOnlineHistoryQuery(
        new Date('2025-01-01T12:00:00Z'),
        new Date('2025-01-01T13:00:00Z'),
        '5m',
      );

      const result = await handler.execute(query);

      expect(result.data).toHaveLength(3);
      expect(result.summary.max).toBe(1200);
      expect(result.summary.min).toBe(1000);
      expect(result.summary.avg).toBe(1100);
    });

    it('should return empty result for no data', async () => {
      mockRepo.findByTimeRange.mockResolvedValue([]);

      const query = new GetOnlineHistoryQuery(
        new Date('2025-01-01T12:00:00Z'),
        new Date('2025-01-01T13:00:00Z'),
        '5m',
      );

      const result = await handler.execute(query);

      expect(result.data).toHaveLength(0);
      expect(result.total).toBe(0);
    });
  });
});

function createSnapshot(ts: Date, onlineCount: number): OnlineSnapshot {
  return OnlineSnapshot.reconstitute({
    id: BigInt(Math.floor(Math.random() * 1000000)),
    ts,
    onlineCount,
    windowSeconds: 180,
  });
}
```

---

## 5. E2E 测试

### 5.1 测试目标

E2E 测试验证**完整的 API 流程**：
- HTTP 请求/响应
- 认证和授权
- 参数校验
- 数据库交互

### 5.2 测试环境

E2E 测试需要真实的基础设施：

```yaml
# docker-compose.test.yml
services:
  postgres-test:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: presence_test
    ports:
      - "5434:5432"

  redis-test:
    image: redis:7-alpine
    ports:
      - "6381:6379"
```

### 5.3 测试设置

```typescript
// test/e2e/setup-e2e.ts
beforeAll(async () => {
  if (!process.env.DATABASE_URL) {
    console.warn('WARNING: DATABASE_URL not set.');
  }
  if (!process.env.REDIS_HOST) {
    console.warn('WARNING: REDIS_HOST not set.');
  }
});
```

### 5.4 示例：API 测试

```typescript
// test/e2e/presence.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe, ExecutionContext } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';
import { GlobalExceptionFilter } from '../../src/shared/filters/global-exception.filter';
import { JwtAuthGuard } from '../../src/shared/guards/jwt-auth.guard';

describe('Presence API (E2E)', () => {
  let app: INestApplication;
  const mockUserId = BigInt(12345);

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideGuard(JwtAuthGuard)
      .useValue({
        canActivate: (context: ExecutionContext) => {
          const req = context.switchToHttp().getRequest();
          req.user = { userId: mockUserId.toString() };
          return true;
        },
      })
      .compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalFilters(new GlobalExceptionFilter());
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: true,
    }));
    app.setGlobalPrefix('api/v1');

    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('POST /api/v1/presence/heartbeat', () => {
    it('should record heartbeat successfully', async () => {
      const response = await request(app.getHttpServer())
        .post('/api/v1/presence/heartbeat')
        .send({
          installId: 'test-install-id-12345',
          appVersion: '1.0.0',
          clientTs: Date.now(),
        })
        .expect(201);

      expect(response.body).toHaveProperty('ok', true);
      expect(response.body).toHaveProperty('serverTs');
    });

    it('should validate installId type', async () => {
      const response = await request(app.getHttpServer())
        .post('/api/v1/presence/heartbeat')
        .send({
          installId: 12345, // Invalid: not a string
          appVersion: '1.0.0',
          clientTs: Date.now(),
        })
        .expect(400);

      expect(response.body.statusCode).toBe(400);
    });
  });

  describe('GET /api/v1/presence/online-count', () => {
    it('should return online count', async () => {
      const response = await request(app.getHttpServer())
        .get('/api/v1/presence/online-count')
        .expect(200);

      expect(response.body).toHaveProperty('count');
      expect(typeof response.body.count).toBe('number');
      expect(response.body).toHaveProperty('windowSeconds', 180);
    });
  });

  describe('GET /api/v1/presence/online-history', () => {
    it('should return online history', async () => {
      const response = await request(app.getHttpServer())
        .get('/api/v1/presence/online-history')
        .query({
          startTime: new Date(Date.now() - 3600000).toISOString(),
          endTime: new Date().toISOString(),
          interval: '5m',
        })
        .expect(200);

      expect(response.body).toHaveProperty('data');
      expect(Array.isArray(response.body.data)).toBe(true);
      expect(response.body).toHaveProperty('interval', '5m');
    });

    it('should validate interval enum', async () => {
      await request(app.getHttpServer())
        .get('/api/v1/presence/online-history')
        .query({
          startTime: new Date(Date.now() - 3600000).toISOString(),
          endTime: new Date().toISOString(),
          interval: '10m', // Invalid
        })
        .expect(400);
    });
  });
});
```

---

## 6. 运行测试

### 6.1 命令行运行

```bash
# 运行所有测试
npm test

# 运行单元测试
npm run test:unit

# 运行集成测试
npm run test:integration

# 运行 E2E 测试 (需要 Docker)
npm run test:e2e

# 生成覆盖率报告
npm run test:cov

# 监视模式
npm run test:watch

# 运行特定测试文件
npm test -- --testPathPattern="install-id.vo.spec.ts"
```

### 6.2 使用 Make 命令

```bash
# 启动测试基础设施
make test-docker-up

# 运行单元测试
make test-unit

# 运行集成测试
make test-integration

# 运行 E2E 测试
make test-e2e

# 运行所有测试
make test-all

# 停止测试基础设施
make test-docker-down

# Docker 内运行全部测试
make test-docker-all
```

### 6.3 在 WSL2 中运行

```bash
# 进入 WSL2
wsl -d Ubuntu

# 设置环境变量并运行
export DATABASE_URL='postgresql://test:test@localhost:5434/presence_test'
export REDIS_HOST='localhost'
export REDIS_PORT='6381'

npm test
```

---

## 7. 覆盖率报告

### 7.1 生成报告

```bash
npm run test:cov
```

### 7.2 覆盖率阈值

```javascript
// package.json
{
  "jest": {
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}
```

### 7.3 查看报告

覆盖率报告生成在 `coverage/` 目录：
- `coverage/lcov-report/index.html` - HTML 报告
- `coverage/lcov.info` - LCOV 格式

---

## 8. CI/CD 集成

### 8.1 GitHub Actions

```yaml
# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: presence_test
        ports:
          - 5434:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7-alpine
        ports:
          - 6381:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Generate Prisma Client
        run: npx prisma generate

      - name: Push database schema
        run: npx prisma db push
        env:
          DATABASE_URL: postgresql://test:test@localhost:5434/presence_test

      - name: Run unit tests
        run: npm run test:unit

      - name: Run integration tests
        run: npm run test:integration

      - name: Run E2E tests
        run: npm run test:e2e
        env:
          DATABASE_URL: postgresql://test:test@localhost:5434/presence_test
          REDIS_HOST: localhost
          REDIS_PORT: 6381

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info
```

---

## 9. 测试最佳实践

### 9.1 命名规范

```typescript
describe('ClassName/FunctionName', () => {
  describe('methodName', () => {
    it('should <expected behavior> when <condition>', () => {
      // Arrange - Given
      // Act - When
      // Assert - Then
    });
  });
});
```

### 9.2 AAA 模式

```typescript
it('should calculate correct DAU count', () => {
  // Arrange (Given)
  const events = [createEvent('user1'), createEvent('user2')];
  const service = new DauCalculationService();

  // Act (When)
  const result = service.calculateDau(events);

  // Assert (Then)
  expect(result.total).toBe(2);
});
```

### 9.3 避免测试反模式

```typescript
// ❌ 测试实现细节
it('should call repository.save once', () => {
  expect(mockRepo.save).toHaveBeenCalledTimes(1);
});

// ✅ 测试行为
it('should persist the event', async () => {
  await handler.execute(command);
  const saved = await mockRepo.findById(eventId);
  expect(saved).toBeDefined();
});

// ❌ 过度 Mock
it('should return mocked value', () => {
  mockService.calculate.mockReturnValue(100);
  expect(service.calculate()).toBe(100); // 测试的是 Mock，不是代码
});

// ✅ 测试真实逻辑
it('should calculate correct value', () => {
  const service = new CalculationService();
  expect(service.calculate(10, 20)).toBe(30);
});
```

### 9.4 测试数据工厂

```typescript
// test/factories/event-log.factory.ts
export function createEventLog(overrides: Partial<EventLogProps> = {}): EventLog {
  return EventLog.create({
    userId: BigInt(12345),
    installId: InstallId.fromString('test-install-id'),
    eventName: EventName.fromString('app_session_start'),
    eventTime: new Date(),
    properties: EventProperties.fromData({}),
    ...overrides,
  });
}
```
