package usecase_test import ( "context" "fmt" "strings" "sync" "testing" "time" "apps/backend/internal/module/member/domain" "apps/backend/internal/module/member/usecase" tokenUC "apps/backend/internal/module/token/usecase" "github.com/stretchr/testify/require" ) // fakeRepo is test-only (not a production memory store). type fakeRepo struct { mu sync.Mutex byUID map[int64]*domain.Member byEmail map[string]int64 byPhone map[string]int64 identities map[string]*domain.Identity // platform:login_id byUIDIds map[int64][]string seq int64 codes map[string]string refresh map[string]int64 settings map[int64]*domain.UserSettings } func newFake() *fakeRepo { return &fakeRepo{ byUID: map[int64]*domain.Member{}, byEmail: map[string]int64{}, byPhone: map[string]int64{}, identities: map[string]*domain.Identity{}, byUIDIds: map[int64][]string{}, seq: domain.MinMemberUID, codes: map[string]string{}, refresh: map[string]int64{}, settings: map[int64]*domain.UserSettings{}, } } func idKey(loginID, platform string) string { return platform + ":" + loginID } func (f *fakeRepo) NextUID(ctx context.Context) (int64, error) { f.mu.Lock() defer f.mu.Unlock() f.seq++ if f.seq < domain.MinMemberUID { f.seq = domain.MinMemberUID + 1 } return f.seq, nil } func (f *fakeRepo) CreateMember(ctx context.Context, m *domain.Member) error { f.mu.Lock() defer f.mu.Unlock() if m.Email != "" { if _, ok := f.byEmail[m.Email]; ok { return domain.ErrEmailTaken } f.byEmail[m.Email] = m.UID } if m.Phone != "" { f.byPhone[m.Phone] = m.UID } cp := *m f.byUID[m.UID] = &cp return nil } func (f *fakeRepo) FindByUID(ctx context.Context, uid int64) (*domain.Member, error) { f.mu.Lock() defer f.mu.Unlock() m, ok := f.byUID[uid] if !ok { return nil, domain.ErrNotFound } cp := *m return &cp, nil } func (f *fakeRepo) FindByEmail(ctx context.Context, email string) (*domain.Member, error) { f.mu.Lock() defer f.mu.Unlock() uid, ok := f.byEmail[email] if !ok { return nil, domain.ErrNotFound } cp := *f.byUID[uid] return &cp, nil } func (f *fakeRepo) FindByPhone(ctx context.Context, phone string) (*domain.Member, error) { f.mu.Lock() defer f.mu.Unlock() uid, ok := f.byPhone[phone] if !ok { return nil, domain.ErrNotFound } cp := *f.byUID[uid] return &cp, nil } func (f *fakeRepo) UpdateMember(ctx context.Context, m *domain.Member) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.byUID[m.UID]; !ok { return domain.ErrNotFound } cp := *m f.byUID[m.UID] = &cp if m.Email != "" { f.byEmail[m.Email] = m.UID } return nil } func (f *fakeRepo) ListPage(ctx context.Context, page, pageSize int, query, status string) ([]*domain.Member, int64, error) { f.mu.Lock() defer f.mu.Unlock() if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } query = strings.ToLower(strings.TrimSpace(query)) var all []*domain.Member for _, m := range f.byUID { if status != "" && m.Status != status { continue } if query != "" { uidStr := fmt.Sprintf("%d", m.UID) if !strings.Contains(strings.ToLower(m.Email), query) && !strings.Contains(strings.ToLower(m.DisplayName), query) && !strings.Contains(uidStr, query) { continue } } cp := *m all = append(all, &cp) } total := int64(len(all)) start := (page - 1) * pageSize if start >= len(all) { return []*domain.Member{}, total, nil } end := start + pageSize if end > len(all) { end = len(all) } return all[start:end], total, nil } func (f *fakeRepo) CountActiveAdmins(ctx context.Context) (int64, error) { f.mu.Lock() defer f.mu.Unlock() var n int64 for _, m := range f.byUID { if m.IsAdmin() && m.Status != domain.StatusSuspended { n++ } } return n, nil } func (f *fakeRepo) FindByInviteCode(ctx context.Context, code string) (*domain.Member, error) { f.mu.Lock() defer f.mu.Unlock() code = strings.ToUpper(strings.TrimSpace(code)) for _, m := range f.byUID { if strings.ToUpper(strings.TrimSpace(m.InviteCode)) == code { cp := *m return &cp, nil } } return nil, domain.ErrNotFound } func (f *fakeRepo) ListAllMembers(ctx context.Context) ([]*domain.Member, error) { f.mu.Lock() defer f.mu.Unlock() out := make([]*domain.Member, 0, len(f.byUID)) for _, m := range f.byUID { cp := *m out = append(out, &cp) } return out, nil } func (f *fakeRepo) CreateIdentity(ctx context.Context, id *domain.Identity) error { f.mu.Lock() defer f.mu.Unlock() k := idKey(id.LoginID, id.Platform) if _, ok := f.identities[k]; ok { return domain.ErrIdentityTaken } cp := *id f.identities[k] = &cp f.byUIDIds[id.UID] = append(f.byUIDIds[id.UID], k) return nil } func (f *fakeRepo) FindIdentity(ctx context.Context, loginID, platform string) (*domain.Identity, error) { f.mu.Lock() defer f.mu.Unlock() id, ok := f.identities[idKey(loginID, platform)] if !ok { return nil, domain.ErrIdentityNotFound } cp := *id return &cp, nil } func (f *fakeRepo) ListIdentitiesByUID(ctx context.Context, uid int64) ([]*domain.Identity, error) { f.mu.Lock() defer f.mu.Unlock() var out []*domain.Identity for _, k := range f.byUIDIds[uid] { if id, ok := f.identities[k]; ok { cp := *id out = append(out, &cp) } } return out, nil } func (f *fakeRepo) DeleteIdentity(ctx context.Context, loginID, platform string) error { f.mu.Lock() defer f.mu.Unlock() k := idKey(loginID, platform) id, ok := f.identities[k] if !ok { return domain.ErrIdentityNotFound } delete(f.identities, k) var rest []string for _, x := range f.byUIDIds[id.UID] { if x != k { rest = append(rest, x) } } f.byUIDIds[id.UID] = rest return nil } func (f *fakeRepo) UpdateIdentityPassword(ctx context.Context, loginID, platform, passwordHash string) error { f.mu.Lock() defer f.mu.Unlock() id, ok := f.identities[idKey(loginID, platform)] if !ok { return domain.ErrIdentityNotFound } id.PasswordHash = passwordHash return nil } func (f *fakeRepo) SetCode(kind, key, code string, ttl time.Duration) bool { f.mu.Lock() defer f.mu.Unlock() f.codes[kind+":"+key] = code return true } func (f *fakeRepo) CheckCode(kind, key, code string) bool { f.mu.Lock() defer f.mu.Unlock() k := kind + ":" + key v, ok := f.codes[k] if !ok || v != code { return false } delete(f.codes, k) return true } func (f *fakeRepo) PeekCode(kind, key, code string) bool { f.mu.Lock() defer f.mu.Unlock() return f.codes[kind+":"+key] == code } func (f *fakeRepo) SaveRefresh(jti string, uid int64, exp time.Time) { f.mu.Lock() defer f.mu.Unlock() f.refresh[jti] = uid } func (f *fakeRepo) ConsumeRefresh(jti string) (int64, bool) { f.mu.Lock() defer f.mu.Unlock() uid, ok := f.refresh[jti] if !ok { return 0, false } delete(f.refresh, jti) return uid, true } func (f *fakeRepo) RevokeRefresh(jti string) { f.mu.Lock() defer f.mu.Unlock() delete(f.refresh, jti) } func (f *fakeRepo) RevokeAllRefreshByUID(ctx context.Context, uid int64) error { f.mu.Lock() defer f.mu.Unlock() for j, u := range f.refresh { if u == uid { delete(f.refresh, j) } } return nil } func (f *fakeRepo) GetSettings(ctx context.Context, uid int64) (*domain.UserSettings, error) { return domain.DefaultSettings(uid), nil } func (f *fakeRepo) SaveSettings(ctx context.Context, uid int64, s *domain.UserSettings) error { return nil } func TestAU_01_LoginOk(t *testing.T) { store := newFake() svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) const strong = "Admin123!@#x" m, tokens, err := svc.Register(context.Background(), "admin@haixun.local", strong, "Admin") require.NoError(t, err) m.Roles = []string{"admin", "member"} m.Status = domain.StatusActive m.EmailVerified = true require.NoError(t, store.UpdateMember(context.Background(), m)) m2, tokens2, err := svc.Login(context.Background(), "admin@haixun.local", strong) require.NoError(t, err) require.Equal(t, m.UID, m2.UID) require.True(t, m.UID >= domain.MinMemberUID) require.NotEmpty(t, tokens.AccessToken) require.NotEmpty(t, tokens2.AccessToken) } func TestAU_02_LoginBadPassword(t *testing.T) { store := newFake() svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) const strong = "Admin123!@#x" _, _, err := svc.Register(context.Background(), "admin@haixun.local", strong, "Admin") require.NoError(t, err) _, _, err = svc.Login(context.Background(), "admin@haixun.local", "wrong") require.ErrorIs(t, err, domain.ErrBadPassword) } func TestAR_02_RegisterOk(t *testing.T) { store := newFake() svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) m, tokens, err := svc.Register(context.Background(), "new@example.com", "Secret12!@#a", "New") require.NoError(t, err) require.True(t, m.UID >= domain.MinMemberUID) require.NotEmpty(t, tokens.AccessToken) ids, err := svc.ListIdentities(context.Background(), m.UID) require.NoError(t, err) require.Len(t, ids, 1) } func TestAU_07_Refresh(t *testing.T) { store := newFake() svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("sec-a", "sec-r", 3600, 86400), 10) _, tokens, err := svc.Register(context.Background(), "r@example.com", "Secret12!@#a", "R") require.NoError(t, err) next, err := svc.Refresh(context.Background(), tokens.RefreshToken) require.NoError(t, err) require.NotEmpty(t, next.AccessToken) _, err = svc.Refresh(context.Background(), tokens.RefreshToken) require.Error(t, err) } func TestBind_Unbind(t *testing.T) { store := newFake() svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) m, _, err := svc.Register(context.Background(), "b@example.com", "Secret12!@#a", "B") require.NoError(t, err) id, err := svc.BindAccount(context.Background(), m.UID, "g-sub-1", domain.PlatformGoogle, domain.AccountTypePlatform, "") require.NoError(t, err) require.Equal(t, "g-sub-1", id.LoginID) ids, _ := svc.ListIdentities(context.Background(), m.UID) require.Len(t, ids, 2) require.NoError(t, svc.UnbindAccount(context.Background(), m.UID, "g-sub-1", domain.PlatformGoogle)) err = svc.UnbindAccount(context.Background(), m.UID, "b@example.com", domain.PlatformPassword) require.Error(t, err) } func TestLoginOAuth_TrustedProviderIdentity(t *testing.T) { store := newFake() svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "g123", "u@g.com", "G123") require.NoError(t, err) require.NotEmpty(t, pair.AccessToken) require.True(t, m.UID >= domain.MinMemberUID) m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "g123", "u@g.com", "G123") require.NoError(t, err) require.Equal(t, m.UID, m2.UID) } func TestMember_UidIs8DigitsFrom1M(t *testing.T) { require.Equal(t, int64(1_000_000), domain.MinMemberUID) // 1_000_000 是 7 位;顯示補零成 8 位:01000000 require.Equal(t, "01000000", fmt.Sprintf("%08d", domain.MinMemberUID)) require.Equal(t, "01000001", fmt.Sprintf("%08d", domain.MinMemberUID+1)) }