feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)

## Server Testing Utils
- Add TestServer, TestClient, MockRoom for unit testing
- Export testing utilities from @esengine/server/testing

## Transaction Storage (BREAKING)
- Simplify RedisStorage/MongoStorage to factory pattern only
- Remove DI client injection option
- Add lazy connection and Symbol.asyncDispose support
- Add 161 unit tests with full coverage

## Pathfinding Tests
- Add 150 unit tests covering all components
- BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother

## Docs
- Update storage.md for new factory pattern API
This commit is contained in:
YHH
2025-12-29 15:02:13 +08:00
committed by GitHub
parent 10c3891abd
commit 3b978384c7
50 changed files with 7591 additions and 660 deletions

View File

@@ -0,0 +1,574 @@
/**
* @zh SagaOrchestrator 单元测试
* @en SagaOrchestrator unit tests
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
SagaOrchestrator,
createSagaOrchestrator,
type SagaStep,
type SagaLog,
} from '../../src/distributed/SagaOrchestrator.js'
import { MemoryStorage } from '../../src/storage/MemoryStorage.js'
// ============================================================================
// Test Suite | 测试套件
// ============================================================================
describe('SagaOrchestrator', () => {
let storage: MemoryStorage
let orchestrator: SagaOrchestrator
beforeEach(() => {
storage = new MemoryStorage()
orchestrator = new SagaOrchestrator({
storage,
timeout: 5000,
serverId: 'test-server',
})
})
// ========================================================================
// 构造器测试 | Constructor Tests
// ========================================================================
describe('Constructor', () => {
it('should create with default config', () => {
const defaultOrchestrator = new SagaOrchestrator()
expect(defaultOrchestrator).toBeDefined()
})
it('should create with custom config', () => {
const customOrchestrator = new SagaOrchestrator({
storage,
timeout: 10000,
serverId: 'custom-server',
})
expect(customOrchestrator).toBeDefined()
})
it('should use createSagaOrchestrator factory', () => {
const factoryOrchestrator = createSagaOrchestrator({ serverId: 'factory-server' })
expect(factoryOrchestrator).toBeInstanceOf(SagaOrchestrator)
})
})
// ========================================================================
// 成功执行测试 | Successful Execution Tests
// ========================================================================
describe('execute() - success', () => {
it('should execute single step saga', async () => {
const executeLog: string[] = []
const steps: SagaStep<{ value: number }>[] = [
{
name: 'step1',
execute: async (data) => {
executeLog.push(`execute:${data.value}`)
return { success: true }
},
compensate: async (data) => {
executeLog.push(`compensate:${data.value}`)
},
data: { value: 1 },
},
]
const result = await orchestrator.execute(steps)
expect(result.success).toBe(true)
expect(result.sagaId).toMatch(/^saga_/)
expect(result.completedSteps).toEqual(['step1'])
expect(result.duration).toBeGreaterThanOrEqual(0)
expect(executeLog).toEqual(['execute:1'])
})
it('should execute multi-step saga', async () => {
const executeLog: string[] = []
const steps: SagaStep<{ name: string }>[] = [
{
name: 'step1',
execute: async (data) => {
executeLog.push(`execute:${data.name}`)
return { success: true }
},
compensate: async (data) => {
executeLog.push(`compensate:${data.name}`)
},
data: { name: 'A' },
},
{
name: 'step2',
execute: async (data) => {
executeLog.push(`execute:${data.name}`)
return { success: true }
},
compensate: async (data) => {
executeLog.push(`compensate:${data.name}`)
},
data: { name: 'B' },
},
{
name: 'step3',
execute: async (data) => {
executeLog.push(`execute:${data.name}`)
return { success: true }
},
compensate: async (data) => {
executeLog.push(`compensate:${data.name}`)
},
data: { name: 'C' },
},
]
const result = await orchestrator.execute(steps)
expect(result.success).toBe(true)
expect(result.completedSteps).toEqual(['step1', 'step2', 'step3'])
expect(executeLog).toEqual(['execute:A', 'execute:B', 'execute:C'])
})
it('should save saga log on success', async () => {
const steps: SagaStep<{}>[] = [
{
name: 'step1',
execute: async () => ({ success: true }),
compensate: async () => {},
data: {},
},
]
const result = await orchestrator.execute(steps)
const log = await orchestrator.getSagaLog(result.sagaId)
expect(log).not.toBeNull()
expect(log?.state).toBe('completed')
expect(log?.steps[0].state).toBe('completed')
})
})
// ========================================================================
// 失败和补偿测试 | Failure and Compensation Tests
// ========================================================================
describe('execute() - failure and compensation', () => {
it('should compensate on step failure', async () => {
const executeLog: string[] = []
const steps: SagaStep<{ name: string }>[] = [
{
name: 'step1',
execute: async (data) => {
executeLog.push(`execute:${data.name}`)
return { success: true }
},
compensate: async (data) => {
executeLog.push(`compensate:${data.name}`)
},
data: { name: 'A' },
},
{
name: 'step2',
execute: async (data) => {
executeLog.push(`execute:${data.name}`)
return { success: true }
},
compensate: async (data) => {
executeLog.push(`compensate:${data.name}`)
},
data: { name: 'B' },
},
{
name: 'step3',
execute: async () => {
return { success: false, error: 'Step 3 failed' }
},
compensate: async () => {},
data: { name: 'C' },
},
]
const result = await orchestrator.execute(steps)
expect(result.success).toBe(false)
expect(result.failedStep).toBe('step3')
expect(result.error).toBe('Step 3 failed')
expect(result.completedSteps).toEqual(['step1', 'step2'])
// Compensation should be in reverse order
expect(executeLog).toEqual([
'execute:A',
'execute:B',
'compensate:B',
'compensate:A',
])
})
it('should save saga log on failure', async () => {
const steps: SagaStep<{}>[] = [
{
name: 'step1',
execute: async () => ({ success: true }),
compensate: async () => {},
data: {},
},
{
name: 'step2',
execute: async () => ({ success: false, error: 'Failed' }),
compensate: async () => {},
data: {},
},
]
const result = await orchestrator.execute(steps)
const log = await orchestrator.getSagaLog(result.sagaId)
expect(log?.state).toBe('compensated')
expect(log?.steps[0].state).toBe('compensated')
expect(log?.steps[1].state).toBe('failed')
})
it('should handle compensation error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const steps: SagaStep<{}>[] = [
{
name: 'step1',
execute: async () => ({ success: true }),
compensate: async () => {
throw new Error('Compensation failed')
},
data: {},
},
{
name: 'step2',
execute: async () => ({ success: false, error: 'Failed' }),
compensate: async () => {},
data: {},
},
]
const result = await orchestrator.execute(steps)
expect(result.success).toBe(false)
const log = await orchestrator.getSagaLog(result.sagaId)
expect(log?.steps[0].state).toBe('failed')
expect(log?.steps[0].error).toContain('Compensation failed')
consoleSpy.mockRestore()
})
})
// ========================================================================
// 超时测试 | Timeout Tests
// ========================================================================
describe('timeout', () => {
it('should timeout on slow saga', async () => {
const fastOrchestrator = new SagaOrchestrator({
storage,
timeout: 50, // 50ms timeout
})
// Timeout is checked between steps, so we need 2 steps
const steps: SagaStep<{}>[] = [
{
name: 'slow-step',
execute: async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
return { success: true }
},
compensate: async () => {},
data: {},
},
{
name: 'second-step',
execute: async () => {
return { success: true }
},
compensate: async () => {},
data: {},
},
]
const result = await fastOrchestrator.execute(steps)
expect(result.success).toBe(false)
expect(result.error).toContain('timed out')
})
})
// ========================================================================
// 分布式服务器测试 | Distributed Server Tests
// ========================================================================
describe('distributed servers', () => {
it('should track serverId for each step', async () => {
const steps: SagaStep<{}>[] = [
{
name: 'step1',
serverId: 'server-1',
execute: async () => ({ success: true }),
compensate: async () => {},
data: {},
},
{
name: 'step2',
serverId: 'server-2',
execute: async () => ({ success: true }),
compensate: async () => {},
data: {},
},
]
const result = await orchestrator.execute(steps)
const log = await orchestrator.getSagaLog(result.sagaId)
expect(log?.steps[0].serverId).toBe('server-1')
expect(log?.steps[1].serverId).toBe('server-2')
})
it('should include orchestrator serverId in metadata', async () => {
const steps: SagaStep<{}>[] = [
{
name: 'step1',
execute: async () => ({ success: true }),
compensate: async () => {},
data: {},
},
]
const result = await orchestrator.execute(steps)
const log = await orchestrator.getSagaLog(result.sagaId)
expect(log?.metadata?.orchestratorServerId).toBe('test-server')
})
})
// ========================================================================
// getSagaLog 测试 | getSagaLog Tests
// ========================================================================
describe('getSagaLog()', () => {
it('should return saga log by id', async () => {
const steps: SagaStep<{}>[] = [
{
name: 'step1',
execute: async () => ({ success: true }),
compensate: async () => {},
data: {},
},
]
const result = await orchestrator.execute(steps)
const log = await orchestrator.getSagaLog(result.sagaId)
expect(log).not.toBeNull()
expect(log?.id).toBe(result.sagaId)
})
it('should return null for non-existent saga', async () => {
const log = await orchestrator.getSagaLog('non-existent')
expect(log).toBeNull()
})
it('should return null without storage', async () => {
const noStorageOrchestrator = new SagaOrchestrator()
const log = await noStorageOrchestrator.getSagaLog('any-id')
expect(log).toBeNull()
})
})
// ========================================================================
// 恢复测试 | Recovery Tests
// ========================================================================
describe('recover()', () => {
it('should return 0 without storage', async () => {
const noStorageOrchestrator = new SagaOrchestrator()
const count = await noStorageOrchestrator.recover()
expect(count).toBe(0)
})
it('should return 0 when no pending sagas', async () => {
const count = await orchestrator.recover()
expect(count).toBe(0)
})
})
// ========================================================================
// 边界情况测试 | Edge Cases
// ========================================================================
describe('Edge Cases', () => {
it('should handle empty steps', async () => {
const result = await orchestrator.execute([])
expect(result.success).toBe(true)
expect(result.completedSteps).toEqual([])
})
it('should handle execute throwing exception', async () => {
const steps: SagaStep<{}>[] = [
{
name: 'throwing-step',
execute: async () => {
throw new Error('Unexpected error')
},
compensate: async () => {},
data: {},
},
]
const result = await orchestrator.execute(steps)
expect(result.success).toBe(false)
expect(result.error).toBe('Unexpected error')
})
it('should work without storage', async () => {
const noStorageOrchestrator = new SagaOrchestrator()
const steps: SagaStep<{}>[] = [
{
name: 'step1',
execute: async () => ({ success: true }),
compensate: async () => {},
data: {},
},
]
const result = await noStorageOrchestrator.execute(steps)
expect(result.success).toBe(true)
})
it('should track step timing', async () => {
const steps: SagaStep<{}>[] = [
{
name: 'step1',
execute: async () => {
await new Promise((resolve) => setTimeout(resolve, 50))
return { success: true }
},
compensate: async () => {},
data: {},
},
]
const result = await orchestrator.execute(steps)
const log = await orchestrator.getSagaLog(result.sagaId)
expect(log?.steps[0].startedAt).toBeGreaterThan(0)
expect(log?.steps[0].completedAt).toBeGreaterThan(log?.steps[0].startedAt!)
})
})
// ========================================================================
// 实际场景测试 | Real World Scenario Tests
// ========================================================================
describe('Real World Scenarios', () => {
it('should handle distributed purchase flow', async () => {
const inventory: Map<string, number> = new Map()
const wallet: Map<string, number> = new Map()
inventory.set('item-1', 10)
wallet.set('player-1', 1000)
const steps: SagaStep<{ playerId: string; itemId: string; price: number }>[] = [
{
name: 'deduct_currency',
serverId: 'wallet-server',
execute: async (data) => {
const balance = wallet.get(data.playerId) ?? 0
if (balance < data.price) {
return { success: false, error: 'Insufficient balance' }
}
wallet.set(data.playerId, balance - data.price)
return { success: true }
},
compensate: async (data) => {
const balance = wallet.get(data.playerId) ?? 0
wallet.set(data.playerId, balance + data.price)
},
data: { playerId: 'player-1', itemId: 'item-1', price: 100 },
},
{
name: 'reserve_item',
serverId: 'inventory-server',
execute: async (data) => {
const stock = inventory.get(data.itemId) ?? 0
if (stock < 1) {
return { success: false, error: 'Out of stock' }
}
inventory.set(data.itemId, stock - 1)
return { success: true }
},
compensate: async (data) => {
const stock = inventory.get(data.itemId) ?? 0
inventory.set(data.itemId, stock + 1)
},
data: { playerId: 'player-1', itemId: 'item-1', price: 100 },
},
]
const result = await orchestrator.execute(steps)
expect(result.success).toBe(true)
expect(wallet.get('player-1')).toBe(900)
expect(inventory.get('item-1')).toBe(9)
})
it('should rollback distributed purchase on inventory failure', async () => {
const wallet: Map<string, number> = new Map()
const inventory: Map<string, number> = new Map()
wallet.set('player-1', 1000)
inventory.set('item-1', 0) // Out of stock
const steps: SagaStep<{ playerId: string; itemId: string; price: number }>[] = [
{
name: 'deduct_currency',
execute: async (data) => {
const balance = wallet.get(data.playerId) ?? 0
wallet.set(data.playerId, balance - data.price)
return { success: true }
},
compensate: async (data) => {
const balance = wallet.get(data.playerId) ?? 0
wallet.set(data.playerId, balance + data.price)
},
data: { playerId: 'player-1', itemId: 'item-1', price: 100 },
},
{
name: 'reserve_item',
execute: async (data) => {
const stock = inventory.get(data.itemId) ?? 0
if (stock < 1) {
return { success: false, error: 'Out of stock' }
}
inventory.set(data.itemId, stock - 1)
return { success: true }
},
compensate: async (data) => {
const stock = inventory.get(data.itemId) ?? 0
inventory.set(data.itemId, stock + 1)
},
data: { playerId: 'player-1', itemId: 'item-1', price: 100 },
},
]
const result = await orchestrator.execute(steps)
expect(result.success).toBe(false)
expect(result.error).toBe('Out of stock')
expect(wallet.get('player-1')).toBe(1000) // Restored
expect(inventory.get('item-1')).toBe(0) // Unchanged
})
})
})