//go:build unit package service import ( "context" "errors" "fmt" "sync" "testing" "time" "github.com/stretchr/testify/require" ) // raceSafeUserRepo 是仅覆盖注册路径的并发安全用户仓储桩。 // 未用到的方法走嵌入接口(调用即 panic,注册路径不会触发)。 type raceSafeUserRepo struct { UserRepository mu sync.Mutex nextID int64 byEmail map[string]*User byID map[int64]*User } func newRaceSafeUserRepo() *raceSafeUserRepo { return &raceSafeUserRepo{nextID: 1, byEmail: map[string]*User{}, byID: map[int64]*User{}} } func (s *raceSafeUserRepo) ExistsByEmail(_ context.Context, email string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() _, ok := s.byEmail[email] return ok, nil } func (s *raceSafeUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) { return s.ExistsByEmail(ctx, email) } func (s *raceSafeUserRepo) CreateWithEmailAliasGuard(_ context.Context, user *User) error { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.byEmail[user.Email]; ok { return ErrEmailExists } user.ID = s.nextID s.nextID++ clone := *user s.byEmail[user.Email] = &clone s.byID[user.ID] = &clone return nil } func (s *raceSafeUserRepo) GetByEmail(_ context.Context, email string) (*User, error) { s.mu.Lock() defer s.mu.Unlock() u, ok := s.byEmail[email] if !ok { return nil, ErrUserNotFound } clone := *u return &clone, nil } func (s *raceSafeUserRepo) GetByID(_ context.Context, id int64) (*User, error) { s.mu.Lock() defer s.mu.Unlock() u, ok := s.byID[id] if !ok { return nil, ErrUserNotFound } clone := *u return &clone, nil } func (s *raceSafeUserRepo) Update(context.Context, *User, UserUpdateFields) error { return nil } // raceSafeRedeemRepo 是并发安全的兑换码仓储桩:Use 以互斥锁 + 状态条件 // 模拟数据库的条件更新(WHERE status='unused'),语义与线上实现一致。 type raceSafeRedeemRepo struct { RedeemCodeRepository mu sync.Mutex codes map[string]*RedeemCode } func (s *raceSafeRedeemRepo) GetByCode(_ context.Context, code string) (*RedeemCode, error) { s.mu.Lock() defer s.mu.Unlock() c, ok := s.codes[code] if !ok { return nil, ErrRedeemCodeNotFound } clone := *c return &clone, nil } func (s *raceSafeRedeemRepo) Use(_ context.Context, id, userID int64) error { s.mu.Lock() defer s.mu.Unlock() for _, c := range s.codes { if c.ID != id { continue } if c.Status != StatusUnused { return ErrRedeemCodeUsed } now := time.Now().UTC() c.Status = StatusUsed c.UsedBy = &userID c.UsedAt = &now return nil } return ErrRedeemCodeNotFound } // TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency 回归测试: // 同一邀请码并发注册必须恰好成功 1 次,其余请求以 INVITATION_CODE_INVALID 拒绝。 // // 修复前:邀请码“检查(CanUse) 与 标记已用(Use)”分离且不在同一事务,Use 失败被吞, // 并发请求全部注册成功(一个邀请码可创建任意数量账号)。此测试在该实现下必然失败。 // 修复后:用户创建与邀请码占用在同一事务内原子完成(或退化路径下由 Use 条件更新 // 兜底),并发下仅最先占码的注册成功。 func TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency(t *testing.T) { const code = "INV-RACE-001" userRepo := newRaceSafeUserRepo() redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{ code: {ID: 1, Code: code, Type: RedeemTypeInvitation, Status: StatusUnused}, }} settings := map[string]string{ "registration_enabled": "true", "invitation_code_enabled": "true", } svc := newOAuthEmailFlowAuthService( userRepo, redeemRepo, &refreshTokenCacheStub{}, settings, nil, // emailCache:注册不要求邮箱验证,保持关闭 &userPlatformQuotaRepoStub{}, ) const n = 8 ctx := context.Background() start := make(chan struct{}) results := make(chan error, n) var wg sync.WaitGroup for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() <-start email := fmt.Sprintf("race-%d@example.com", i) _, _, err := svc.RegisterWithVerification(ctx, email, "Password123!", "", "", code, "") results <- err }(i) } close(start) wg.Wait() close(results) successes := 0 rejected := 0 for err := range results { switch { case err == nil: successes++ case errors.Is(err, ErrInvitationCodeInvalid): rejected++ default: t.Fatalf("unexpected registration error: %v", err) } } require.Equal(t, 1, successes, "同一邀请码并发注册必须恰好成功 1 次") require.Equal(t, n-1, rejected, "其余并发请求必须以 INVITATION_CODE_INVALID 拒绝") claimed, err := redeemRepo.GetByCode(ctx, code) require.NoError(t, err) require.Equal(t, StatusUsed, claimed.Status, "邀请码最终必须处于 used 状态") require.NotNil(t, claimed.UsedBy, "used_by 必须记录实际注册用户") } // TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed 顺序路径回归: // 已使用过的邀请码再次注册(即便换邮箱)必须被拒绝。 func TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed(t *testing.T) { const code = "INV-RACE-002" userRepo := newRaceSafeUserRepo() redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{ code: {ID: 2, Code: code, Type: RedeemTypeInvitation, Status: StatusUsed}, }} settings := map[string]string{ "registration_enabled": "true", "invitation_code_enabled": "true", } svc := newOAuthEmailFlowAuthService( userRepo, redeemRepo, &refreshTokenCacheStub{}, settings, nil, &userPlatformQuotaRepoStub{}, ) _, _, err := svc.RegisterWithVerification(context.Background(), "later@example.com", "Password123!", "", "", code, "") require.ErrorIs(t, err, ErrInvitationCodeInvalid) } // TestAuthService_Register_InvitationCodeMissingWhenEnabled 门控回归: // 邀请码开启时,不带邀请码的注册必须被拒绝(不产生用户)。 func TestAuthService_Register_InvitationCodeMissingWhenEnabled(t *testing.T) { userRepo := newRaceSafeUserRepo() redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{}} settings := map[string]string{ "registration_enabled": "true", "invitation_code_enabled": "true", } svc := newOAuthEmailFlowAuthService( userRepo, redeemRepo, &refreshTokenCacheStub{}, settings, nil, &userPlatformQuotaRepoStub{}, ) _, _, err := svc.RegisterWithVerification(context.Background(), "no-invite@example.com", "Password123!", "", "", "", "") require.ErrorIs(t, err, ErrInvitationCodeRequired) ok, err := userRepo.ExistsByEmail(context.Background(), "no-invite@example.com") require.NoError(t, err) require.False(t, ok, "被拒绝的注册不应产生用户") }