package usecase_test import ( "context" "sync" "testing" "time" authdomain "gateway/internal/model/auth/domain" "gateway/internal/model/auth/domain/entity" domrepo "gateway/internal/model/auth/domain/repository" domusecase "gateway/internal/model/auth/domain/usecase" authusecase "gateway/internal/model/auth/usecase" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/v2/bson" ) const testTenantAcme = "acme" func TestInviteUseCaseValidateAndConsume(t *testing.T) { t.Parallel() repo := newMemoryInviteRepo() lock := newMemoryInviteLock() uc := authusecase.MustInviteUseCase(authusecase.InviteUseCaseParam{ Repo: repo, Lock: lock, }) ctx := context.Background() repo.seed(&entity.InviteCode{ ID: bson.NewObjectID(), TenantID: testTenantAcme, CodeHash: authdomain.HashInviteCode("BETA-2026-TEST"), MaxUses: 2, NewUsersOnly: true, }) view, err := uc.Validate(ctx, &domusecase.ValidateInviteRequest{ TenantID: testTenantAcme, Code: "beta-2026-test", }) require.NoError(t, err) require.Equal(t, testTenantAcme, view.TenantID) require.Equal(t, int64(2), view.RemainingUses) require.True(t, view.NewUsersOnly) consumed, err := uc.Consume(ctx, &domusecase.ConsumeInviteRequest{ TenantID: testTenantAcme, Code: "BETA-2026-TEST", }) require.NoError(t, err) require.Equal(t, int64(1), consumed.UsedCount) view, err = uc.Validate(ctx, &domusecase.ValidateInviteRequest{ TenantID: testTenantAcme, Code: "BETA-2026-TEST", }) require.NoError(t, err) require.Equal(t, int64(1), view.RemainingUses) } func TestInviteUseCaseExpired(t *testing.T) { t.Parallel() repo := newMemoryInviteRepo() uc := authusecase.MustInviteUseCase(authusecase.InviteUseCaseParam{ Repo: repo, Lock: newMemoryInviteLock(), }) repo.seed(&entity.InviteCode{ ID: bson.NewObjectID(), TenantID: testTenantAcme, CodeHash: authdomain.HashInviteCode("EXPIRED"), MaxUses: 1, ExpiresAt: time.Now().UTC().Add(-time.Hour).UnixMilli(), }) _, err := uc.Validate(context.Background(), &domusecase.ValidateInviteRequest{ TenantID: testTenantAcme, Code: "EXPIRED", }) require.Error(t, err) } func TestInviteUseCaseConcurrentConsume(t *testing.T) { t.Parallel() repo := newMemoryInviteRepo() lock := newMemoryInviteLock() uc := authusecase.MustInviteUseCase(authusecase.InviteUseCaseParam{ Repo: repo, Lock: lock, }) repo.seed(&entity.InviteCode{ ID: bson.NewObjectID(), TenantID: testTenantAcme, CodeHash: authdomain.HashInviteCode("ONCE"), MaxUses: 1, }) var wg sync.WaitGroup successes := make(chan struct{}, 2) failures := make(chan struct{}, 2) for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() _, err := uc.Consume(context.Background(), &domusecase.ConsumeInviteRequest{ TenantID: testTenantAcme, Code: "ONCE", }) if err == nil { successes <- struct{}{} return } failures <- struct{}{} }() } wg.Wait() close(successes) close(failures) require.Len(t, successes, 1) require.Len(t, failures, 1) } type memoryInviteRepo struct { mu sync.Mutex items map[string]*entity.InviteCode } func newMemoryInviteRepo() *memoryInviteRepo { return &memoryInviteRepo{items: make(map[string]*entity.InviteCode)} } func (r *memoryInviteRepo) key(tenantID, codeHash string) string { return tenantID + ":" + codeHash } func (r *memoryInviteRepo) seed(invite *entity.InviteCode) { r.mu.Lock() defer r.mu.Unlock() cp := *invite r.items[r.key(invite.TenantID, invite.CodeHash)] = &cp } func (r *memoryInviteRepo) GetByTenantAndCodeHash(_ context.Context, tenantID, codeHash string) (*entity.InviteCode, error) { r.mu.Lock() defer r.mu.Unlock() invite, ok := r.items[r.key(tenantID, codeHash)] if !ok { return nil, authdomain.ErrInviteNotFound } cp := *invite return &cp, nil } func (r *memoryInviteRepo) ConsumeOne(_ context.Context, id bson.ObjectID) (*entity.InviteCode, error) { r.mu.Lock() defer r.mu.Unlock() for _, invite := range r.items { if invite.ID != id { continue } now := time.Now().UTC().UnixMilli() if invite.ExpiresAt > 0 && invite.ExpiresAt <= now { return nil, authdomain.ErrInviteExpired } if invite.UsedCount >= invite.MaxUses { return nil, authdomain.ErrInviteExhausted } invite.UsedCount++ cp := *invite return &cp, nil } return nil, authdomain.ErrInviteNotFound } var _ domrepo.InviteRepository = (*memoryInviteRepo)(nil) type memoryInviteLock struct { mu sync.Mutex } func newMemoryInviteLock() *memoryInviteLock { return &memoryInviteLock{} } func (l *memoryInviteLock) TryLock(_ context.Context, _, _ string) (bool, error) { l.mu.Lock() return true, nil } func (l *memoryInviteLock) Unlock(_ context.Context, _, _ string) error { l.mu.Unlock() return nil } var _ domrepo.InviteConsumeLock = (*memoryInviteLock)(nil)