1053 lines
29 KiB
Markdown
1053 lines
29 KiB
Markdown
# Authorization Service 测试文档
|
||
|
||
## 目录
|
||
|
||
1. [测试架构概述](#测试架构概述)
|
||
2. [测试金字塔](#测试金字塔)
|
||
3. [单元测试](#单元测试)
|
||
4. [集成测试](#集成测试)
|
||
5. [端到端测试](#端到端测试)
|
||
6. [Docker 测试环境](#docker-测试环境)
|
||
7. [测试覆盖率](#测试覆盖率)
|
||
8. [CI/CD 集成](#cicd-集成)
|
||
9. [手动测试指南](#手动测试指南)
|
||
|
||
---
|
||
|
||
## 测试架构概述
|
||
|
||
Authorization Service 采用多层测试策略,确保代码质量和系统稳定性。
|
||
|
||
### 测试框架
|
||
|
||
| 框架 | 用途 |
|
||
|------|------|
|
||
| Jest | 测试运行器和断言库 |
|
||
| @nestjs/testing | NestJS 测试工具 |
|
||
| supertest | HTTP 请求测试 |
|
||
| ts-jest | TypeScript 支持 |
|
||
|
||
### 测试类型
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ E2E Tests │ ← 少量,验证完整流程
|
||
│ (test/*.e2e-spec.ts) │
|
||
├─────────────────────────────────────────────┤
|
||
│ Integration Tests │ ← 中等,验证模块协作
|
||
│ (test/*.integration-spec.ts) │
|
||
├─────────────────────────────────────────────┤
|
||
│ Unit Tests │ ← 大量,验证单个组件
|
||
│ (src/**/*.spec.ts) │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 测试金字塔
|
||
|
||
### 测试分布
|
||
|
||
| 层级 | 数量 | 执行时间 | 覆盖范围 |
|
||
|------|------|----------|----------|
|
||
| 单元测试 | 33+ | ~15秒 | 领域层核心逻辑 |
|
||
| 集成测试 | 30+ | ~25秒 | 领域服务、值对象交互 |
|
||
| E2E测试 | 6+ | ~20秒 | API端点、认证流程 |
|
||
|
||
### 运行所有测试
|
||
|
||
```bash
|
||
# 运行所有测试
|
||
npm run test:all
|
||
|
||
# 或分别运行
|
||
npm run test:unit # 单元测试
|
||
npm run test:integration # 集成测试
|
||
npm run test:e2e # E2E 测试
|
||
```
|
||
|
||
---
|
||
|
||
## 单元测试
|
||
|
||
### 测试位置
|
||
|
||
单元测试与源代码放在一起:
|
||
|
||
```
|
||
src/
|
||
├── domain/
|
||
│ ├── aggregates/
|
||
│ │ ├── authorization-role.aggregate.ts
|
||
│ │ └── authorization-role.aggregate.spec.ts ← 单元测试
|
||
│ ├── entities/
|
||
│ │ ├── ladder-target-rule.entity.ts
|
||
│ │ └── ladder-target-rule.entity.spec.ts
|
||
│ └── value-objects/
|
||
│ ├── month.vo.ts
|
||
│ └── month.vo.spec.ts
|
||
```
|
||
|
||
### 测试配置
|
||
|
||
```json
|
||
// jest.config.js 或 package.json
|
||
{
|
||
"jest": {
|
||
"moduleFileExtensions": ["js", "json", "ts"],
|
||
"rootDir": "src",
|
||
"testRegex": ".*\\.spec\\.ts$",
|
||
"transform": {
|
||
"^.+\\.(t|j)s$": "ts-jest"
|
||
},
|
||
"moduleNameMapper": {
|
||
"^@/(.*)$": "<rootDir>/$1"
|
||
},
|
||
"testEnvironment": "node"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 聚合根测试示例
|
||
|
||
```typescript
|
||
// src/domain/aggregates/authorization-role.aggregate.spec.ts
|
||
|
||
import { AuthorizationRole } from './authorization-role.aggregate'
|
||
import { UserId, AdminUserId } from '@/domain/value-objects'
|
||
import { RoleType, AuthorizationStatus } from '@/domain/enums'
|
||
import { DomainError } from '@/shared/exceptions'
|
||
|
||
describe('AuthorizationRole Aggregate', () => {
|
||
describe('createAuthProvinceCompany', () => {
|
||
it('should create a pending province company authorization', () => {
|
||
const userId = UserId.create('user-123')
|
||
|
||
const auth = AuthorizationRole.createAuthProvinceCompany({
|
||
userId,
|
||
provinceCode: '430000',
|
||
provinceName: '湖南省',
|
||
})
|
||
|
||
expect(auth.userId.value).toBe('user-123')
|
||
expect(auth.roleType).toBe(RoleType.AUTH_PROVINCE_COMPANY)
|
||
expect(auth.status).toBe(AuthorizationStatus.PENDING)
|
||
expect(auth.provinceCode).toBe('430000')
|
||
expect(auth.provinceName).toBe('湖南省')
|
||
})
|
||
|
||
it('should emit AuthorizationAppliedEvent', () => {
|
||
const userId = UserId.create('user-123')
|
||
|
||
const auth = AuthorizationRole.createAuthProvinceCompany({
|
||
userId,
|
||
provinceCode: '430000',
|
||
provinceName: '湖南省',
|
||
})
|
||
|
||
expect(auth.domainEvents).toHaveLength(1)
|
||
expect(auth.domainEvents[0].eventName).toBe('authorization.applied')
|
||
})
|
||
})
|
||
|
||
describe('authorize', () => {
|
||
it('should change status to APPROVED when pending', () => {
|
||
const auth = createPendingAuthorization()
|
||
const adminId = AdminUserId.create('admin-001')
|
||
|
||
auth.authorize(adminId)
|
||
|
||
expect(auth.status).toBe(AuthorizationStatus.APPROVED)
|
||
expect(auth.authorizedBy?.value).toBe('admin-001')
|
||
})
|
||
|
||
it('should throw error when already approved', () => {
|
||
const auth = createApprovedAuthorization()
|
||
const adminId = AdminUserId.create('admin-001')
|
||
|
||
expect(() => auth.authorize(adminId)).toThrow(DomainError)
|
||
})
|
||
})
|
||
|
||
describe('activate', () => {
|
||
it('should change status to ACTIVE when approved', () => {
|
||
const auth = createApprovedAuthorization()
|
||
|
||
auth.activate()
|
||
|
||
expect(auth.status).toBe(AuthorizationStatus.ACTIVE)
|
||
expect(auth.activatedAt).toBeDefined()
|
||
})
|
||
})
|
||
|
||
describe('revoke', () => {
|
||
it('should change status to REVOKED with reason', () => {
|
||
const auth = createActiveAuthorization()
|
||
const adminId = AdminUserId.create('admin-001')
|
||
|
||
auth.revoke(adminId, '违规操作')
|
||
|
||
expect(auth.status).toBe(AuthorizationStatus.REVOKED)
|
||
expect(auth.revokedBy?.value).toBe('admin-001')
|
||
expect(auth.revokeReason).toBe('违规操作')
|
||
})
|
||
})
|
||
})
|
||
|
||
// 测试辅助函数
|
||
function createPendingAuthorization(): AuthorizationRole {
|
||
return AuthorizationRole.createAuthProvinceCompany({
|
||
userId: UserId.create('user-123'),
|
||
provinceCode: '430000',
|
||
provinceName: '湖南省',
|
||
})
|
||
}
|
||
|
||
function createApprovedAuthorization(): AuthorizationRole {
|
||
const auth = createPendingAuthorization()
|
||
auth.authorize(AdminUserId.create('admin-001'))
|
||
return auth
|
||
}
|
||
|
||
function createActiveAuthorization(): AuthorizationRole {
|
||
const auth = createApprovedAuthorization()
|
||
auth.activate()
|
||
return auth
|
||
}
|
||
```
|
||
|
||
### 值对象测试示例
|
||
|
||
```typescript
|
||
// src/domain/value-objects/month.vo.spec.ts
|
||
|
||
import { Month } from './month.vo'
|
||
|
||
describe('Month Value Object', () => {
|
||
describe('create', () => {
|
||
it('should create month from valid string', () => {
|
||
const month = Month.create('2024-03')
|
||
expect(month.value).toBe('2024-03')
|
||
})
|
||
|
||
it('should throw error for invalid format', () => {
|
||
expect(() => Month.create('2024/03')).toThrow()
|
||
expect(() => Month.create('03-2024')).toThrow()
|
||
expect(() => Month.create('invalid')).toThrow()
|
||
})
|
||
})
|
||
|
||
describe('current', () => {
|
||
it('should create current month', () => {
|
||
const month = Month.current()
|
||
const now = new Date()
|
||
const expected = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||
expect(month.value).toBe(expected)
|
||
})
|
||
})
|
||
|
||
describe('navigation', () => {
|
||
it('should get next month', () => {
|
||
expect(Month.create('2024-03').next().value).toBe('2024-04')
|
||
expect(Month.create('2024-12').next().value).toBe('2025-01')
|
||
})
|
||
|
||
it('should get previous month', () => {
|
||
expect(Month.create('2024-03').previous().value).toBe('2024-02')
|
||
expect(Month.create('2024-01').previous().value).toBe('2023-12')
|
||
})
|
||
})
|
||
|
||
describe('comparison', () => {
|
||
it('should compare months correctly', () => {
|
||
const m1 = Month.create('2024-03')
|
||
const m2 = Month.create('2024-06')
|
||
|
||
expect(m1.isBefore(m2)).toBe(true)
|
||
expect(m2.isAfter(m1)).toBe(true)
|
||
expect(m1.equals(Month.create('2024-03'))).toBe(true)
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
### 实体测试示例
|
||
|
||
```typescript
|
||
// src/domain/entities/ladder-target-rule.entity.spec.ts
|
||
|
||
import { LadderTargetRule } from './ladder-target-rule.entity'
|
||
import { RoleType } from '@/domain/enums'
|
||
|
||
describe('LadderTargetRule Entity', () => {
|
||
describe('getTarget', () => {
|
||
describe('province company', () => {
|
||
it.each([
|
||
[1, 150, 150],
|
||
[2, 300, 450],
|
||
[3, 600, 1050],
|
||
[5, 2400, 4650],
|
||
[9, 11750, 50000],
|
||
[12, 11750, 50000], // 超过9月使用第9月目标
|
||
])('month %i should have monthly=%i, cumulative=%i', (month, monthly, cumulative) => {
|
||
const target = LadderTargetRule.getTarget(RoleType.AUTH_PROVINCE_COMPANY, month)
|
||
|
||
expect(target.monthlyTarget).toBe(monthly)
|
||
expect(target.cumulativeTarget).toBe(cumulative)
|
||
})
|
||
})
|
||
|
||
describe('city company', () => {
|
||
it('should return correct targets', () => {
|
||
const target = LadderTargetRule.getTarget(RoleType.AUTH_CITY_COMPANY, 1)
|
||
|
||
expect(target.monthlyTarget).toBe(30)
|
||
expect(target.cumulativeTarget).toBe(30)
|
||
})
|
||
})
|
||
|
||
describe('community', () => {
|
||
it('should return fixed target', () => {
|
||
const target = LadderTargetRule.getTarget(RoleType.COMMUNITY, 1)
|
||
|
||
expect(target.monthlyTarget).toBe(10)
|
||
expect(target.cumulativeTarget).toBe(10)
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('getFinalTarget', () => {
|
||
it('should return 50000 for province', () => {
|
||
expect(LadderTargetRule.getFinalTarget(RoleType.AUTH_PROVINCE_COMPANY)).toBe(50000)
|
||
})
|
||
|
||
it('should return 10000 for city', () => {
|
||
expect(LadderTargetRule.getFinalTarget(RoleType.AUTH_CITY_COMPANY)).toBe(10000)
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 集成测试
|
||
|
||
### 测试位置
|
||
|
||
集成测试位于 `test/` 目录:
|
||
|
||
```
|
||
test/
|
||
├── jest-integration.json # 集成测试配置
|
||
├── setup-integration.ts # 测试环境设置
|
||
└── domain-services.integration-spec.ts # 领域服务集成测试
|
||
```
|
||
|
||
### 测试配置
|
||
|
||
```json
|
||
// test/jest-integration.json
|
||
{
|
||
"moduleFileExtensions": ["js", "json", "ts"],
|
||
"rootDir": ".",
|
||
"testEnvironment": "node",
|
||
"testRegex": ".integration-spec.ts$",
|
||
"transform": {
|
||
"^.+\\.(t|j)s$": "ts-jest"
|
||
},
|
||
"moduleNameMapper": {
|
||
"^@/(.*)$": "<rootDir>/../src/$1"
|
||
},
|
||
"setupFilesAfterEnv": ["<rootDir>/setup-integration.ts"],
|
||
"testTimeout": 30000
|
||
}
|
||
```
|
||
|
||
### 领域服务集成测试
|
||
|
||
```typescript
|
||
// test/domain-services.integration-spec.ts
|
||
|
||
import { Test, TestingModule } from '@nestjs/testing'
|
||
import { AuthorizationValidatorService, IReferralRepository } from '@/domain/services/authorization-validator.service'
|
||
import { AssessmentCalculatorService, ITeamStatisticsRepository } from '@/domain/services/assessment-calculator.service'
|
||
import { IAuthorizationRoleRepository } from '@/domain/repositories'
|
||
import { UserId, RegionCode, Month } from '@/domain/value-objects'
|
||
import { RoleType } from '@/domain/enums'
|
||
import { AuthorizationRole } from '@/domain/aggregates'
|
||
|
||
describe('Domain Services Integration Tests', () => {
|
||
let module: TestingModule
|
||
let validatorService: AuthorizationValidatorService
|
||
let calculatorService: AssessmentCalculatorService
|
||
|
||
// Mock repositories
|
||
const mockAuthorizationRoleRepository: jest.Mocked<IAuthorizationRoleRepository> = {
|
||
save: jest.fn(),
|
||
findById: jest.fn(),
|
||
findByUserIdAndRoleType: jest.fn(),
|
||
findByUserIdRoleTypeAndRegion: jest.fn(),
|
||
findByUserId: jest.fn(),
|
||
findActiveByRoleTypeAndRegion: jest.fn(),
|
||
findAllActive: jest.fn(),
|
||
findPendingByUserId: jest.fn(),
|
||
findByStatus: jest.fn(),
|
||
delete: jest.fn(),
|
||
}
|
||
|
||
const mockReferralRepository: jest.Mocked<IReferralRepository> = {
|
||
findByUserId: jest.fn(),
|
||
getAllAncestors: jest.fn(),
|
||
getAllDescendants: jest.fn(),
|
||
}
|
||
|
||
const mockTeamStatisticsRepository: jest.Mocked<ITeamStatisticsRepository> = {
|
||
findByUserId: jest.fn(),
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
module = await Test.createTestingModule({
|
||
providers: [
|
||
AuthorizationValidatorService,
|
||
AssessmentCalculatorService,
|
||
{
|
||
provide: 'AUTHORIZATION_ROLE_REPOSITORY',
|
||
useValue: mockAuthorizationRoleRepository,
|
||
},
|
||
{
|
||
provide: 'REFERRAL_REPOSITORY',
|
||
useValue: mockReferralRepository,
|
||
},
|
||
{
|
||
provide: 'TEAM_STATISTICS_REPOSITORY',
|
||
useValue: mockTeamStatisticsRepository,
|
||
},
|
||
],
|
||
}).compile()
|
||
|
||
validatorService = module.get(AuthorizationValidatorService)
|
||
calculatorService = module.get(AssessmentCalculatorService)
|
||
})
|
||
|
||
afterAll(async () => {
|
||
await module.close()
|
||
})
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
})
|
||
|
||
describe('AuthorizationValidatorService', () => {
|
||
describe('validateAuthorizationRequest', () => {
|
||
it('should return success when no conflicts', async () => {
|
||
const userId = UserId.create('user-123')
|
||
const roleType = RoleType.AUTH_PROVINCE_COMPANY
|
||
const regionCode = RegionCode.create('430000')
|
||
|
||
mockAuthorizationRoleRepository.findByUserIdAndRoleType.mockResolvedValue(null)
|
||
mockReferralRepository.findByUserId.mockResolvedValue(null)
|
||
|
||
const result = await validatorService.validateAuthorizationRequest(
|
||
userId,
|
||
roleType,
|
||
regionCode,
|
||
mockReferralRepository,
|
||
mockAuthorizationRoleRepository,
|
||
)
|
||
|
||
expect(result.isValid).toBe(true)
|
||
})
|
||
|
||
it('should return failure when user already has authorization', async () => {
|
||
const userId = UserId.create('user-123')
|
||
const roleType = RoleType.AUTH_PROVINCE_COMPANY
|
||
const regionCode = RegionCode.create('430000')
|
||
|
||
const existingAuth = AuthorizationRole.createAuthProvinceCompany({
|
||
userId,
|
||
provinceCode: '440000',
|
||
provinceName: '广东省',
|
||
})
|
||
|
||
mockAuthorizationRoleRepository.findByUserIdAndRoleType.mockResolvedValue(existingAuth)
|
||
|
||
const result = await validatorService.validateAuthorizationRequest(
|
||
userId,
|
||
roleType,
|
||
regionCode,
|
||
mockReferralRepository,
|
||
mockAuthorizationRoleRepository,
|
||
)
|
||
|
||
expect(result.isValid).toBe(false)
|
||
expect(result.errorMessage).toContain('只能申请一个省代或市代授权')
|
||
})
|
||
|
||
it('should return failure when team member has same region authorization', async () => {
|
||
const userId = UserId.create('user-123')
|
||
const ancestorUserId = UserId.create('ancestor-user')
|
||
const roleType = RoleType.AUTH_PROVINCE_COMPANY
|
||
const regionCode = RegionCode.create('430000')
|
||
|
||
mockAuthorizationRoleRepository.findByUserIdAndRoleType.mockResolvedValue(null)
|
||
mockReferralRepository.findByUserId.mockResolvedValue({ parentId: 'ancestor-user' })
|
||
mockReferralRepository.getAllAncestors.mockResolvedValue([ancestorUserId])
|
||
mockReferralRepository.getAllDescendants.mockResolvedValue([])
|
||
|
||
const existingAuth = AuthorizationRole.createAuthProvinceCompany({
|
||
userId: ancestorUserId,
|
||
provinceCode: '430000',
|
||
provinceName: '湖南省',
|
||
})
|
||
mockAuthorizationRoleRepository.findByUserIdRoleTypeAndRegion.mockResolvedValue(existingAuth)
|
||
|
||
const result = await validatorService.validateAuthorizationRequest(
|
||
userId,
|
||
roleType,
|
||
regionCode,
|
||
mockReferralRepository,
|
||
mockAuthorizationRoleRepository,
|
||
)
|
||
|
||
expect(result.isValid).toBe(false)
|
||
expect(result.errorMessage).toContain('本团队已有人申请')
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('AssessmentCalculatorService', () => {
|
||
describe('assessAndRankRegion', () => {
|
||
it('should calculate assessments and rank by exceed ratio', async () => {
|
||
const roleType = RoleType.AUTH_PROVINCE_COMPANY
|
||
const regionCode = RegionCode.create('430000')
|
||
const assessmentMonth = Month.current()
|
||
|
||
// Setup: Two authorizations
|
||
const auth1 = AuthorizationRole.createAuthProvinceCompany({
|
||
userId: UserId.create('user-1'),
|
||
provinceCode: '430000',
|
||
provinceName: '湖南省',
|
||
})
|
||
auth1.authorize(AdminUserId.create('admin'))
|
||
|
||
const auth2 = AuthorizationRole.createAuthProvinceCompany({
|
||
userId: UserId.create('user-2'),
|
||
provinceCode: '430000',
|
||
provinceName: '湖南省',
|
||
})
|
||
auth2.authorize(AdminUserId.create('admin'))
|
||
|
||
mockAuthorizationRoleRepository.findActiveByRoleTypeAndRegion.mockResolvedValue([auth1, auth2])
|
||
|
||
// User 1 has better stats
|
||
mockTeamStatisticsRepository.findByUserId
|
||
.mockResolvedValueOnce({
|
||
userId: 'user-1',
|
||
totalTeamPlantingCount: 200,
|
||
getProvinceTeamCount: () => 70,
|
||
})
|
||
.mockResolvedValueOnce({
|
||
userId: 'user-2',
|
||
totalTeamPlantingCount: 100,
|
||
getProvinceTeamCount: () => 35,
|
||
})
|
||
|
||
const assessments = await calculatorService.assessAndRankRegion(
|
||
roleType,
|
||
regionCode,
|
||
assessmentMonth,
|
||
mockAuthorizationRoleRepository,
|
||
mockTeamStatisticsRepository,
|
||
)
|
||
|
||
expect(assessments.length).toBe(2)
|
||
expect(assessments[0].userId.value).toBe('user-1')
|
||
expect(assessments[0].rankingInRegion).toBe(1)
|
||
expect(assessments[0].isFirstPlace).toBe(true)
|
||
})
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 端到端测试
|
||
|
||
### 测试位置
|
||
|
||
```
|
||
test/
|
||
├── jest-e2e.json # E2E 测试配置
|
||
└── app.e2e-spec.ts # E2E 测试
|
||
```
|
||
|
||
### E2E 测试配置
|
||
|
||
```json
|
||
// test/jest-e2e.json
|
||
{
|
||
"moduleFileExtensions": ["js", "json", "ts"],
|
||
"rootDir": ".",
|
||
"testEnvironment": "node",
|
||
"testRegex": ".e2e-spec.ts$",
|
||
"transform": {
|
||
"^.+\\.(t|j)s$": "ts-jest"
|
||
},
|
||
"moduleNameMapper": {
|
||
"^@/(.*)$": "<rootDir>/../src/$1"
|
||
},
|
||
"testTimeout": 60000
|
||
}
|
||
```
|
||
|
||
### E2E 测试示例
|
||
|
||
```typescript
|
||
// test/app.e2e-spec.ts
|
||
|
||
import { Test, TestingModule } from '@nestjs/testing'
|
||
import { INestApplication, ValidationPipe } from '@nestjs/common'
|
||
import * as request from 'supertest'
|
||
import { AppModule } from '@/app.module'
|
||
import { GlobalExceptionFilter } from '@/shared/filters'
|
||
import { TransformInterceptor } from '@/shared/interceptors'
|
||
import { PrismaClient } from '@prisma/client'
|
||
|
||
describe('Authorization Service E2E Tests', () => {
|
||
let app: INestApplication
|
||
let prisma: PrismaClient
|
||
const isDockerEnv = process.env.NODE_ENV === 'test' && process.env.DATABASE_URL?.includes('test-db')
|
||
|
||
beforeAll(async () => {
|
||
try {
|
||
// Initialize Prisma
|
||
prisma = new PrismaClient()
|
||
await prisma.$connect()
|
||
|
||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||
imports: [AppModule],
|
||
}).compile()
|
||
|
||
app = moduleFixture.createNestApplication()
|
||
app.setGlobalPrefix('api/v1')
|
||
app.useGlobalPipes(
|
||
new ValidationPipe({
|
||
whitelist: true,
|
||
forbidNonWhitelisted: true,
|
||
transform: true,
|
||
}),
|
||
)
|
||
app.useGlobalFilters(new GlobalExceptionFilter())
|
||
app.useGlobalInterceptors(new TransformInterceptor())
|
||
|
||
await app.init()
|
||
console.log('E2E tests initialized successfully')
|
||
} catch (error) {
|
||
if (isDockerEnv) {
|
||
throw new Error(`E2E tests failed in Docker: ${error}`)
|
||
}
|
||
console.log('E2E tests skipped: Infrastructure not available')
|
||
}
|
||
}, 60000)
|
||
|
||
afterAll(async () => {
|
||
if (app) await app.close()
|
||
if (prisma) await prisma.$disconnect()
|
||
})
|
||
|
||
describe('Authentication Tests', () => {
|
||
it('should return 401 for unauthorized access to /api/v1/authorizations/my', async () => {
|
||
if (!app) return
|
||
|
||
return request(app.getHttpServer())
|
||
.get('/api/v1/authorizations/my')
|
||
.expect(401)
|
||
})
|
||
|
||
it('should return 401 for unauthorized admin access', async () => {
|
||
if (!app) return
|
||
|
||
return request(app.getHttpServer())
|
||
.post('/api/v1/admin/authorizations/province-company')
|
||
.send({ userId: 'test', provinceCode: '430000', provinceName: '湖南省' })
|
||
.expect(401)
|
||
})
|
||
})
|
||
|
||
describe('Validation Tests', () => {
|
||
it('should return 401 for community authorization without token', async () => {
|
||
if (!app) return
|
||
|
||
return request(app.getHttpServer())
|
||
.post('/api/v1/authorizations/community')
|
||
.send({ communityName: '测试社区' })
|
||
.expect(401)
|
||
})
|
||
|
||
it('should return 401 for province authorization without token', async () => {
|
||
if (!app) return
|
||
|
||
return request(app.getHttpServer())
|
||
.post('/api/v1/authorizations/province')
|
||
.send({ provinceCode: '430000', provinceName: '湖南省' })
|
||
.expect(401)
|
||
})
|
||
|
||
it('should return 401 for city authorization without token', async () => {
|
||
if (!app) return
|
||
|
||
return request(app.getHttpServer())
|
||
.post('/api/v1/authorizations/city')
|
||
.send({ cityCode: '430100', cityName: '长沙市' })
|
||
.expect(401)
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## Docker 测试环境
|
||
|
||
### Docker Compose 配置
|
||
|
||
```yaml
|
||
# docker-compose.test.yml
|
||
|
||
services:
|
||
test-db:
|
||
image: postgres:15-alpine
|
||
environment:
|
||
POSTGRES_USER: test
|
||
POSTGRES_PASSWORD: test
|
||
POSTGRES_DB: authorization_test
|
||
ports:
|
||
- "5433:5432"
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U test -d authorization_test"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
test-redis:
|
||
image: redis:7-alpine
|
||
ports:
|
||
- "6380:6379"
|
||
healthcheck:
|
||
test: ["CMD", "redis-cli", "ping"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
test-kafka:
|
||
image: apache/kafka:3.7.0
|
||
environment:
|
||
KAFKA_NODE_ID: 1
|
||
KAFKA_PROCESS_ROLES: broker,controller
|
||
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
|
||
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://test-kafka:9092
|
||
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
|
||
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@test-kafka:9093
|
||
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
||
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
||
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
|
||
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
|
||
ports:
|
||
- "9093:9092"
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"]
|
||
interval: 10s
|
||
timeout: 10s
|
||
retries: 10
|
||
start_period: 30s
|
||
|
||
authorization-service-test:
|
||
build:
|
||
context: .
|
||
dockerfile: Dockerfile.test
|
||
environment:
|
||
DATABASE_URL: postgresql://test:test@test-db:5432/authorization_test
|
||
REDIS_HOST: test-redis
|
||
REDIS_PORT: 6379
|
||
KAFKA_BROKERS: test-kafka:9092
|
||
JWT_SECRET: test-jwt-secret-key
|
||
JWT_EXPIRES_IN: 1h
|
||
NODE_ENV: test
|
||
depends_on:
|
||
test-db:
|
||
condition: service_healthy
|
||
test-redis:
|
||
condition: service_healthy
|
||
test-kafka:
|
||
condition: service_healthy
|
||
volumes:
|
||
- ./coverage:/app/coverage
|
||
command: sh -c "npx prisma migrate deploy && npm run test:unit && npm run test:integration && npm run test:e2e"
|
||
|
||
networks:
|
||
default:
|
||
driver: bridge
|
||
```
|
||
|
||
### 测试 Dockerfile
|
||
|
||
```dockerfile
|
||
# Dockerfile.test
|
||
|
||
FROM node:20-alpine
|
||
|
||
WORKDIR /app
|
||
|
||
# Install OpenSSL for Prisma
|
||
RUN apk add --no-cache openssl openssl-dev libc6-compat
|
||
|
||
# Copy package files
|
||
COPY package*.json ./
|
||
COPY prisma ./prisma/
|
||
|
||
# Install dependencies
|
||
RUN npm ci
|
||
|
||
# Generate Prisma client
|
||
RUN npx prisma generate
|
||
|
||
# Copy source code and tests
|
||
COPY . .
|
||
|
||
# Default command
|
||
CMD ["npm", "run", "test:all"]
|
||
```
|
||
|
||
### 运行 Docker 测试
|
||
|
||
```bash
|
||
# 使用 Makefile
|
||
make test-docker-all
|
||
|
||
# 或直接使用 docker compose
|
||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
|
||
```
|
||
|
||
---
|
||
|
||
## 测试覆盖率
|
||
|
||
### 生成覆盖率报告
|
||
|
||
```bash
|
||
# 运行测试并生成覆盖率
|
||
npm run test:cov
|
||
|
||
# 覆盖率报告位置
|
||
coverage/
|
||
├── lcov-report/ # HTML 报告
|
||
│ └── index.html
|
||
├── lcov.info # LCOV 格式
|
||
└── coverage-summary.json
|
||
```
|
||
|
||
### 覆盖率目标
|
||
|
||
| 指标 | 目标 | 说明 |
|
||
|------|------|------|
|
||
| 行覆盖率 | ≥80% | 代码行被执行的比例 |
|
||
| 分支覆盖率 | ≥70% | 条件分支被覆盖的比例 |
|
||
| 函数覆盖率 | ≥80% | 函数被调用的比例 |
|
||
|
||
---
|
||
|
||
## CI/CD 集成
|
||
|
||
### 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: authorization_test
|
||
ports:
|
||
- 5432:5432
|
||
options: >-
|
||
--health-cmd pg_isready
|
||
--health-interval 10s
|
||
--health-timeout 5s
|
||
--health-retries 5
|
||
|
||
redis:
|
||
image: redis:7-alpine
|
||
ports:
|
||
- 6379:6379
|
||
|
||
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: Run migrations
|
||
run: npx prisma migrate deploy
|
||
env:
|
||
DATABASE_URL: postgresql://test:test@localhost:5432/authorization_test
|
||
|
||
- name: Run unit tests
|
||
run: npm run test:unit
|
||
|
||
- name: Run integration tests
|
||
run: npm run test:integration
|
||
env:
|
||
DATABASE_URL: postgresql://test:test@localhost:5432/authorization_test
|
||
REDIS_HOST: localhost
|
||
REDIS_PORT: 6379
|
||
|
||
- name: Upload coverage
|
||
uses: codecov/codecov-action@v3
|
||
with:
|
||
files: ./coverage/lcov.info
|
||
```
|
||
|
||
---
|
||
|
||
## 手动测试指南
|
||
|
||
### 使用 curl 测试 API
|
||
|
||
```bash
|
||
# 获取 JWT Token(假设从 Identity Service)
|
||
TOKEN="eyJhbGciOiJIUzI1NiIs..."
|
||
|
||
# 获取我的授权
|
||
curl -X GET http://localhost:3002/api/v1/authorizations/my \
|
||
-H "Authorization: Bearer $TOKEN"
|
||
|
||
# 申请省代授权
|
||
curl -X POST http://localhost:3002/api/v1/authorizations/province \
|
||
-H "Authorization: Bearer $TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"provinceCode": "430000",
|
||
"provinceName": "湖南省"
|
||
}'
|
||
|
||
# 管理员审核授权
|
||
curl -X POST http://localhost:3002/api/v1/admin/authorizations/auth-123/review \
|
||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"approved": true,
|
||
"reason": "审核通过"
|
||
}'
|
||
```
|
||
|
||
### 使用 REST Client (VSCode)
|
||
|
||
创建 `.http` 文件:
|
||
|
||
```http
|
||
### Variables
|
||
@baseUrl = http://localhost:3002/api/v1
|
||
@token = your-jwt-token
|
||
|
||
### Get my authorizations
|
||
GET {{baseUrl}}/authorizations/my
|
||
Authorization: Bearer {{token}}
|
||
|
||
### Apply for province authorization
|
||
POST {{baseUrl}}/authorizations/province
|
||
Authorization: Bearer {{token}}
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"provinceCode": "430000",
|
||
"provinceName": "湖南省"
|
||
}
|
||
|
||
### Admin: Review authorization
|
||
POST {{baseUrl}}/admin/authorizations/auth-123/review
|
||
Authorization: Bearer {{adminToken}}
|
||
Content-Type: application/json
|
||
|
||
{
|
||
"approved": true,
|
||
"reason": "审核通过"
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 测试最佳实践
|
||
|
||
### 1. 遵循 AAA 模式
|
||
|
||
```typescript
|
||
it('should do something', () => {
|
||
// Arrange - 准备测试数据
|
||
const input = createTestInput()
|
||
|
||
// Act - 执行被测试的操作
|
||
const result = service.doSomething(input)
|
||
|
||
// Assert - 验证结果
|
||
expect(result).toBe(expected)
|
||
})
|
||
```
|
||
|
||
### 2. 使用描述性测试名称
|
||
|
||
```typescript
|
||
// Good
|
||
it('should return failure when user already has province authorization')
|
||
|
||
// Bad
|
||
it('test validation')
|
||
```
|
||
|
||
### 3. 隔离测试
|
||
|
||
- 每个测试应该独立运行
|
||
- 使用 `beforeEach` 重置状态
|
||
- 避免测试之间的依赖
|
||
|
||
### 4. Mock 外部依赖
|
||
|
||
```typescript
|
||
const mockRepository: jest.Mocked<IRepository> = {
|
||
find: jest.fn(),
|
||
save: jest.fn(),
|
||
}
|
||
```
|
||
|
||
### 5. 测试边界条件
|
||
|
||
- 空值处理
|
||
- 边界值
|
||
- 异常情况
|