package service import ( "context" "os" "testing" "time" "github.com/topfans/backend/pkg/jwt" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/user" "github.com/topfans/backend/services/userService/repository" "go.uber.org/zap" "gorm.io/gorm" ) // TestMain 初始化包内测试共享前置: // 测试进程不会调用 logger.Init,而 Register/Login 等成功/告警路径会调用 logger.Logger.*, // 若全局 logger 为 nil 会直接 panic。这里注入 no-op logger(不产生输出), // 让整个 service 包的 live-DB 用例可稳定运行。不涉及 DB DSN,不改动既有 setup。 // // 同时初始化 JWT secret(32+ 字节合法密钥):Login 流程会调 jwt.GenerateToken, // pkg/jwt 在 3.5 治理后已禁止静默弱默认值,运行期若未 MustInit 直接 panic。 func TestMain(m *testing.M) { if logger.Logger == nil { logger.Logger = zap.NewNop() } if err := jwt.MustInit("test-secret-key-must-be-at-least-32-bytes"); err != nil { panic("jwt.MustInit failed in TestMain: " + err.Error()) } os.Exit(m.Run()) } // createTestStar 插入一条 is_active 的测试明星,返回其自增 star_id 与清理函数。 // star_id / created_at / updated_at 交给 GORM 自增与 BeforeCreate 钩子,避免手动指定 ID // 破坏 PostgreSQL 序列(见 CLAUDE.md 序列同步规则)。 func createTestStar(t *testing.T, db *gorm.DB) (int64, func()) { t.Helper() star := &models.Star{ Name: "bcrypt-tx-test-star", IdentityID: "bcrypt_tx_test_" + time.Now().Format("150405.000000"), IsActive: true, } if err := db.Create(star).Error; err != nil { t.Fatalf("create test star: %v", err) } return star.StarID, func() { db.Exec("DELETE FROM stars WHERE star_id = ?", star.StarID) } } // TestRegister_Success_PasswordHashedAndVerifiable 覆盖 bcrypt 移出事务后的行为等价性: // // 注册仍然成功、返回 access_token,并且写入库的 password_hash 能被 bcrypt 正确校验。 // // 结构性保证(哈希在事务外)由 Register 中「HashPassword 位于 s.db.Transaction(...) 之前」 // 的代码位置 + 注释锁定;本用例负责保证前移不破坏原有注册行为。 func TestRegister_Success_PasswordHashedAndVerifiable(t *testing.T) { skipIfNoTestEnv(t) db := setupTestDB(t) defer cleanupTestDB(t, db) userRepo := repository.NewUserRepository() fanProfileRepo := repository.NewFanProfileRepository() starRepo := repository.NewStarRepository() starID, cleanupStar := createTestStar(t, db) defer cleanupStar() const mobile = "13800001990" const password = "Passw0rd123" // 预清理,避免上次残留导致 mobile 冲突 db.Exec("DELETE FROM fan_profiles WHERE user_id IN (SELECT id FROM users WHERE mobile = ?)", mobile) db.Exec("DELETE FROM users WHERE mobile = ?", mobile) svc := NewAuthService(userRepo, fanProfileRepo, starRepo, db) resp, err := svc.Register(context.Background(), &pb.RegisterRequest{ Mobile: mobile, Password: password, Nickname: "bcryptTester", StarId: starID, // VerifyToken 为空 → 跳过 Redis verify_token 校验,测试无需 Redis }) if err != nil { t.Fatalf("Register failed: %v", err) } if resp == nil || resp.Base == nil { t.Fatal("expected non-nil response with base") } if resp.AccessToken == "" { t.Fatal("expected non-empty access_token in RegisterResponse") } // 落库后 password_hash 必须能被 bcrypt 正确校验(证明事务外算出的哈希被正确写入) saved, err := userRepo.GetByMobile(mobile) if err != nil { t.Fatalf("GetByMobile after register: %v", err) } defer func() { db.Exec("DELETE FROM fan_profiles WHERE user_id = ?", saved.ID) deleteTestUser(t, db, userRepo, saved.ID) }() if saved.PasswordHash == "" { t.Fatal("password_hash should be persisted") } if saved.PasswordHash == password { t.Fatal("password_hash must not be the plaintext password") } if !userRepo.VerifyPassword(saved, password) { t.Fatal("stored password_hash should verify against the original password") } }