| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236 |
- //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, "被拒绝的注册不应产生用户")
- }
|