Files
junhong_cmp_fiber/tests/unit/task_handler_test.go
huang 984ccccc63 docs(constitution): 新增数据库设计原则(v2.4.0)
在项目宪章中新增第九条原则"数据库设计原则",明确禁止使用数据库外键约束和ORM关联标签。

主要变更:
- 新增原则IX:数据库设计原则(Database Design Principles)
- 强制要求:数据库表不得使用外键约束
- 强制要求:GORM模型不得使用ORM关联标签(foreignKey、hasMany等)
- 强制要求:表关系必须通过ID字段手动维护
- 强制要求:关联数据查询必须显式编写,避免ORM魔法
- 强制要求:时间字段由GORM处理,不使用数据库触发器

设计理念:
- 提升业务逻辑灵活性(无数据库约束限制)
- 优化高并发性能(无外键检查开销)
- 增强代码可读性(显式查询,无隐式预加载)
- 简化数据库架构和迁移流程
- 支持分布式和微服务场景

版本升级:2.3.0 → 2.4.0(MINOR)
2025-11-13 13:40:19 +08:00

391 lines
9.0 KiB
Go

package unit
import (
"context"
"testing"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// MockEmailPayload 邮件任务载荷(测试用)
type MockEmailPayload struct {
RequestID string `json:"request_id"`
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
CC []string `json:"cc,omitempty"`
}
// TestHandlerIdempotency 测试处理器幂等性逻辑
func TestHandlerIdempotency(t *testing.T) {
redisClient := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer redisClient.Close()
ctx := context.Background()
redisClient.FlushDB(ctx)
requestID := "test-req-001"
lockKey := constants.RedisTaskLockKey(requestID)
// 测试场景1: 第一次执行(未加锁)
t.Run("First Execution - Should Acquire Lock", func(t *testing.T) {
result, err := redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
require.NoError(t, err)
assert.True(t, result, "第一次执行应该成功获取锁")
})
// 测试场景2: 重复执行(已加锁)
t.Run("Duplicate Execution - Should Skip", func(t *testing.T) {
result, err := redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
require.NoError(t, err)
assert.False(t, result, "重复执行应该跳过(锁已存在)")
})
// 清理
redisClient.Del(ctx, lockKey)
}
// TestHandlerErrorHandling 测试处理器错误处理
func TestHandlerErrorHandling(t *testing.T) {
tests := []struct {
name string
payload MockEmailPayload
shouldError bool
errorMsg string
}{
{
name: "Valid Payload",
payload: MockEmailPayload{
RequestID: "valid-001",
To: "test@example.com",
Subject: "Test",
Body: "Test Body",
},
shouldError: false,
},
{
name: "Missing RequestID",
payload: MockEmailPayload{
RequestID: "",
To: "test@example.com",
Subject: "Test",
Body: "Test Body",
},
shouldError: true,
errorMsg: "request_id 不能为空",
},
{
name: "Missing To",
payload: MockEmailPayload{
RequestID: "test-002",
To: "",
Subject: "Test",
Body: "Test Body",
},
shouldError: true,
errorMsg: "收件人不能为空",
},
{
name: "Invalid Email Format",
payload: MockEmailPayload{
RequestID: "test-003",
To: "invalid-email",
Subject: "Test",
Body: "Test Body",
},
shouldError: true,
errorMsg: "邮箱格式无效",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证载荷
err := validateEmailPayload(&tt.payload)
if tt.shouldError {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg)
} else {
require.NoError(t, err)
}
})
}
}
// validateEmailPayload 验证邮件载荷(模拟实际处理器中的验证逻辑)
func validateEmailPayload(payload *MockEmailPayload) error {
if payload.RequestID == "" {
return asynq.SkipRetry // 参数错误不重试
}
if payload.To == "" {
return asynq.SkipRetry
}
// 简单的邮箱格式验证
if payload.To != "" && !contains(payload.To, "@") {
return asynq.SkipRetry
}
return nil
}
func contains(s, substr string) bool {
for i := 0; i < len(s)-len(substr)+1; i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// TestHandlerRetryLogic 测试重试逻辑
func TestHandlerRetryLogic(t *testing.T) {
tests := []struct {
name string
error error
shouldRetry bool
}{
{
name: "Retryable Error - Network Issue",
error: assert.AnError,
shouldRetry: true,
},
{
name: "Non-Retryable Error - Invalid Params",
error: asynq.SkipRetry,
shouldRetry: false,
},
{
name: "No Error",
error: nil,
shouldRetry: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldRetry := tt.error != nil && tt.error != asynq.SkipRetry
assert.Equal(t, tt.shouldRetry, shouldRetry)
})
}
}
// TestPayloadDeserialization 测试载荷反序列化
func TestPayloadDeserialization(t *testing.T) {
tests := []struct {
name string
jsonPayload string
expectError bool
}{
{
name: "Valid JSON",
jsonPayload: `{"request_id":"test-001","to":"test@example.com","subject":"Test","body":"Body"}`,
expectError: false,
},
{
name: "Invalid JSON",
jsonPayload: `{invalid json}`,
expectError: true,
},
{
name: "Empty JSON",
jsonPayload: `{}`,
expectError: false, // JSON 解析成功,但验证会失败
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var payload MockEmailPayload
err := sonic.Unmarshal([]byte(tt.jsonPayload), &payload)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
// TestTaskStatusTransition 测试任务状态转换
func TestTaskStatusTransition(t *testing.T) {
redisClient := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer redisClient.Close()
ctx := context.Background()
redisClient.FlushDB(ctx)
taskID := "task-transition-001"
statusKey := constants.RedisTaskStatusKey(taskID)
// 状态转换序列
transitions := []struct {
status string
valid bool
}{
{"pending", true},
{"processing", true},
{"completed", true},
{"failed", false}, // completed 后不应该转到 failed
}
currentStatus := ""
for _, tr := range transitions {
t.Run("Transition to "+tr.status, func(t *testing.T) {
// 检查状态转换是否合法
if isValidTransition(currentStatus, tr.status) == tr.valid {
err := redisClient.Set(ctx, statusKey, tr.status, 7*24*time.Hour).Err()
require.NoError(t, err)
currentStatus = tr.status
} else {
// 不合法的转换应该被拒绝
assert.False(t, tr.valid)
}
})
}
}
// isValidTransition 检查状态转换是否合法
func isValidTransition(from, to string) bool {
validTransitions := map[string][]string{
"": {"pending"},
"pending": {"processing", "failed"},
"processing": {"completed", "failed"},
"completed": {}, // 终态
"failed": {}, // 终态
}
allowed, exists := validTransitions[from]
if !exists {
return false
}
for _, valid := range allowed {
if valid == to {
return true
}
}
return false
}
// TestConcurrentTaskExecution 测试并发任务执行
func TestConcurrentTaskExecution(t *testing.T) {
redisClient := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer redisClient.Close()
ctx := context.Background()
redisClient.FlushDB(ctx)
// 模拟多个并发任务尝试获取同一个锁
requestID := "concurrent-test-001"
lockKey := constants.RedisTaskLockKey(requestID)
concurrency := 10
successCount := 0
done := make(chan bool, concurrency)
// 并发执行
for i := 0; i < concurrency; i++ {
go func() {
result, err := redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
if err == nil && result {
successCount++
}
done <- true
}()
}
// 等待所有 goroutine 完成
for i := 0; i < concurrency; i++ {
<-done
}
// 验证只有一个成功获取锁
assert.Equal(t, 1, successCount, "只有一个任务应该成功获取锁")
}
// TestTaskTimeout 测试任务超时处理
func TestTaskTimeout(t *testing.T) {
tests := []struct {
name string
taskDuration time.Duration
timeout time.Duration
shouldTimeout bool
}{
{
name: "Normal Execution",
taskDuration: 100 * time.Millisecond,
timeout: 1 * time.Second,
shouldTimeout: false,
},
{
name: "Timeout Execution",
taskDuration: 2 * time.Second,
timeout: 500 * time.Millisecond,
shouldTimeout: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), tt.timeout)
defer cancel()
// 模拟任务执行
done := make(chan bool)
go func() {
time.Sleep(tt.taskDuration)
done <- true
}()
select {
case <-done:
assert.False(t, tt.shouldTimeout, "任务应该正常完成")
case <-ctx.Done():
assert.True(t, tt.shouldTimeout, "任务应该超时")
}
})
}
}
// TestLockExpiration 测试锁过期机制
func TestLockExpiration(t *testing.T) {
redisClient := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
defer redisClient.Close()
ctx := context.Background()
redisClient.FlushDB(ctx)
requestID := "expiration-test-001"
lockKey := constants.RedisTaskLockKey(requestID)
// 设置短 TTL 的锁
result, err := redisClient.SetNX(ctx, lockKey, "1", 100*time.Millisecond).Result()
require.NoError(t, err)
assert.True(t, result)
// 等待锁过期
time.Sleep(200 * time.Millisecond)
// 验证锁已过期,可以重新获取
result, err = redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
require.NoError(t, err)
assert.True(t, result, "锁过期后应该可以重新获取")
}