thread-master/apps/backend/internal/module/member/usecase/auth_test.go

391 lines
11 KiB
Go
Raw Normal View History

2026-07-10 12:54:45 +00:00
package usecase_test
import (
"context"
"fmt"
2026-07-13 01:15:30 +00:00
"strings"
2026-07-10 12:54:45 +00:00
"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) {
2026-07-13 01:15:30 +00:00
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
2026-07-10 12:54:45 +00:00
}
2026-07-13 03:18:08 +00:00
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
}
2026-07-10 12:54:45 +00:00
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) {
f.mu.Lock()
defer f.mu.Unlock()
f.codes[kind+":"+key] = code
}
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)
}
2026-07-13 08:59:13 +00:00
func TestLoginOAuth_TrustedProviderIdentity(t *testing.T) {
2026-07-10 12:54:45 +00:00
store := newFake()
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10)
2026-07-13 08:59:13 +00:00
m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "g123", "u@g.com", "G123")
2026-07-10 12:54:45 +00:00
require.NoError(t, err)
require.NotEmpty(t, pair.AccessToken)
require.True(t, m.UID >= domain.MinMemberUID)
2026-07-13 08:59:13 +00:00
m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "g123", "u@g.com", "G123")
2026-07-10 12:54:45 +00:00
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))
}