test: complete Wave 2 - all entity factories + documentation

- tests/fixtures/factory.ts: Config/Database + 6 additional factories (547 lines total)
  - UserFactory (3 methods: createAdmin, createUserDefault, createGuest)
  - OrderFactory (2 methods: createOrder, createOrders)
  - MaterialFactory (2 methods: createMaterial, createMaterials)
  - ConfigFactory (1 method: createErpConfig)
  - DatabaseFactory (1 method: createDatabaseConfig)
  - ExtractResultFactory (2 methods)
  - CleanerResultFactory (2 methods)
  - AuditLogFactory (1 method)
  - UpdateReleaseFactory (1 method)
  - ProductionInputFactory (1 method)
  - ValidationErrorFactory (1 method)
  Total: 17 factory methods across 10 factory classes

- Test coverage:
  - user-factory.test.ts: 3 tests
  - order-material-factory.test.ts: 4 tests
  - config-factory.test.ts: 5 tests
  - other-factories.test.ts: 15 tests
  Total: 27 factory tests

- docs/TEST_FACTORY_USAGE.md: User guide with examples (152 lines)

All factories support overrides customization and follow the <=100 lines per factory constraint.
This commit is contained in:
Misaka
2026-04-04 20:23:45 +08:00
parent fb3dd43164
commit 7473f34485
4 changed files with 788 additions and 1 deletions

196
docs/TEST_FACTORY_USAGE.md Normal file
View File

@@ -0,0 +1,196 @@
# Test Factory 使用指南
Test Factory 提供测试数据工厂类,确保测试数据一致性和可维护性。
## 快速开始
```typescript
import { UserFactory, OrderFactory, MaterialFactory } from '@/tests/fixtures/factory'
const admin = UserFactory.createAdmin()
const user = UserFactory.createUserDefault()
const order = OrderFactory.createOrder()
const material = MaterialFactory.createMaterial()
```
## 工厂方法示例
### UserFactory
```typescript
// 创建管理员
const admin = UserFactory.createAdmin()
// { id: 'USR-...', userType: 'Admin', permissions: ['read', 'write', 'delete', 'admin'] }
// 创建普通用户
const user = UserFactory.createUserDefault()
// 创建访客
const guest = UserFactory.createGuest()
// 自定义字段
const custom = UserFactory.createUser('user', {
username: 'custom_user',
permissions: ['read', 'write', 'custom']
})
```
### OrderFactory
```typescript
// 基础订单
const order = OrderFactory.createOrder()
// { id: 'ORD-..., orderNumber: 'SC...', plannedQuantity: 100 }
// 批量创建
const orders = OrderFactory.createOrders(5)
// 自定义字段
const customOrder = OrderFactory.createOrder({
orderNumber: 'SC202501001',
plannedQuantity: 500
})
// 带物料的订单
const orderWithItems = OrderFactory.createOrder({
items: MaterialFactory.createMaterials(3)
})
// 批量创建相同配置
const batch = OrderFactory.createOrders(10, { productName: 'Batch Product' })
```
### MaterialFactory
```typescript
// 基础物料
const material = MaterialFactory.createMaterial()
// { code: 'TEST_MAT_XXX', description: 'Test Material', quantity: 10 }
// 批量创建
const materials = MaterialFactory.createMaterials(5)
// 自定义字段
const custom = MaterialFactory.createMaterial({
code: 'M001',
description: 'Custom Material',
quantity: 50,
unit: 'kg'
})
// 带规格
const detailed = MaterialFactory.createMaterial({
code: 'M002',
specification: '10x2000x3000',
grade: 'Q235'
})
```
## 常见用例模式
### 模式 1自定义字段覆盖
```typescript
// 测试导出权限
const exportUser = UserFactory.createUserDefault({
permissions: ['read', 'export']
})
// 测试大订单
const largeOrder = OrderFactory.createOrder({
plannedQuantity: 10000,
items: MaterialFactory.createMaterials(20)
})
```
### 模式 2批量创建关联数据
```typescript
const user = UserFactory.createUserDefault()
const orders = OrderFactory.createOrders(3, { creator: user.username })
```
### 模式 3测试边界条件
```typescript
const emptyOrder = OrderFactory.createOrder({ items: [] })
const zeroOrder = OrderFactory.createOrder({ plannedQuantity: 0 })
const readOnlyUser = UserFactory.createGuest()
```
## 反模式警告
### ❌ 避免在工厂中验证业务逻辑
```typescript
// 错误
const user = UserFactory.createAdmin({ permissions: [] })
// 正确:验证在测试中
const admin = UserFactory.createAdmin()
expect(admin.permissions).toContain('admin')
```
### ❌ 避免硬编码 ID
```typescript
// 错误
const order = OrderFactory.createOrder({ id: 'ORD-FIXED-123' })
// 正确
const order = OrderFactory.createOrder()
```
### ❌ 避免混合工厂职责
```typescript
// 错误
const order = OrderFactory.createOrder({
items: MaterialFactory.createMaterials(10).map((m) => ({
...m,
quantity: m.quantity * Math.random()
}))
})
// 正确
const order = OrderFactory.createOrder()
const materials = MaterialFactory.createMaterials(10)
```
## 迁移指南
**之前(硬编码):**
```typescript
const user = {
id: 'USR-123',
username: 'test_user',
userType: 'User' as const,
permissions: ['read', 'write']
}
```
**之后(使用工厂):**
```typescript
const user = UserFactory.createUserDefault({ username: 'test_user' })
```
**迁移步骤:**
1. 识别硬编码 - 查找测试中的字面量对象
2. 选择工厂 - UserFactory / OrderFactory / MaterialFactory
3. 替换调用 - 用 `createXxx()` 替换字面量
4. 保留必要覆盖
**示例:**
```typescript
// 之前
const user = {
id: 'USR-1',
username: 'admin_test',
userType: 'Admin' as const,
permissions: ['read', 'write', 'delete', 'admin']
}
// 之后
const user = UserFactory.createAdmin({ username: 'admin_test' })
```
---
**提示**:更多 API 细节查看 `tests/fixtures/factory.ts` 源码。

