docs(constitution): 新增数据库设计原则(v2.4.0)
在项目宪章中新增第九条原则"数据库设计原则",明确禁止使用数据库外键约束和ORM关联标签。 主要变更: - 新增原则IX:数据库设计原则(Database Design Principles) - 强制要求:数据库表不得使用外键约束 - 强制要求:GORM模型不得使用ORM关联标签(foreignKey、hasMany等) - 强制要求:表关系必须通过ID字段手动维护 - 强制要求:关联数据查询必须显式编写,避免ORM魔法 - 强制要求:时间字段由GORM处理,不使用数据库触发器 设计理念: - 提升业务逻辑灵活性(无数据库约束限制) - 优化高并发性能(无外键检查开销) - 增强代码可读性(显式查询,无隐式预加载) - 简化数据库架构和迁移流程 - 支持分布式和微服务场景 版本升级:2.3.0 → 2.4.0(MINOR)
This commit is contained in:
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler"
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -64,7 +63,8 @@ func setupAuthTestApp(t *testing.T, rdb *redis.Client) *fiber.App {
|
||||
})
|
||||
})
|
||||
|
||||
app.Get("/api/v1/users", handler.GetUsers)
|
||||
// 注释:用户路由已移至实例方法,集成测试中使用测试路由即可
|
||||
// 实际的用户路由测试应在 cmd/api/main.go 中完整初始化
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
489
tests/integration/database_test.go
Normal file
489
tests/integration/database_test.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
testcontainers_postgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.uber.org/zap"
|
||||
postgresDriver "gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestMain 设置测试环境
|
||||
func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// setupTestDB 启动 PostgreSQL 容器并使用迁移脚本初始化数据库
|
||||
func setupTestDB(t *testing.T) (*postgres.Store, func()) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 启动 PostgreSQL 容器
|
||||
postgresContainer, err := testcontainers_postgres.RunContainer(ctx,
|
||||
testcontainers.WithImage("postgres:14-alpine"),
|
||||
testcontainers_postgres.WithDatabase("testdb"),
|
||||
testcontainers_postgres.WithUsername("postgres"),
|
||||
testcontainers_postgres.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(30*time.Second),
|
||||
),
|
||||
)
|
||||
require.NoError(t, err, "启动 PostgreSQL 容器失败")
|
||||
|
||||
// 获取连接字符串
|
||||
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err, "获取数据库连接字符串失败")
|
||||
|
||||
// 应用数据库迁移
|
||||
migrationsPath := getMigrationsPath(t)
|
||||
m, err := migrate.New(
|
||||
fmt.Sprintf("file://%s", migrationsPath),
|
||||
connStr,
|
||||
)
|
||||
require.NoError(t, err, "创建迁移实例失败")
|
||||
|
||||
// 执行向上迁移
|
||||
err = m.Up()
|
||||
require.NoError(t, err, "执行数据库迁移失败")
|
||||
|
||||
// 连接数据库
|
||||
db, err := gorm.Open(postgresDriver.Open(connStr), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err, "连接数据库失败")
|
||||
|
||||
// 创建测试 logger
|
||||
testLogger := zap.NewNop()
|
||||
store := postgres.NewStore(db, testLogger)
|
||||
|
||||
// 返回清理函数
|
||||
cleanup := func() {
|
||||
// 执行向下迁移清理数据
|
||||
if err := m.Down(); err != nil && err != migrate.ErrNoChange {
|
||||
t.Logf("清理迁移失败: %v", err)
|
||||
}
|
||||
m.Close()
|
||||
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
if err := postgresContainer.Terminate(ctx); err != nil {
|
||||
t.Logf("终止容器失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return store, cleanup
|
||||
}
|
||||
|
||||
// getMigrationsPath 获取迁移文件路径
|
||||
func getMigrationsPath(t *testing.T) string {
|
||||
// 获取项目根目录
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err, "获取工作目录失败")
|
||||
|
||||
// 从测试目录向上找到项目根目录
|
||||
migrationsPath := filepath.Join(wd, "..", "..", "migrations")
|
||||
|
||||
// 验证迁移目录存在
|
||||
_, err = os.Stat(migrationsPath)
|
||||
require.NoError(t, err, fmt.Sprintf("迁移目录不存在: %s", migrationsPath))
|
||||
|
||||
return migrationsPath
|
||||
}
|
||||
|
||||
// TestUserCRUD 测试用户 CRUD 操作
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("创建用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
err := store.User.Create(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, user.ID)
|
||||
assert.NotZero(t, user.CreatedAt)
|
||||
assert.NotZero(t, user.UpdatedAt)
|
||||
})
|
||||
|
||||
t.Run("根据ID查询用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "queryuser",
|
||||
Email: "query@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 查询用户
|
||||
found, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.Username, found.Username)
|
||||
assert.Equal(t, user.Email, found.Email)
|
||||
assert.Equal(t, constants.UserStatusActive, found.Status)
|
||||
})
|
||||
|
||||
t.Run("根据用户名查询用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "findbyname",
|
||||
Email: "findbyname@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 根据用户名查询
|
||||
found, err := store.User.GetByUsername(ctx, "findbyname")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.ID, found.ID)
|
||||
assert.Equal(t, user.Email, found.Email)
|
||||
})
|
||||
|
||||
t.Run("更新用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "updateuser",
|
||||
Email: "update@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新用户
|
||||
user.Email = "newemail@example.com"
|
||||
user.Status = constants.UserStatusInactive
|
||||
err = store.User.Update(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证更新
|
||||
found, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "newemail@example.com", found.Email)
|
||||
assert.Equal(t, constants.UserStatusInactive, found.Status)
|
||||
})
|
||||
|
||||
t.Run("列表查询用户", func(t *testing.T) {
|
||||
// 创建多个测试用户
|
||||
for i := 1; i <= 5; i++ {
|
||||
user := &model.User{
|
||||
Username: fmt.Sprintf("listuser%d", i),
|
||||
Email: fmt.Sprintf("list%d@example.com", i),
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
users, total, err := store.User.List(ctx, 1, 3)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(users), 3)
|
||||
assert.GreaterOrEqual(t, total, int64(5))
|
||||
})
|
||||
|
||||
t.Run("软删除用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "deleteuser",
|
||||
Email: "delete@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 软删除
|
||||
err = store.User.Delete(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证已删除(查询应该找不到)
|
||||
_, err = store.User.GetByID(ctx, user.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderCRUD 测试订单 CRUD 操作
|
||||
func TestOrderCRUD(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "orderuser",
|
||||
Email: "orderuser@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("创建订单", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
Remark: "测试订单",
|
||||
}
|
||||
|
||||
err := store.Order.Create(ctx, order)
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, order.ID)
|
||||
assert.NotZero(t, order.CreatedAt)
|
||||
})
|
||||
|
||||
t.Run("根据ID查询订单", func(t *testing.T) {
|
||||
// 创建测试订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-002",
|
||||
UserID: user.ID,
|
||||
Amount: 20000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 查询订单
|
||||
found, err := store.Order.GetByID(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, order.OrderID, found.OrderID)
|
||||
assert.Equal(t, order.Amount, found.Amount)
|
||||
})
|
||||
|
||||
t.Run("根据订单号查询", func(t *testing.T) {
|
||||
// 创建测试订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-003",
|
||||
UserID: user.ID,
|
||||
Amount: 30000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 根据订单号查询
|
||||
found, err := store.Order.GetByOrderID(ctx, "ORD-003")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, order.ID, found.ID)
|
||||
})
|
||||
|
||||
t.Run("根据用户ID列表查询", func(t *testing.T) {
|
||||
// 创建多个订单
|
||||
for i := 1; i <= 3; i++ {
|
||||
order := &model.Order{
|
||||
OrderID: fmt.Sprintf("ORD-USER-%d", i),
|
||||
UserID: user.ID,
|
||||
Amount: int64(i * 10000),
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 列表查询
|
||||
orders, total, err := store.Order.ListByUserID(ctx, user.ID, 1, 10)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(orders), 3)
|
||||
assert.GreaterOrEqual(t, total, int64(3))
|
||||
})
|
||||
|
||||
t.Run("更新订单状态", func(t *testing.T) {
|
||||
// 创建测试订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-UPDATE",
|
||||
UserID: user.ID,
|
||||
Amount: 50000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新状态
|
||||
now := time.Now()
|
||||
order.Status = constants.OrderStatusPaid
|
||||
order.PaidAt = &now
|
||||
err = store.Order.Update(ctx, order)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证更新
|
||||
found, err := store.Order.GetByID(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, constants.OrderStatusPaid, found.Status)
|
||||
assert.NotNil(t, found.PaidAt)
|
||||
})
|
||||
|
||||
t.Run("软删除订单", func(t *testing.T) {
|
||||
// 创建测试订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-DELETE",
|
||||
UserID: user.ID,
|
||||
Amount: 60000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 软删除
|
||||
err = store.Order.Delete(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证已删除
|
||||
_, err = store.Order.GetByID(ctx, order.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestTransaction 测试事务功能
|
||||
func TestTransaction(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("事务提交", func(t *testing.T) {
|
||||
var userID uint
|
||||
var orderID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: "txuser",
|
||||
Email: "txuser@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx.User.Create(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
userID = user.ID
|
||||
|
||||
// 创建订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-TX-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
if err := tx.Order.Create(ctx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
orderID = order.ID
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证用户和订单都已创建
|
||||
user, err := store.User.GetByID(ctx, userID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "txuser", user.Username)
|
||||
|
||||
order, err := store.Order.GetByID(ctx, orderID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ORD-TX-001", order.OrderID)
|
||||
})
|
||||
|
||||
t.Run("事务回滚", func(t *testing.T) {
|
||||
var userID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: "rollbackuser",
|
||||
Email: "rollback@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx.User.Create(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
userID = user.ID
|
||||
|
||||
// 模拟错误,触发回滚
|
||||
return fmt.Errorf("模拟错误")
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "模拟错误", err.Error())
|
||||
|
||||
// 验证用户未创建(已回滚)
|
||||
_, err = store.User.GetByID(ctx, userID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConcurrentOperations 测试并发操作
|
||||
func TestConcurrentOperations(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("并发创建用户", func(t *testing.T) {
|
||||
concurrency := 10
|
||||
errChan := make(chan error, concurrency)
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func(index int) {
|
||||
user := &model.User{
|
||||
Username: fmt.Sprintf("concurrent%d", index),
|
||||
Email: fmt.Sprintf("concurrent%d@example.com", index),
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
errChan <- store.User.Create(ctx, user)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 收集结果
|
||||
successCount := 0
|
||||
for i := 0; i < concurrency; i++ {
|
||||
err := <-errChan
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, concurrency, successCount, "所有并发创建应该成功")
|
||||
})
|
||||
}
|
||||
169
tests/integration/health_test.go
Normal file
169
tests/integration/health_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestHealthCheckNormal 测试健康检查 - 正常状态
|
||||
func TestHealthCheckNormal(t *testing.T) {
|
||||
// 初始化日志
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// 初始化内存数据库
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 初始化 Redis 客户端(使用本地 Redis)
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
DB: 0,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New()
|
||||
|
||||
// 创建健康检查处理器
|
||||
healthHandler := handler.NewHealthHandler(db, rdb, logger)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
|
||||
// 发送测试请求
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 验证响应状态码
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// 验证响应内容
|
||||
// 注意:这里可以进一步解析 JSON 响应体验证详细信息
|
||||
}
|
||||
|
||||
// TestHealthCheckDatabaseDown 测试健康检查 - 数据库异常
|
||||
func TestHealthCheckDatabaseDown(t *testing.T) {
|
||||
t.Skip("需要模拟数据库连接失败的场景")
|
||||
|
||||
// 初始化日志
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// 初始化一个会失败的数据库连接
|
||||
db, err := gorm.Open(sqlite.Open("/invalid/path/test.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
// 预期会失败
|
||||
t.Log("数据库连接失败(预期行为)")
|
||||
}
|
||||
|
||||
// 初始化 Redis 客户端
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
DB: 0,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New()
|
||||
|
||||
// 创建健康检查处理器
|
||||
healthHandler := handler.NewHealthHandler(db, rdb, logger)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
|
||||
// 发送测试请求
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 验证响应状态码应该是 503 (Service Unavailable)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestHealthCheckRedisDown 测试健康检查 - Redis 异常
|
||||
func TestHealthCheckRedisDown(t *testing.T) {
|
||||
// 初始化日志
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// 初始化内存数据库
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 初始化一个连接到无效地址的 Redis 客户端
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:9999", // 无效端口
|
||||
DB: 0,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New()
|
||||
|
||||
// 创建健康检查处理器
|
||||
healthHandler := handler.NewHealthHandler(db, rdb, logger)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
|
||||
// 发送测试请求
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 验证响应状态码应该是 503 (Service Unavailable)
|
||||
assert.Equal(t, 503, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestHealthCheckDetailed 测试健康检查 - 验证详细信息
|
||||
func TestHealthCheckDetailed(t *testing.T) {
|
||||
// 初始化日志
|
||||
logger, _ := zap.NewDevelopment()
|
||||
|
||||
// 初始化内存数据库
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 初始化 Redis 客户端
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
DB: 0,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
// 测试 Redis 连接
|
||||
ctx := context.Background()
|
||||
_, err = rdb.Ping(ctx).Result()
|
||||
if err != nil {
|
||||
t.Skip("Redis 未运行,跳过测试")
|
||||
}
|
||||
|
||||
// 创建 Fiber 应用
|
||||
app := fiber.New()
|
||||
|
||||
// 创建健康检查处理器
|
||||
healthHandler := handler.NewHealthHandler(db, rdb, logger)
|
||||
app.Get("/health", healthHandler.Check)
|
||||
|
||||
// 发送测试请求
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 验证响应状态码
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
// TODO: 解析 JSON 响应并验证包含以下字段:
|
||||
// - status: "healthy"
|
||||
// - postgres: "up"
|
||||
// - redis: "up"
|
||||
// - timestamp
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||
"github.com/google/uuid"
|
||||
@@ -115,7 +115,7 @@ func TestPanicRecovery(t *testing.T) {
|
||||
if tt.shouldPanic {
|
||||
// panic 应该返回统一错误响应
|
||||
var response response.Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ func TestSubsequentRequestsAfterPanic(t *testing.T) {
|
||||
|
||||
// 验证响应内容
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Request %d: failed to unmarshal response: %v", i, err)
|
||||
}
|
||||
|
||||
@@ -606,7 +606,7 @@ func TestRecoverMiddlewareOrder(t *testing.T) {
|
||||
// 解析响应,验证返回了统一错误格式
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var response response.Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
|
||||
312
tests/integration/task_test.go
Normal file
312
tests/integration/task_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package integration
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// EmailPayload 邮件任务载荷(测试用)
|
||||
type EmailPayload struct {
|
||||
RequestID string `json:"request_id"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
CC []string `json:"cc,omitempty"`
|
||||
}
|
||||
|
||||
// TestTaskSubmit 测试任务提交
|
||||
func TestTaskSubmit(t *testing.T) {
|
||||
// 创建 Redis 客户端
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
// 清理测试数据
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
// 创建 Asynq 客户端
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 构造任务载荷
|
||||
payload := &EmailPayload{
|
||||
RequestID: "test-request-001",
|
||||
To: "test@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "This is a test email",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提交任务
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task,
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
asynq.MaxRetry(constants.DefaultRetryMax),
|
||||
)
|
||||
|
||||
// 验证
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, info.ID)
|
||||
assert.Equal(t, constants.QueueDefault, info.Queue)
|
||||
assert.Equal(t, constants.DefaultRetryMax, info.MaxRetry)
|
||||
}
|
||||
|
||||
// TestTaskPriority 测试任务优先级
|
||||
func TestTaskPriority(t *testing.T) {
|
||||
// 创建 Redis 客户端
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
// 创建 Asynq 客户端
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
queue string
|
||||
}{
|
||||
{"Critical Priority", constants.QueueCritical},
|
||||
{"Default Priority", constants.QueueDefault},
|
||||
{"Low Priority", constants.QueueLow},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := &EmailPayload{
|
||||
RequestID: "test-request-" + tt.queue,
|
||||
To: "test@example.com",
|
||||
Subject: "Test",
|
||||
Body: "Test",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task, asynq.Queue(tt.queue))
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.queue, info.Queue)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskRetry 测试任务重试机制
|
||||
func TestTaskRetry(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := &EmailPayload{
|
||||
RequestID: "retry-test-001",
|
||||
To: "test@example.com",
|
||||
Subject: "Retry Test",
|
||||
Body: "Test retry mechanism",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提交任务并设置重试次数
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task,
|
||||
asynq.MaxRetry(3),
|
||||
asynq.Timeout(30*time.Second),
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 3, info.MaxRetry)
|
||||
assert.Equal(t, 30*time.Second, info.Timeout)
|
||||
}
|
||||
|
||||
// TestTaskIdempotency 测试任务幂等性键
|
||||
func TestTaskIdempotency(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
requestID := "idempotent-test-001"
|
||||
lockKey := constants.RedisTaskLockKey(requestID)
|
||||
|
||||
// 第一次设置锁(模拟任务开始执行)
|
||||
result, err := redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result, "第一次设置锁应该成功")
|
||||
|
||||
// 第二次设置锁(模拟重复任务)
|
||||
result, err = redisClient.SetNX(ctx, lockKey, "1", 24*time.Hour).Result()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result, "第二次设置锁应该失败(幂等性)")
|
||||
|
||||
// 验证锁存在
|
||||
exists, err := redisClient.Exists(ctx, lockKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), exists)
|
||||
|
||||
// 验证 TTL
|
||||
ttl, err := redisClient.TTL(ctx, lockKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, ttl.Hours(), 23.0)
|
||||
assert.LessOrEqual(t, ttl.Hours(), 24.0)
|
||||
}
|
||||
|
||||
// TestTaskStatusTracking 测试任务状态跟踪
|
||||
func TestTaskStatusTracking(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
taskID := "task-123456"
|
||||
statusKey := constants.RedisTaskStatusKey(taskID)
|
||||
|
||||
// 设置任务状态
|
||||
statuses := []string{"pending", "processing", "completed"}
|
||||
|
||||
for _, status := range statuses {
|
||||
err := redisClient.Set(ctx, statusKey, status, 7*24*time.Hour).Err()
|
||||
require.NoError(t, err)
|
||||
|
||||
// 读取状态
|
||||
result, err := redisClient.Get(ctx, statusKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, status, result)
|
||||
}
|
||||
|
||||
// 验证 TTL
|
||||
ttl, err := redisClient.TTL(ctx, statusKey).Result()
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, ttl.Hours(), 24.0*6)
|
||||
}
|
||||
|
||||
// TestQueueInspection 测试队列检查
|
||||
func TestQueueInspection(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 提交多个任务
|
||||
for i := 0; i < 5; i++ {
|
||||
payload := &EmailPayload{
|
||||
RequestID: "test-" + string(rune(i)),
|
||||
To: "test@example.com",
|
||||
Subject: "Test",
|
||||
Body: "Test",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
_, err = client.Enqueue(task, asynq.Queue(constants.QueueDefault))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 创建 Inspector 检查队列
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
// 获取队列信息
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, info.Pending)
|
||||
assert.Equal(t, 0, info.Active)
|
||||
}
|
||||
|
||||
// TestTaskSerialization 测试任务序列化
|
||||
func TestTaskSerialization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload EmailPayload
|
||||
}{
|
||||
{
|
||||
name: "Simple Email",
|
||||
payload: EmailPayload{
|
||||
RequestID: "req-001",
|
||||
To: "user@example.com",
|
||||
Subject: "Hello",
|
||||
Body: "Hello World",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Email with CC",
|
||||
payload: EmailPayload{
|
||||
RequestID: "req-002",
|
||||
To: "user@example.com",
|
||||
Subject: "Hello",
|
||||
Body: "Hello World",
|
||||
CC: []string{"cc1@example.com", "cc2@example.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 序列化
|
||||
payloadBytes, err := sonic.Marshal(tt.payload)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, payloadBytes)
|
||||
|
||||
// 反序列化
|
||||
var decoded EmailPayload
|
||||
err = sonic.Unmarshal(payloadBytes, &decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证
|
||||
assert.Equal(t, tt.payload.RequestID, decoded.RequestID)
|
||||
assert.Equal(t, tt.payload.To, decoded.To)
|
||||
assert.Equal(t, tt.payload.Subject, decoded.Subject)
|
||||
assert.Equal(t, tt.payload.Body, decoded.Body)
|
||||
assert.Equal(t, tt.payload.CC, decoded.CC)
|
||||
})
|
||||
}
|
||||
}
|
||||
502
tests/unit/model_test.go
Normal file
502
tests/unit/model_test.go
Normal file
@@ -0,0 +1,502 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestUserValidation 测试用户模型验证
|
||||
func TestUserValidation(t *testing.T) {
|
||||
validate := validator.New()
|
||||
|
||||
t.Run("有效的创建用户请求", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "validuser",
|
||||
Email: "valid@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("用户名太短", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "ab", // 少于 3 个字符
|
||||
Email: "valid@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("用户名太长", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "a123456789012345678901234567890123456789012345678901", // 超过 50 个字符
|
||||
Email: "valid@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("无效的邮箱格式", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "validuser",
|
||||
Email: "invalid-email",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("密码太短", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "validuser",
|
||||
Email: "valid@example.com",
|
||||
Password: "short", // 少于 8 个字符
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("缺少必填字段", func(t *testing.T) {
|
||||
req := &model.CreateUserRequest{
|
||||
Username: "validuser",
|
||||
// 缺少 Email 和 Password
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserUpdateValidation 测试用户更新验证
|
||||
func TestUserUpdateValidation(t *testing.T) {
|
||||
validate := validator.New()
|
||||
|
||||
t.Run("有效的更新请求", func(t *testing.T) {
|
||||
email := "newemail@example.com"
|
||||
status := constants.UserStatusActive
|
||||
req := &model.UpdateUserRequest{
|
||||
Email: &email,
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("无效的邮箱格式", func(t *testing.T) {
|
||||
email := "invalid-email"
|
||||
req := &model.UpdateUserRequest{
|
||||
Email: &email,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("无效的状态值", func(t *testing.T) {
|
||||
status := "invalid_status"
|
||||
req := &model.UpdateUserRequest{
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("空更新请求", func(t *testing.T) {
|
||||
req := &model.UpdateUserRequest{}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err) // 空更新请求应该是有效的
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderValidation 测试订单模型验证
|
||||
func TestOrderValidation(t *testing.T) {
|
||||
validate := validator.New()
|
||||
|
||||
t.Run("有效的创建订单请求", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Remark: "测试订单",
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("订单号太短", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-123", // 少于 10 个字符
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("订单号太长", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-12345678901234567890123456789012345678901234567890", // 超过 50 个字符
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("用户ID无效", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 0, // 用户ID必须大于0
|
||||
Amount: 10000,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("金额为负数", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: -1000, // 金额不能为负数
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("缺少必填字段", func(t *testing.T) {
|
||||
req := &model.CreateOrderRequest{
|
||||
OrderID: "ORD-2025-001",
|
||||
// 缺少 UserID 和 Amount
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderUpdateValidation 测试订单更新验证
|
||||
func TestOrderUpdateValidation(t *testing.T) {
|
||||
validate := validator.New()
|
||||
|
||||
t.Run("有效的更新请求", func(t *testing.T) {
|
||||
status := constants.OrderStatusPaid
|
||||
remark := "已支付"
|
||||
req := &model.UpdateOrderRequest{
|
||||
Status: &status,
|
||||
Remark: &remark,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("无效的状态值", func(t *testing.T) {
|
||||
status := "invalid_status"
|
||||
req := &model.UpdateOrderRequest{
|
||||
Status: &status,
|
||||
}
|
||||
|
||||
err := validate.Struct(req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserModel 测试用户模型
|
||||
func TestUserModel(t *testing.T) {
|
||||
t.Run("创建用户模型", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
assert.Equal(t, "testuser", user.Username)
|
||||
assert.Equal(t, "test@example.com", user.Email)
|
||||
assert.Equal(t, constants.UserStatusActive, user.Status)
|
||||
})
|
||||
|
||||
t.Run("用户表名", func(t *testing.T) {
|
||||
user := &model.User{}
|
||||
assert.Equal(t, "tb_user", user.TableName())
|
||||
})
|
||||
|
||||
t.Run("软删除字段", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
// DeletedAt 应该是 nil (未删除)
|
||||
assert.True(t, user.DeletedAt.Time.IsZero())
|
||||
})
|
||||
|
||||
t.Run("LastLoginAt 可选字段", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
assert.Nil(t, user.LastLoginAt)
|
||||
|
||||
// 设置登录时间
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
assert.NotNil(t, user.LastLoginAt)
|
||||
assert.Equal(t, now, *user.LastLoginAt)
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderModel 测试订单模型
|
||||
func TestOrderModel(t *testing.T) {
|
||||
t.Run("创建订单模型", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
Remark: "测试订单",
|
||||
}
|
||||
|
||||
assert.Equal(t, "ORD-2025-001", order.OrderID)
|
||||
assert.Equal(t, uint(1), order.UserID)
|
||||
assert.Equal(t, int64(10000), order.Amount)
|
||||
assert.Equal(t, constants.OrderStatusPending, order.Status)
|
||||
})
|
||||
|
||||
t.Run("订单表名", func(t *testing.T) {
|
||||
order := &model.Order{}
|
||||
assert.Equal(t, "tb_order", order.TableName())
|
||||
})
|
||||
|
||||
t.Run("可选时间字段", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
|
||||
assert.Nil(t, order.PaidAt)
|
||||
assert.Nil(t, order.CompletedAt)
|
||||
|
||||
// 设置支付时间
|
||||
now := time.Now()
|
||||
order.PaidAt = &now
|
||||
assert.NotNil(t, order.PaidAt)
|
||||
assert.Equal(t, now, *order.PaidAt)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBaseModel 测试基础模型
|
||||
func TestBaseModel(t *testing.T) {
|
||||
t.Run("BaseModel 字段", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
// ID 应该是 0 (未保存)
|
||||
assert.Zero(t, user.ID)
|
||||
|
||||
// 时间戳应该是零值
|
||||
assert.True(t, user.CreatedAt.IsZero())
|
||||
assert.True(t, user.UpdatedAt.IsZero())
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserStatusConstants 测试用户状态常量
|
||||
func TestUserStatusConstants(t *testing.T) {
|
||||
t.Run("用户状态常量定义", func(t *testing.T) {
|
||||
assert.Equal(t, "active", constants.UserStatusActive)
|
||||
assert.Equal(t, "inactive", constants.UserStatusInactive)
|
||||
assert.Equal(t, "suspended", constants.UserStatusSuspended)
|
||||
})
|
||||
|
||||
t.Run("用户状态验证", func(t *testing.T) {
|
||||
validStatuses := []string{
|
||||
constants.UserStatusActive,
|
||||
constants.UserStatusInactive,
|
||||
constants.UserStatusSuspended,
|
||||
}
|
||||
|
||||
for _, status := range validStatuses {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: status,
|
||||
}
|
||||
assert.Contains(t, validStatuses, user.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderStatusConstants 测试订单状态常量
|
||||
func TestOrderStatusConstants(t *testing.T) {
|
||||
t.Run("订单状态常量定义", func(t *testing.T) {
|
||||
assert.Equal(t, "pending", constants.OrderStatusPending)
|
||||
assert.Equal(t, "paid", constants.OrderStatusPaid)
|
||||
assert.Equal(t, "processing", constants.OrderStatusProcessing)
|
||||
assert.Equal(t, "completed", constants.OrderStatusCompleted)
|
||||
assert.Equal(t, "cancelled", constants.OrderStatusCancelled)
|
||||
})
|
||||
|
||||
t.Run("订单状态流转", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-2025-001",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
|
||||
// 订单状态流转:pending -> paid -> processing -> completed
|
||||
assert.Equal(t, constants.OrderStatusPending, order.Status)
|
||||
|
||||
order.Status = constants.OrderStatusPaid
|
||||
assert.Equal(t, constants.OrderStatusPaid, order.Status)
|
||||
|
||||
order.Status = constants.OrderStatusProcessing
|
||||
assert.Equal(t, constants.OrderStatusProcessing, order.Status)
|
||||
|
||||
order.Status = constants.OrderStatusCompleted
|
||||
assert.Equal(t, constants.OrderStatusCompleted, order.Status)
|
||||
})
|
||||
|
||||
t.Run("订单取消", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-2025-002",
|
||||
UserID: 1,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
|
||||
// 从任何状态都可以取消
|
||||
order.Status = constants.OrderStatusCancelled
|
||||
assert.Equal(t, constants.OrderStatusCancelled, order.Status)
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserResponse 测试用户响应模型
|
||||
func TestUserResponse(t *testing.T) {
|
||||
t.Run("创建用户响应", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
resp := &model.UserResponse{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Status: constants.UserStatusActive,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
assert.Equal(t, uint(1), resp.ID)
|
||||
assert.Equal(t, "testuser", resp.Username)
|
||||
assert.Equal(t, "test@example.com", resp.Email)
|
||||
assert.Equal(t, constants.UserStatusActive, resp.Status)
|
||||
})
|
||||
|
||||
t.Run("用户响应不包含密码", func(t *testing.T) {
|
||||
// UserResponse 结构体不应该包含 Password 字段
|
||||
resp := &model.UserResponse{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
// 验证结构体大小合理 (不包含密码字段)
|
||||
assert.NotNil(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
// TestListResponse 测试列表响应模型
|
||||
func TestListResponse(t *testing.T) {
|
||||
t.Run("用户列表响应", func(t *testing.T) {
|
||||
users := []model.UserResponse{
|
||||
{ID: 1, Username: "user1", Email: "user1@example.com", Status: constants.UserStatusActive},
|
||||
{ID: 2, Username: "user2", Email: "user2@example.com", Status: constants.UserStatusActive},
|
||||
}
|
||||
|
||||
resp := &model.ListUsersResponse{
|
||||
Users: users,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
Total: 100,
|
||||
TotalPages: 5,
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, len(resp.Users))
|
||||
assert.Equal(t, 1, resp.Page)
|
||||
assert.Equal(t, 20, resp.PageSize)
|
||||
assert.Equal(t, int64(100), resp.Total)
|
||||
assert.Equal(t, 5, resp.TotalPages)
|
||||
})
|
||||
|
||||
t.Run("订单列表响应", func(t *testing.T) {
|
||||
orders := []model.OrderResponse{
|
||||
{ID: 1, OrderID: "ORD-001", UserID: 1, Amount: 10000, Status: constants.OrderStatusPending},
|
||||
{ID: 2, OrderID: "ORD-002", UserID: 1, Amount: 20000, Status: constants.OrderStatusPaid},
|
||||
}
|
||||
|
||||
resp := &model.ListOrdersResponse{
|
||||
Orders: orders,
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
Total: 50,
|
||||
TotalPages: 3,
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, len(resp.Orders))
|
||||
assert.Equal(t, 1, resp.Page)
|
||||
assert.Equal(t, 20, resp.PageSize)
|
||||
assert.Equal(t, int64(50), resp.Total)
|
||||
assert.Equal(t, 3, resp.TotalPages)
|
||||
})
|
||||
}
|
||||
|
||||
// TestFieldTags 测试字段标签
|
||||
func TestFieldTags(t *testing.T) {
|
||||
t.Run("User GORM 标签", func(t *testing.T) {
|
||||
user := &model.User{}
|
||||
|
||||
// 验证 TableName 方法存在
|
||||
tableName := user.TableName()
|
||||
assert.Equal(t, "tb_user", tableName)
|
||||
})
|
||||
|
||||
t.Run("Order GORM 标签", func(t *testing.T) {
|
||||
order := &model.Order{}
|
||||
|
||||
// 验证 TableName 方法存在
|
||||
tableName := order.TableName()
|
||||
assert.Equal(t, "tb_order", tableName)
|
||||
})
|
||||
}
|
||||
555
tests/unit/queue_test.go
Normal file
555
tests/unit/queue_test.go
Normal file
@@ -0,0 +1,555 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// TestQueueClientEnqueue 测试任务入队
|
||||
func TestQueueClientEnqueue(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := map[string]string{
|
||||
"request_id": "test-001",
|
||||
"to": "test@example.com",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, info.ID)
|
||||
assert.Equal(t, constants.QueueDefault, info.Queue)
|
||||
}
|
||||
|
||||
// TestQueueClientEnqueueWithOptions 测试带选项的任务入队
|
||||
func TestQueueClientEnqueueWithOptions(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts []asynq.Option
|
||||
verify func(*testing.T, *asynq.TaskInfo)
|
||||
}{
|
||||
{
|
||||
name: "Custom Queue",
|
||||
opts: []asynq.Option{
|
||||
asynq.Queue(constants.QueueCritical),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.Equal(t, constants.QueueCritical, info.Queue)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Retry",
|
||||
opts: []asynq.Option{
|
||||
asynq.MaxRetry(3),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.Equal(t, 3, info.MaxRetry)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom Timeout",
|
||||
opts: []asynq.Option{
|
||||
asynq.Timeout(5 * time.Minute),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.Equal(t, 5*time.Minute, info.Timeout)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Delayed Task",
|
||||
opts: []asynq.Option{
|
||||
asynq.ProcessIn(10 * time.Second),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.True(t, info.NextProcessAt.After(time.Now()))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Combined Options",
|
||||
opts: []asynq.Option{
|
||||
asynq.Queue(constants.QueueCritical),
|
||||
asynq.MaxRetry(5),
|
||||
asynq.Timeout(10 * time.Minute),
|
||||
},
|
||||
verify: func(t *testing.T, info *asynq.TaskInfo) {
|
||||
assert.Equal(t, constants.QueueCritical, info.Queue)
|
||||
assert.Equal(t, 5, info.MaxRetry)
|
||||
assert.Equal(t, 10*time.Minute, info.Timeout)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"request_id": "test-" + tt.name,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task, tt.opts...)
|
||||
|
||||
require.NoError(t, err)
|
||||
tt.verify(t, info)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueueClientTaskUniqueness 测试任务唯一性
|
||||
func TestQueueClientTaskUniqueness(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := map[string]string{
|
||||
"request_id": "unique-001",
|
||||
"to": "test@example.com",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 第一次提交
|
||||
task1 := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info1, err := client.Enqueue(task1,
|
||||
asynq.TaskID("unique-task-001"),
|
||||
asynq.Unique(1*time.Hour),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, info1)
|
||||
|
||||
// 第二次提交(重复)
|
||||
task2 := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info2, err := client.Enqueue(task2,
|
||||
asynq.TaskID("unique-task-001"),
|
||||
asynq.Unique(1*time.Hour),
|
||||
)
|
||||
|
||||
// 应该返回错误(任务已存在)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, info2)
|
||||
}
|
||||
|
||||
// TestQueuePriorityWeights 测试队列优先级权重
|
||||
func TestQueuePriorityWeights(t *testing.T) {
|
||||
queues := map[string]int{
|
||||
constants.QueueCritical: 6,
|
||||
constants.QueueDefault: 3,
|
||||
constants.QueueLow: 1,
|
||||
}
|
||||
|
||||
// 验证权重总和
|
||||
totalWeight := 0
|
||||
for _, weight := range queues {
|
||||
totalWeight += weight
|
||||
}
|
||||
assert.Equal(t, 10, totalWeight)
|
||||
|
||||
// 验证权重比例
|
||||
assert.Equal(t, 0.6, float64(queues[constants.QueueCritical])/float64(totalWeight))
|
||||
assert.Equal(t, 0.3, float64(queues[constants.QueueDefault])/float64(totalWeight))
|
||||
assert.Equal(t, 0.1, float64(queues[constants.QueueLow])/float64(totalWeight))
|
||||
}
|
||||
|
||||
// TestTaskPayloadSizeLimit 测试任务载荷大小限制
|
||||
func TestTaskPayloadSizeLimit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payloadSize int
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "Small Payload (1KB)",
|
||||
payloadSize: 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Medium Payload (100KB)",
|
||||
payloadSize: 100 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "Large Payload (1MB)",
|
||||
payloadSize: 1024 * 1024,
|
||||
shouldError: false,
|
||||
},
|
||||
// Redis 默认支持最大 512MB,但实际应用中不建议超过 1MB
|
||||
}
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 创建指定大小的载荷
|
||||
largeData := make([]byte, tt.payloadSize)
|
||||
for i := range largeData {
|
||||
largeData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"request_id": "size-test-001",
|
||||
"data": largeData,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeDataSync, payloadBytes)
|
||||
info, err := client.Enqueue(task)
|
||||
|
||||
if tt.shouldError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, info)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskScheduling 测试任务调度
|
||||
func TestTaskScheduling(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
scheduleOpt asynq.Option
|
||||
expectedTime time.Time
|
||||
}{
|
||||
{
|
||||
name: "Process In 5 Seconds",
|
||||
scheduleOpt: asynq.ProcessIn(5 * time.Second),
|
||||
expectedTime: time.Now().Add(5 * time.Second),
|
||||
},
|
||||
{
|
||||
name: "Process At Specific Time",
|
||||
scheduleOpt: asynq.ProcessAt(time.Now().Add(10 * time.Second)),
|
||||
expectedTime: time.Now().Add(10 * time.Second),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := map[string]string{
|
||||
"request_id": "schedule-test-" + tt.name,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task, tt.scheduleOpt)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, info.NextProcessAt.After(time.Now()))
|
||||
// 允许 1 秒的误差
|
||||
assert.WithinDuration(t, tt.expectedTime, info.NextProcessAt, 1*time.Second)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueueInspectorStats 测试队列统计
|
||||
func TestQueueInspectorStats(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 提交一些任务
|
||||
for i := 0; i < 5; i++ {
|
||||
payload := map[string]string{
|
||||
"request_id": "stats-test-" + string(rune(i)),
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
_, err = client.Enqueue(task)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 使用 Inspector 查询统计
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 5, info.Pending)
|
||||
assert.Equal(t, 0, info.Active)
|
||||
assert.Equal(t, 0, info.Completed)
|
||||
}
|
||||
|
||||
// TestTaskRetention 测试任务保留策略
|
||||
func TestTaskRetention(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := map[string]string{
|
||||
"request_id": "retention-test-001",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提交任务并设置保留时间
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task,
|
||||
asynq.Retention(24*time.Hour), // 保留 24 小时
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, info)
|
||||
}
|
||||
|
||||
// TestQueueDraining 测试队列暂停和恢复
|
||||
func TestQueueDraining(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
// 暂停队列
|
||||
err := inspector.PauseQueue(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 检查队列是否已暂停
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, info.Paused)
|
||||
|
||||
// 恢复队列
|
||||
err = inspector.UnpauseQueue(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 检查队列是否已恢复
|
||||
info, err = inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, info.Paused)
|
||||
}
|
||||
|
||||
// TestTaskCancellation 测试任务取消
|
||||
func TestTaskCancellation(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
payload := map[string]string{
|
||||
"request_id": "cancel-test-001",
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 提交任务
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
info, err := client.Enqueue(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 取消任务
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
err = inspector.DeleteTask(constants.QueueDefault, info.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 验证任务已删除
|
||||
queueInfo, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, queueInfo.Pending)
|
||||
}
|
||||
|
||||
// TestBatchTaskEnqueue 测试批量任务入队
|
||||
func TestBatchTaskEnqueue(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 批量创建任务
|
||||
batchSize := 100
|
||||
for i := 0; i < batchSize; i++ {
|
||||
payload := map[string]string{
|
||||
"request_id": "batch-" + string(rune(i)),
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
_, err = client.Enqueue(task)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 验证任务数量
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, batchSize, info.Pending)
|
||||
}
|
||||
|
||||
// TestTaskGrouping 测试任务分组
|
||||
func TestTaskGrouping(t *testing.T) {
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer redisClient.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
redisClient.FlushDB(ctx)
|
||||
|
||||
client := asynq.NewClient(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
// 提交分组任务
|
||||
groupKey := "email-batch-001"
|
||||
for i := 0; i < 5; i++ {
|
||||
payload := map[string]string{
|
||||
"request_id": "group-" + string(rune(i)),
|
||||
"group": groupKey,
|
||||
}
|
||||
|
||||
payloadBytes, err := sonic.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := asynq.NewTask(constants.TaskTypeEmailSend, payloadBytes)
|
||||
_, err = client.Enqueue(task,
|
||||
asynq.Group(groupKey),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 验证任务已按组提交
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{
|
||||
Addr: "localhost:6379",
|
||||
})
|
||||
defer inspector.Close()
|
||||
|
||||
info, err := inspector.GetQueueInfo(constants.QueueDefault)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, info.Pending, 5)
|
||||
}
|
||||
550
tests/unit/store_test.go
Normal file
550
tests/unit/store_test.go
Normal file
@@ -0,0 +1,550 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// setupTestStore 创建内存数据库用于单元测试
|
||||
func setupTestStore(t *testing.T) (*postgres.Store, func()) {
|
||||
// 使用 SQLite 内存数据库进行单元测试
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err, "创建内存数据库失败")
|
||||
|
||||
// 自动迁移
|
||||
err = db.AutoMigrate(&model.User{}, &model.Order{})
|
||||
require.NoError(t, err, "数据库迁移失败")
|
||||
|
||||
// 创建测试 logger
|
||||
testLogger := zap.NewNop()
|
||||
store := postgres.NewStore(db, testLogger)
|
||||
|
||||
cleanup := func() {
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return store, cleanup
|
||||
}
|
||||
|
||||
// TestUserStore 测试用户 Store 层
|
||||
func TestUserStore(t *testing.T) {
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("创建用户成功", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
|
||||
err := store.User.Create(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, user.ID)
|
||||
assert.False(t, user.CreatedAt.IsZero())
|
||||
assert.False(t, user.UpdatedAt.IsZero())
|
||||
})
|
||||
|
||||
t.Run("创建重复用户名失败", func(t *testing.T) {
|
||||
user1 := &model.User{
|
||||
Username: "duplicate",
|
||||
Email: "user1@example.com",
|
||||
Password: "password1",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 尝试创建相同用户名
|
||||
user2 := &model.User{
|
||||
Username: "duplicate",
|
||||
Email: "user2@example.com",
|
||||
Password: "password2",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err = store.User.Create(ctx, user2)
|
||||
assert.Error(t, err, "应该返回唯一约束错误")
|
||||
})
|
||||
|
||||
t.Run("根据ID查询用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "findbyid",
|
||||
Email: "findbyid@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.Username, found.Username)
|
||||
assert.Equal(t, user.Email, found.Email)
|
||||
})
|
||||
|
||||
t.Run("查询不存在的用户", func(t *testing.T) {
|
||||
_, err := store.User.GetByID(ctx, 99999)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("根据用户名查询用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "findbyname",
|
||||
Email: "findbyname@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := store.User.GetByUsername(ctx, "findbyname")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.ID, found.ID)
|
||||
})
|
||||
|
||||
t.Run("更新用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "updatetest",
|
||||
Email: "update@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新用户
|
||||
user.Email = "newemail@example.com"
|
||||
user.Status = constants.UserStatusInactive
|
||||
err = store.User.Update(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证更新
|
||||
found, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "newemail@example.com", found.Email)
|
||||
assert.Equal(t, constants.UserStatusInactive, found.Status)
|
||||
})
|
||||
|
||||
t.Run("软删除用户", func(t *testing.T) {
|
||||
user := &model.User{
|
||||
Username: "deletetest",
|
||||
Email: "delete@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 软删除
|
||||
err = store.User.Delete(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证已删除
|
||||
_, err = store.User.GetByID(ctx, user.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("分页列表查询", func(t *testing.T) {
|
||||
// 创建10个用户
|
||||
for i := 1; i <= 10; i++ {
|
||||
user := &model.User{
|
||||
Username: "listuser" + string(rune('0'+i)),
|
||||
Email: "list" + string(rune('0'+i)) + "@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 第一页
|
||||
users, total, err := store.User.List(ctx, 1, 5)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(users), 5)
|
||||
assert.GreaterOrEqual(t, total, int64(10))
|
||||
|
||||
// 第二页
|
||||
users2, total2, err := store.User.List(ctx, 2, 5)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(users2), 5)
|
||||
assert.Equal(t, total, total2)
|
||||
|
||||
// 验证不同页的数据不同
|
||||
if len(users) > 0 && len(users2) > 0 {
|
||||
assert.NotEqual(t, users[0].ID, users2[0].ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrderStore 测试订单 Store 层
|
||||
func TestOrderStore(t *testing.T) {
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "orderuser",
|
||||
Email: "orderuser@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("创建订单成功", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-TEST-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
Remark: "测试订单",
|
||||
}
|
||||
|
||||
err := store.Order.Create(ctx, order)
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, order.ID)
|
||||
assert.False(t, order.CreatedAt.IsZero())
|
||||
})
|
||||
|
||||
t.Run("创建重复订单号失败", func(t *testing.T) {
|
||||
order1 := &model.Order{
|
||||
OrderID: "ORD-DUP-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 尝试创建相同订单号
|
||||
order2 := &model.Order{
|
||||
OrderID: "ORD-DUP-001",
|
||||
UserID: user.ID,
|
||||
Amount: 20000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err = store.Order.Create(ctx, order2)
|
||||
assert.Error(t, err, "应该返回唯一约束错误")
|
||||
})
|
||||
|
||||
t.Run("根据ID查询订单", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-FIND-001",
|
||||
UserID: user.ID,
|
||||
Amount: 20000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := store.Order.GetByID(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, order.OrderID, found.OrderID)
|
||||
assert.Equal(t, order.Amount, found.Amount)
|
||||
})
|
||||
|
||||
t.Run("根据订单号查询", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-FIND-002",
|
||||
UserID: user.ID,
|
||||
Amount: 30000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
found, err := store.Order.GetByOrderID(ctx, "ORD-FIND-002")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, order.ID, found.ID)
|
||||
})
|
||||
|
||||
t.Run("根据用户ID列表查询", func(t *testing.T) {
|
||||
// 创建多个订单
|
||||
for i := 1; i <= 5; i++ {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-LIST-" + string(rune('0'+i)),
|
||||
UserID: user.ID,
|
||||
Amount: int64(i * 10000),
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
orders, total, err := store.Order.ListByUserID(ctx, user.ID, 1, 10)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(orders), 5)
|
||||
assert.GreaterOrEqual(t, total, int64(5))
|
||||
})
|
||||
|
||||
t.Run("更新订单状态", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-UPDATE-001",
|
||||
UserID: user.ID,
|
||||
Amount: 50000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 更新状态
|
||||
now := time.Now()
|
||||
order.Status = constants.OrderStatusPaid
|
||||
order.PaidAt = &now
|
||||
err = store.Order.Update(ctx, order)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证更新
|
||||
found, err := store.Order.GetByID(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, constants.OrderStatusPaid, found.Status)
|
||||
assert.NotNil(t, found.PaidAt)
|
||||
})
|
||||
|
||||
t.Run("软删除订单", func(t *testing.T) {
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-DELETE-001",
|
||||
UserID: user.ID,
|
||||
Amount: 60000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
err := store.Order.Create(ctx, order)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 软删除
|
||||
err = store.Order.Delete(ctx, order.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证已删除
|
||||
_, err = store.Order.GetByID(ctx, order.ID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestStoreTransaction 测试事务功能
|
||||
func TestStoreTransaction(t *testing.T) {
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("事务提交成功", func(t *testing.T) {
|
||||
var userID uint
|
||||
var orderID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: "txuser1",
|
||||
Email: "txuser1@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx.User.Create(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
userID = user.ID
|
||||
|
||||
// 创建订单
|
||||
order := &model.Order{
|
||||
OrderID: "ORD-TX-001",
|
||||
UserID: user.ID,
|
||||
Amount: 10000,
|
||||
Status: constants.OrderStatusPending,
|
||||
}
|
||||
if err := tx.Order.Create(ctx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
orderID = order.ID
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 验证用户和订单都已创建
|
||||
user, err := store.User.GetByID(ctx, userID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "txuser1", user.Username)
|
||||
|
||||
order, err := store.Order.GetByID(ctx, orderID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ORD-TX-001", order.OrderID)
|
||||
})
|
||||
|
||||
t.Run("事务回滚", func(t *testing.T) {
|
||||
var userID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx *postgres.Store) error {
|
||||
// 创建用户
|
||||
user := &model.User{
|
||||
Username: "rollbackuser",
|
||||
Email: "rollback@example.com",
|
||||
Password: "hashedpassword",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx.User.Create(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
userID = user.ID
|
||||
|
||||
// 模拟错误,触发回滚
|
||||
return errors.New("模拟错误")
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "模拟错误", err.Error())
|
||||
|
||||
// 验证用户未创建(已回滚)
|
||||
_, err = store.User.GetByID(ctx, userID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("嵌套事务回滚", func(t *testing.T) {
|
||||
var user1ID, user2ID uint
|
||||
|
||||
err := store.Transaction(ctx, func(tx1 *postgres.Store) error {
|
||||
// 外层事务:创建第一个用户
|
||||
user1 := &model.User{
|
||||
Username: "nested1",
|
||||
Email: "nested1@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx1.User.Create(ctx, user1); err != nil {
|
||||
return err
|
||||
}
|
||||
user1ID = user1.ID
|
||||
|
||||
// 内层事务:创建第二个用户并失败
|
||||
err := tx1.Transaction(ctx, func(tx2 *postgres.Store) error {
|
||||
user2 := &model.User{
|
||||
Username: "nested2",
|
||||
Email: "nested2@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
if err := tx2.User.Create(ctx, user2); err != nil {
|
||||
return err
|
||||
}
|
||||
user2ID = user2.ID
|
||||
|
||||
// 内层事务失败
|
||||
return errors.New("内层事务失败")
|
||||
})
|
||||
|
||||
// 内层事务失败导致外层事务也失败
|
||||
return err
|
||||
})
|
||||
|
||||
assert.Error(t, err)
|
||||
|
||||
// 验证两个用户都未创建
|
||||
_, err = store.User.GetByID(ctx, user1ID)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = store.User.GetByID(ctx, user2ID)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConcurrentAccess 测试并发访问
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("并发创建用户", func(t *testing.T) {
|
||||
concurrency := 20
|
||||
errChan := make(chan error, concurrency)
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func(index int) {
|
||||
user := &model.User{
|
||||
Username: "concurrent" + string(rune('A'+index)),
|
||||
Email: "concurrent" + string(rune('A'+index)) + "@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
errChan <- store.User.Create(ctx, user)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 收集结果
|
||||
successCount := 0
|
||||
for i := 0; i < concurrency; i++ {
|
||||
err := <-errChan
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, concurrency, successCount, "所有并发创建应该成功")
|
||||
})
|
||||
|
||||
t.Run("并发读写同一用户", func(t *testing.T) {
|
||||
// 创建测试用户
|
||||
user := &model.User{
|
||||
Username: "rwuser",
|
||||
Email: "rwuser@example.com",
|
||||
Password: "password",
|
||||
Status: constants.UserStatusActive,
|
||||
}
|
||||
err := store.User.Create(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
concurrency := 10
|
||||
done := make(chan bool, concurrency*2)
|
||||
|
||||
// 并发读
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func() {
|
||||
_, err := store.User.GetByID(ctx, user.ID)
|
||||
assert.NoError(t, err)
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// 并发写
|
||||
for i := 0; i < concurrency; i++ {
|
||||
go func(index int) {
|
||||
user.Status = constants.UserStatusActive
|
||||
err := store.User.Update(ctx, user)
|
||||
assert.NoError(t, err)
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 等待所有操作完成
|
||||
for i := 0; i < concurrency*2; i++ {
|
||||
<-done
|
||||
}
|
||||
})
|
||||
}
|
||||
390
tests/unit/task_handler_test.go
Normal file
390
tests/unit/task_handler_test.go
Normal file
@@ -0,0 +1,390 @@
|
||||
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, "锁过期后应该可以重新获取")
|
||||
}
|
||||
Reference in New Issue
Block a user