package testutils import ( "context" "fmt" "strings" "sync" "testing" "github.com/redis/go-redis/v9" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/break/junhong_cmp_fiber/internal/model" ) // 全局单例数据库和 Redis 连接 // 使用 sync.Once 确保整个测试套件只创建一次连接,显著提升测试性能 var ( testDBOnce sync.Once testDB *gorm.DB testDBInitErr error testRedisOnce sync.Once testRedis *redis.Client testRedisInitErr error ) // 测试数据库配置 // TODO: 未来可以从环境变量或配置文件加载 const ( testDBDSN = "host=cxd.whcxd.cn port=16159 user=erp_pgsql password=erp_2025 dbname=junhong_cmp_test sslmode=disable TimeZone=Asia/Shanghai" testRedisAddr = "cxd.whcxd.cn:16299" testRedisPasswd = "cpNbWtAaqgo1YJmbMp3h" testRedisDB = 15 ) // GetTestDB 获取全局单例测试数据库连接 // // 特点: // - 使用 sync.Once 确保整个测试套件只创建一次连接 // - AutoMigrate 只在首次连接时执行一次 // - 连接失败会跳过测试(不是致命错误) // // 用法: // // func TestXxx(t *testing.T) { // db := testutils.GetTestDB(t) // // db 是全局共享的连接,不要直接修改其状态 // // 如需事务隔离,使用 NewTestTransaction(t) // } func GetTestDB(t *testing.T) *gorm.DB { t.Helper() testDBOnce.Do(func() { var err error testDB, err = gorm.Open(postgres.Open(testDBDSN), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { testDBInitErr = fmt.Errorf("无法连接测试数据库: %w", err) return } err = testDB.AutoMigrate( &model.Account{}, &model.Role{}, &model.Permission{}, &model.AccountRole{}, &model.RolePermission{}, &model.Shop{}, &model.Enterprise{}, &model.PersonalCustomer{}, &model.PersonalCustomerPhone{}, &model.PersonalCustomerICCID{}, &model.PersonalCustomerDevice{}, &model.IotCard{}, &model.IotCardImportTask{}, &model.Device{}, &model.DeviceImportTask{}, &model.DeviceSimBinding{}, &model.Carrier{}, &model.Tag{}, &model.PackageSeries{}, &model.Package{}, &model.ShopPackageAllocation{}, &model.ShopSeriesAllocation{}, &model.EnterpriseCardAuthorization{}, &model.EnterpriseDeviceAuthorization{}, &model.AssetAllocationRecord{}, &model.CommissionWithdrawalRequest{}, &model.CommissionWithdrawalSetting{}, ) if err != nil { errMsg := err.Error() if strings.Contains(errMsg, "does not exist") && strings.Contains(errMsg, "constraint") { // 忽略约束不存在的错误,这是由于约束名变更导致的 } else { testDBInitErr = fmt.Errorf("数据库迁移失败: %w", err) return } } }) if testDBInitErr != nil { t.Skipf("跳过测试:%v", testDBInitErr) } return testDB } // GetTestRedis 获取全局单例 Redis 连接 // // 特点: // - 使用 sync.Once 确保整个测试套件只创建一次连接 // - 连接失败会跳过测试(不是致命错误) // // 用法: // // func TestXxx(t *testing.T) { // rdb := testutils.GetTestRedis(t) // // rdb 是全局共享的连接 // // 使用 CleanTestRedisKeys(t) 自动清理测试相关的 Redis 键 // } func GetTestRedis(t *testing.T) *redis.Client { t.Helper() testRedisOnce.Do(func() { testRedis = redis.NewClient(&redis.Options{ Addr: testRedisAddr, Password: testRedisPasswd, DB: testRedisDB, }) ctx := context.Background() if err := testRedis.Ping(ctx).Err(); err != nil { testRedisInitErr = fmt.Errorf("无法连接 Redis: %w", err) return } }) if testRedisInitErr != nil { t.Skipf("跳过测试:%v", testRedisInitErr) } return testRedis } // NewTestTransaction 创建测试事务,自动在测试结束时回滚 // // 特点: // - 每个测试用例获得独立的事务,互不干扰 // - 使用 t.Cleanup() 确保即使测试 panic 也能回滚 // - 回滚后数据库状态与测试前完全一致 // // 用法: // // func TestXxx(t *testing.T) { // tx := testutils.NewTestTransaction(t) // // 所有数据库操作使用 tx 而非 db // store := postgres.NewXxxStore(tx, rdb) // // 测试结束后自动回滚,无需手动清理 // } // // 注意: // - 不要在子测试(t.Run)中调用此函数,因为子测试可能并行执行 // - 如需在子测试中使用数据库,应在父测试中创建事务并传递 func NewTestTransaction(t *testing.T) *gorm.DB { t.Helper() db := GetTestDB(t) tx := db.Begin() if tx.Error != nil { t.Fatalf("开启测试事务失败: %v", tx.Error) } // 使用 t.Cleanup() 确保测试结束时自动回滚 // 即使测试 panic 也能执行清理 t.Cleanup(func() { tx.Rollback() }) return tx } // CleanTestRedisKeys 清理当前测试的 Redis 键 // // 特点: // - 使用测试名称作为键前缀,格式: test:{TestName}:* // - 测试开始时清理已有键(防止脏数据) // - 使用 t.Cleanup() 确保测试结束时自动清理 // // 用法: // // func TestXxx(t *testing.T) { // rdb := testutils.GetTestRedis(t) // testutils.CleanTestRedisKeys(t, rdb) // // Redis 键使用测试专用前缀: test:TestXxx:your_key // } // // 键命名规范: // - 测试中创建的键应使用 GetTestRedisKeyPrefix(t) 作为前缀 // - 例如: test:TestShopStore_Create:cache:shop:1 func CleanTestRedisKeys(t *testing.T, rdb *redis.Client) { t.Helper() ctx := context.Background() testPrefix := GetTestRedisKeyPrefix(t) // 测试开始前清理已有键 cleanKeys(ctx, rdb, testPrefix) // 测试结束时自动清理 t.Cleanup(func() { cleanKeys(ctx, rdb, testPrefix) }) } // GetTestRedisKeyPrefix 获取当前测试的 Redis 键前缀 // // 返回格式: test:{TestName}: // 用于在测试中创建带前缀的 Redis 键,确保键不会与其他测试冲突 // // 用法: // // func TestXxx(t *testing.T) { // prefix := testutils.GetTestRedisKeyPrefix(t) // key := prefix + "my_cache_key" // // key = "test:TestXxx:my_cache_key" // } func GetTestRedisKeyPrefix(t *testing.T) string { t.Helper() return fmt.Sprintf("test:%s:", t.Name()) } // cleanKeys 清理匹配前缀的所有 Redis 键 func cleanKeys(ctx context.Context, rdb *redis.Client, prefix string) { keys, err := rdb.Keys(ctx, prefix+"*").Result() if err != nil { // 忽略 Redis 错误,不影响测试 return } if len(keys) > 0 { rdb.Del(ctx, keys...) } }