69
tests/fixtures/config-factory.test.ts vendored Normal file
View File

@@ -0,0 +1,69 @@
/**
* ConfigFactory and DatabaseFactory Unit Tests
*/
import { describe, it, expect } from 'vitest'
import { ConfigFactory, DatabaseFactory } from './factory'
describe('ConfigFactory', () => {
it('creates ERP config with default values', () => {
const config = ConfigFactory.createErpConfig()
expect(config.url).toBe('https://test-erp.example.com')
expect(config.username).toBe('test_user')
expect(config.password).toBe('test_password')
})
it('applies overrides to ERP config', () => {
const config = ConfigFactory.createErpConfig({
url: 'https://custom-erp.example.com',
username: 'admin',
password: 'secret123'
})
expect(config.url).toBe('https://custom-erp.example.com')
expect(config.username).toBe('admin')
expect(config.password).toBe('secret123')
})
})
describe('DatabaseFactory', () => {
it('creates MySQL config with default values', () => {
const config = DatabaseFactory.createDatabaseConfig('mysql')
expect(config.type).toBe('mysql')
expect(config.host).toBe('localhost')
expect(config.port).toBe(3306)
expect(config.database).toBe('test_db')
expect(config.username).toBe('test_user')
expect(config.password).toBe('test_password')
})
it('creates SQL Server config with correct port', () => {
const config = DatabaseFactory.createDatabaseConfig('sqlserver')
expect(config.type).toBe('sqlserver')
expect(config.host).toBe('localhost')
expect(config.port).toBe(1433)
expect(config.database).toBe('test_db')
expect(config.username).toBe('test_user')
expect(config.password).toBe('test_password')
})
it('applies overrides to database config', () => {
const config = DatabaseFactory.createDatabaseConfig('mysql', {
host: '192.168.1.100',
port: 3307,
database: 'production_db',
username: 'prod_user',
password: 'prod_password'
})
expect(config.type).toBe('mysql')
expect(config.host).toBe('192.168.1.100')
expect(config.port).toBe(3307)
expect(config.database).toBe('production_db')
expect(config.username).toBe('prod_user')
expect(config.password).toBe('prod_password')
})
})

