package service import ( "context" "errors" "os" "testing" "github.com/topfans/backend/pkg/database" appErrors "github.com/topfans/backend/pkg/errors" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/user" "github.com/topfans/backend/services/userService/repository" "gorm.io/gorm" ) // Test #1: 正常路径 — 有效 token + 正确 old + 有效 new func TestUpdatePassword_Success(t *testing.T) { skipIfNoTestEnv(t) db := setupTestDB(t) defer cleanupTestDB(t, db) userRepo := repository.NewUserRepository() user := createTestUser(t, db, userRepo, "13800001001") defer deleteTestUser(t, db, userRepo, user.ID) // 直接保存 verify_token 到 Redis(scene=password) if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil { t.Skipf("Redis unavailable: %v", err) } svc := &userService{userRepo: userRepo, db: db} req := &pb.UpdatePasswordRequest{ OldPassword: "password123", NewPassword: "newpassword456", VerifyToken: "valid_token", } resp, err := svc.UpdatePassword(context.Background(), req, user.ID) if err != nil { t.Fatalf("UpdatePassword failed: %v", err) } if resp == nil { t.Fatal("expected non-nil response") } // 验证密码已更新 updated, _ := userRepo.GetByID(user.ID) if !userRepo.VerifyPassword(updated, "newpassword456") { t.Error("password not updated") } // 验证 token 已被 ConsumeVerifyToken 删除(Plan A) stored, _ := GetVerifyToken(context.Background(), "password", user.Mobile) if stored != "" { t.Error("verify_token should be consumed after success") } } // Test #2: verify_token 缺失 func TestUpdatePassword_VerifyTokenEmpty(t *testing.T) { skipIfNoTestEnv(t) db := setupTestDB(t) defer cleanupTestDB(t, db) userRepo := repository.NewUserRepository() user := createTestUser(t, db, userRepo, "13800001002") defer deleteTestUser(t, db, userRepo, user.ID) svc := &userService{userRepo: userRepo, db: db} req := &pb.UpdatePasswordRequest{ OldPassword: "password123", NewPassword: "newpassword456", VerifyToken: "", } _, err := svc.UpdatePassword(context.Background(), req, user.ID) if !errors.Is(err, appErrors.ErrInvalidVerifyToken) { t.Errorf("expected ErrInvalidVerifyToken, got %v", err) } } // Test #3: verify_token 错误 func TestUpdatePassword_VerifyTokenWrong(t *testing.T) { skipIfNoTestEnv(t) db := setupTestDB(t) defer cleanupTestDB(t, db) userRepo := repository.NewUserRepository() user := createTestUser(t, db, userRepo, "13800001003") defer deleteTestUser(t, db, userRepo, user.ID) // 保存的 token 与请求的不同 if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "correct_token", 300); err != nil { t.Skipf("Redis unavailable: %v", err) } svc := &userService{userRepo: userRepo, db: db} req := &pb.UpdatePasswordRequest{ OldPassword: "password123", NewPassword: "newpassword456", VerifyToken: "wrong_token", } _, err := svc.UpdatePassword(context.Background(), req, user.ID) if !errors.Is(err, appErrors.ErrInvalidVerifyToken) { t.Errorf("expected ErrInvalidVerifyToken, got %v", err) } } // Test #5: 旧密码错误 func TestUpdatePassword_WrongOldPassword(t *testing.T) { skipIfNoTestEnv(t) db := setupTestDB(t) defer cleanupTestDB(t, db) userRepo := repository.NewUserRepository() user := createTestUser(t, db, userRepo, "13800001005") defer deleteTestUser(t, db, userRepo, user.ID) if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil { t.Skipf("Redis unavailable: %v", err) } svc := &userService{userRepo: userRepo, db: db} req := &pb.UpdatePasswordRequest{ OldPassword: "wrong_old_password", NewPassword: "newpassword456", VerifyToken: "valid_token", } _, err := svc.UpdatePassword(context.Background(), req, user.ID) if !errors.Is(err, appErrors.ErrInvalidOldPassword) { t.Errorf("expected ErrInvalidOldPassword, got %v", err) } // Plan A: 旧密码错时 token 仍保留 stored, _ := GetVerifyToken(context.Background(), "password", user.Mobile) if stored != "valid_token" { t.Error("verify_token should be retained after business failure") } } // Test #7: 新旧密码相同 func TestUpdatePassword_SameAsOld(t *testing.T) { skipIfNoTestEnv(t) db := setupTestDB(t) defer cleanupTestDB(t, db) userRepo := repository.NewUserRepository() user := createTestUser(t, db, userRepo, "13800001007") defer deleteTestUser(t, db, userRepo, user.ID) if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "valid_token", 300); err != nil { t.Skipf("Redis unavailable: %v", err) } svc := &userService{userRepo: userRepo, db: db} req := &pb.UpdatePasswordRequest{ OldPassword: "password123", NewPassword: "password123", VerifyToken: "valid_token", } _, err := svc.UpdatePassword(context.Background(), req, user.ID) if !errors.Is(err, appErrors.ErrSameAsOldPassword) { t.Errorf("expected ErrSameAsOldPassword, got %v", err) } } // Test #11 (Plan A): 旧密码错误后 token 保留可重试 func TestUpdatePassword_PlanA_RetryAfterWrongPassword(t *testing.T) { skipIfNoTestEnv(t) db := setupTestDB(t) defer cleanupTestDB(t, db) userRepo := repository.NewUserRepository() user := createTestUser(t, db, userRepo, "13800001011") defer deleteTestUser(t, db, userRepo, user.ID) if err := SaveVerifyToken(context.Background(), "password", user.Mobile, "shared_token", 300); err != nil { t.Skipf("Redis unavailable: %v", err) } svc := &userService{userRepo: userRepo, db: db} // 第一次:旧密码错 _, err1 := svc.UpdatePassword(context.Background(), &pb.UpdatePasswordRequest{ OldPassword: "wrong_old", NewPassword: "newpassword456", VerifyToken: "shared_token", }, user.ID) if !errors.Is(err1, appErrors.ErrInvalidOldPassword) { t.Errorf("first call: expected ErrInvalidOldPassword, got %v", err1) } // token 仍在 stored, _ := GetVerifyToken(context.Background(), "password", user.Mobile) if stored != "shared_token" { t.Fatal("token should still be in Redis after business failure") } // 第二次:用同一 token + 正确旧密码 → 成功(Plan A 的核心收益) _, err2 := svc.UpdatePassword(context.Background(), &pb.UpdatePasswordRequest{ OldPassword: "password123", NewPassword: "newpassword456", VerifyToken: "shared_token", }, user.ID) if err2 != nil { t.Fatalf("retry: expected success, got %v", err2) } // token 已被 Consume stored2, _ := GetVerifyToken(context.Background(), "password", user.Mobile) if stored2 != "" { t.Error("token should be consumed after successful retry") } } // ==================== Test helpers ==================== func skipIfNoTestEnv(t *testing.T) { if os.Getenv("SKIP_DB_TESTS") != "" { t.Skip("SKIP_DB_TESTS set") } } func setupTestDB(t *testing.T) *gorm.DB { config := database.Config{ Host: "localhost", Port: 5432, User: getEnvOrDefault("TEST_DB_USER", "haihuizhu"), Password: getEnvOrDefault("TEST_DB_PASSWORD", "admin"), DBName: getEnvOrDefault("TEST_DB_NAME", "top-fans"), SSLMode: "disable", TimeZone: "Asia/Shanghai", } if err := database.Init(config); err != nil { t.Skipf("Skipping: failed to connect to test database: %v", err) } return database.GetDB() } func cleanupTestDB(_ *testing.T, db *gorm.DB) { db.Exec("DELETE FROM fan_profiles WHERE user_id IN (SELECT id FROM users WHERE mobile LIKE '13800001%')") db.Exec("DELETE FROM users WHERE mobile LIKE '13800001%'") } func createTestUser(t *testing.T, _ *gorm.DB, repo repository.UserRepository, mobile string) *models.User { hash, err := repository.HashPassword("password123") if err != nil { t.Fatalf("HashPassword: %v", err) } user := &models.User{ Mobile: mobile, PasswordHash: hash, IsActive: true, } if err := repo.Create(user); err != nil { t.Fatalf("Create user: %v", err) } return user } func deleteTestUser(_ *testing.T, db *gorm.DB, _ repository.UserRepository, userID int64) { db.Exec("DELETE FROM users WHERE id = ?", userID) } func getEnvOrDefault(key, defaultVal string) string { if v := os.Getenv(key); v != "" { return v } return defaultVal }