View File

@@ -4,7 +4,14 @@
* Factory class for generating test data with consistent structure. * Factory class for generating test data with consistent structure.
*/ */
import type { TestUser, Order, Material } from './types' import type { TestUser, Order, Material, TestErpConfig, TestDatabaseConfig } from './types'
import type { ExtractorResult, ImportResult } from '../../src/main/types/extractor.types'
import type { CleanerResult, OrderCleanDetail } from '../../src/main/types/cleaner.types'
import type { AuditEntry } from '../../src/main/types/audit.types'
import { AuditAction, AuditStatus } from '../../src/main/types/audit.types'
import type { UpdateRelease } from '../../src/main/types/update.types'
import type { ValidationResult } from '../../src/main/types/validation.types'
import { ValidationError, VALIDATION_ERROR_CODES } from '../../src/main/types/errors'
/** /**
* User Factory - generates test user data * User Factory - generates test user data
@@ -215,3 +222,326 @@ export class MaterialFactory {
}) })
} }
} }
/**
* Config Factory
*
* Creates ERP configuration fixtures for testing.
*/
export class ConfigFactory {
/**
* Create an ERP configuration fixture
*
* @param overrides - Optional overrides to customize the configuration
* @returns A new TestErpConfig instance
*
* @example
* // Basic config with default values
* const config = ConfigFactory.createErpConfig()
*
* @example
* // Config with custom URL
* const config = ConfigFactory.createErpConfig({ url: 'https://custom-erp.example.com' })
*
* @example
* // Config with custom credentials
* const config = ConfigFactory.createErpConfig({ username: 'admin', password: 'secret' })
*/
static createErpConfig(overrides?: Partial<TestErpConfig>): TestErpConfig {
return {
url: 'https://test-erp.example.com',
username: 'test_user',
password: 'test_password',
...overrides
}
}
}
/**
* Database Factory
*
* Creates database configuration fixtures for testing.
*/
export class DatabaseFactory {
/**
* Create a database configuration fixture
*
* @param type - Database type ('mysql' or 'sqlserver'), defaults to 'mysql'
* @param overrides - Optional overrides to customize the configuration
* @returns A new TestDatabaseConfig instance
*
* @example
* // MySQL config with default values
* const config = DatabaseFactory.createDatabaseConfig()
*
* @example
* // SQL Server config
* const config = DatabaseFactory.createDatabaseConfig('sqlserver')
*
* @example
* // MySQL config with custom host
* const config = DatabaseFactory.createDatabaseConfig('mysql', { host: '192.168.1.100' })
*/
static createDatabaseConfig(
type: 'mysql' | 'sqlserver' = 'mysql',
overrides?: Partial<TestDatabaseConfig>
): TestDatabaseConfig {
return {
type,
host: 'localhost',
port: type === 'mysql' ? 3306 : 1433,
database: 'test_db',
username: 'test_user',
password: 'test_password',
...overrides
}
}
}
/**
* Extract Result Factory
*
* Creates ExtractorResult fixtures for testing data extraction.
*/
export class ExtractResultFactory {
/**
* Create an extract result fixture
*
* @param overrides - Optional overrides to customize the result
* @returns A new ExtractorResult instance
*
* @example
* // Basic extract result
* const result = ExtractResultFactory.createExtractResult()
*
* @example
* // Result with errors
* const result = ExtractResultFactory.createExtractResult({ errors: ['Network timeout'] })
*/
static createExtractResult(overrides?: Partial<ExtractorResult>): ExtractorResult {
const timestamp = Date.now()
return {
downloadedFiles: [`download_${timestamp}.xlsx`],
mergedFile: `merged_${timestamp}.xlsx`,
recordCount: 100,
errors: [],
orderRecordCounts: [{ orderNumber: `SC${timestamp}`, recordCount: 100 }],
...overrides
}
}
/**
* Create an import result fixture
*
* @param overrides - Optional overrides
* @returns A new ImportResult instance
*/
static createImportResult(overrides?: Partial<ImportResult>): ImportResult {
return {
success: true,
recordsRead: 100,
recordsDeleted: 5,
recordsImported: 95,
uniqueSourceNumbers: 10,
errors: [],
...overrides
}
}
}
/**
* Cleaner Result Factory
*
* Creates CleanerResult fixtures for testing material cleanup.
*/
export class CleanerResultFactory {
/**
* Create a cleaner result fixture
*
* @param overrides - Optional overrides to customize the result
* @returns A new CleanerResult instance
*
* @example
* // Basic cleaner result
* const result = CleanerResultFactory.createCleanerResult()
*
* @example
* // Result with retries
* const result = CleanerResultFactory.createCleanerResult({ retriedOrders: 2, successfulRetries: 1 })
*/
static createCleanerResult(overrides?: Partial<CleanerResult>): CleanerResult {
return {
ordersProcessed: 5,
materialsDeleted: 20,
materialsSkipped: 2,
errors: [],
details: [],
retriedOrders: 0,
successfulRetries: 0,
...overrides
}
}
/**
* Create an order clean detail fixture
*
* @param overrides - Optional overrides
* @returns A new OrderCleanDetail instance
*/
static createOrderCleanDetail(overrides?: Partial<OrderCleanDetail>): OrderCleanDetail {
return {
orderNumber: `SC${Date.now()}`,
materialsDeleted: 5,
materialsSkipped: 0,
errors: [],
skippedMaterials: [],
retryCount: 0,
...overrides
}
}
}
/**
* Audit Log Factory
*
* Creates AuditEntry fixtures for testing audit logging.
*/
export class AuditLogFactory {
/**
* Create an audit log entry fixture
*
* @param action - Audit action type
* @param status - Audit status
* @param overrides - Optional overrides
* @returns A new AuditEntry instance
*
* @example
* // Successful login audit
* const entry = AuditLogFactory.createAuditLog('LOGIN', 'SUCCESS')
*
* @example
* // Failed extract audit
* const entry = AuditLogFactory.createAuditLog('EXTRACT', 'FAILURE', { resource: 'Order SC123' })
*/
static createAuditLog(
action: AuditAction = AuditAction.LOGIN,
status: AuditStatus = AuditStatus.SUCCESS,
overrides?: Partial<AuditEntry>
): AuditEntry {
return {
timestamp: new Date(),
action,
userId: 'USR-001',
username: 'test_user',
computerName: 'TEST-PC',
appVersion: '1.0.0',
status,
...overrides
}
}
}
/**
* Update Release Factory
*
* Creates UpdateRelease fixtures for testing update mechanisms.
*/
export class UpdateReleaseFactory {
/**
* Create an update release fixture
*
* @param channel - Release channel ('stable' or 'preview')
* @param overrides - Optional overrides
* @returns A new UpdateRelease instance
*
* @example
* // Stable release
* const release = UpdateReleaseFactory.createUpdateRelease('stable')
*
* @example
* // Preview release with custom version
* const release = UpdateReleaseFactory.createUpdateRelease('preview', { version: '2.0.0-beta.1' })
*/
static createUpdateRelease(
channel: 'stable' | 'preview' = 'stable',
overrides?: Partial<UpdateRelease>
): UpdateRelease {
return {
version: '1.0.0',
channel,
artifactKey: `erputo-${channel}-v1.0.0.exe`,
sha256: 'abc123def456',
size: 52428800,
publishedAt: new Date().toISOString(),
changelogKey: 'CHANGELOG.md',
...overrides
}
}
}
/**
* Production Input Factory
*
* Creates ValidationResult fixtures for testing validation.
*/
export class ProductionInputFactory {
/**
* Create a validation result fixture
*
* @param overrides - Optional overrides
* @returns A new ValidationResult instance
*
* @example
* // Basic validation result
* const result = ProductionInputFactory.createValidationResult()
*
* @example
* // Marked for deletion
* const result = ProductionInputFactory.createValidationResult({ isMarkedForDeletion: true })
*/
static createValidationResult(overrides?: Partial<ValidationResult>): ValidationResult {
return {
materialName: 'Test Material',
materialCode: `MAT-${Date.now()}`,
specification: 'Standard Spec',
model: 'Model-A',
managerName: 'Test Manager',
isMarkedForDeletion: false,
...overrides
}
}
}
/**
* Validation Error Factory
*
* Creates ValidationError fixtures for testing error handling.
*/
export class ValidationErrorFactory {
/**
* Create a validation error fixture
*
* @param message - Error message
* @param code - Error code
* @param overrides - Optional overrides
* @returns A new ValidationError instance
*
* @example
* // Basic validation error
* const error = ValidationErrorFactory.createValidationError('Invalid input')
*
* @example
* // Error with specific code
* const error = ValidationErrorFactory.createValidationError(
* 'Missing required field',
* 'VAL_MISSING_REQUIRED'
* )
*/
static createValidationError(
message: string = 'Validation failed',
code: (typeof VALIDATION_ERROR_CODES)[keyof typeof VALIDATION_ERROR_CODES] = VALIDATION_ERROR_CODES.INVALID_INPUT,
cause?: Error
): ValidationError {
return new ValidationError(message, code, cause)
}
}

192
tests/fixtures/other-factories.test.ts vendored Normal file
View File

@@ -0,0 +1,192 @@
/**
* Other Factories Unit Tests
*
* Tests for ExtractResult, CleanerResult, AuditLog, UpdateRelease,
* ProductionInput, and ValidationError factories.
*/
import { describe, it, expect } from 'vitest'
import {
ExtractResultFactory,
CleanerResultFactory,
AuditLogFactory,
UpdateReleaseFactory,
ProductionInputFactory,
ValidationErrorFactory
} from './factory'
import { AuditAction, AuditStatus } from '../../src/main/types/audit.types'
import { VALIDATION_ERROR_CODES } from '../../src/main/types/errors'
describe('ExtractResultFactory', () => {
it('creates extract result with default values', () => {
const result = ExtractResultFactory.createExtractResult()
expect(result.downloadedFiles).toHaveLength(1)
expect(result.downloadedFiles[0]).toMatch(/download_\d+\.xlsx/)
expect(result.mergedFile).toMatch(/merged_\d+\.xlsx/)
expect(result.recordCount).toBe(100)
expect(result.errors).toEqual([])
expect(result.orderRecordCounts).toHaveLength(1)
})
it('creates extract result with overrides', () => {
const result = ExtractResultFactory.createExtractResult({
recordCount: 50,
errors: ['Network timeout']
})
expect(result.recordCount).toBe(50)
expect(result.errors).toEqual(['Network timeout'])
})
it('creates import result with default values', () => {
const importResult = ExtractResultFactory.createImportResult()
expect(importResult.success).toBe(true)
expect(importResult.recordsRead).toBe(100)
expect(importResult.recordsImported).toBe(95)
expect(importResult.errors).toEqual([])
})
})
describe('CleanerResultFactory', () => {
it('creates cleaner result with default values', () => {
const result = CleanerResultFactory.createCleanerResult()
expect(result.ordersProcessed).toBe(5)
expect(result.materialsDeleted).toBe(20)
expect(result.materialsSkipped).toBe(2)
expect(result.errors).toEqual([])
expect(result.details).toEqual([])
expect(result.retriedOrders).toBe(0)
})
it('creates cleaner result with overrides', () => {
const result = CleanerResultFactory.createCleanerResult({
ordersProcessed: 10,
materialsDeleted: 40,
retriedOrders: 2,
successfulRetries: 1
})
expect(result.ordersProcessed).toBe(10)
expect(result.materialsDeleted).toBe(40)
expect(result.retriedOrders).toBe(2)
expect(result.successfulRetries).toBe(1)
})
it('creates order clean detail with default values', () => {
const detail = CleanerResultFactory.createOrderCleanDetail()
expect(detail.orderNumber).toMatch(/SC\d+/)
expect(detail.materialsDeleted).toBe(5)
expect(detail.materialsSkipped).toBe(0)
expect(detail.errors).toEqual([])
expect(detail.skippedMaterials).toEqual([])
expect(detail.retryCount).toBe(0)
})
})
describe('AuditLogFactory', () => {
it('creates audit log with default values', () => {
const entry = AuditLogFactory.createAuditLog()
expect(entry.timestamp).toBeInstanceOf(Date)
expect(entry.action).toBe(AuditAction.LOGIN)
expect(entry.status).toBe(AuditStatus.SUCCESS)
expect(entry.userId).toBe('USR-001')
expect(entry.username).toBe('test_user')
expect(entry.computerName).toBe('TEST-PC')
expect(entry.appVersion).toBe('1.0.0')
})
it('creates audit log with custom action and status', () => {
const entry = AuditLogFactory.createAuditLog(AuditAction.EXTRACT, AuditStatus.FAILURE, {
userId: 'USR-999',
resource: 'Order SC123'
})
expect(entry.action).toBe(AuditAction.EXTRACT)
expect(entry.status).toBe(AuditStatus.FAILURE)
expect(entry.userId).toBe('USR-999')
expect(entry.resource).toBe('Order SC123')
})
})
describe('UpdateReleaseFactory', () => {
it('creates stable release with default values', () => {
const release = UpdateReleaseFactory.createUpdateRelease('stable')
expect(release.version).toBe('1.0.0')
expect(release.channel).toBe('stable')
expect(release.artifactKey).toMatch(/erputo-stable-v1\.0\.0\.exe/)
expect(release.size).toBe(52428800)
expect(release.publishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
})
it('creates preview release with overrides', () => {
const release = UpdateReleaseFactory.createUpdateRelease('preview', {
version: '2.0.0-beta.1',
size: 62914560
})
expect(release.channel).toBe('preview')
expect(release.version).toBe('2.0.0-beta.1')
expect(release.size).toBe(62914560)
})
})
describe('ProductionInputFactory', () => {
it('creates validation result with default values', () => {
const result = ProductionInputFactory.createValidationResult()
expect(result.materialName).toBe('Test Material')
expect(result.materialCode).toMatch(/MAT-\d+/)
expect(result.specification).toBe('Standard Spec')
expect(result.model).toBe('Model-A')
expect(result.managerName).toBe('Test Manager')
expect(result.isMarkedForDeletion).toBe(false)
})
it('creates validation result marked for deletion', () => {
const result = ProductionInputFactory.createValidationResult({
isMarkedForDeletion: true,
materialCode: 'MAT-999'
})
expect(result.isMarkedForDeletion).toBe(true)
expect(result.materialCode).toBe('MAT-999')
})
})
describe('ValidationErrorFactory', () => {
it('creates validation error with default values', () => {
const error = ValidationErrorFactory.createValidationError()
expect(error.name).toBe('ValidationError')
expect(error.message).toBe('Validation failed')
expect(error.code).toBe(VALIDATION_ERROR_CODES.INVALID_INPUT)
})
it('creates validation error with custom message and code', () => {
const error = ValidationErrorFactory.createValidationError(
'Missing required field',
VALIDATION_ERROR_CODES.MISSING_REQUIRED
)
expect(error.message).toBe('Missing required field')
expect(error.code).toBe(VALIDATION_ERROR_CODES.MISSING_REQUIRED)
})
it('creates validation error with cause', () => {
const cause = new Error('Underlying cause')
const error = ValidationErrorFactory.createValidationError(
'Invalid format',
VALIDATION_ERROR_CODES.INVALID_FORMAT,
cause
)
expect(error.cause).toBe(cause)
expect(error.message).toBe('Invalid format')
})
})