325 lines
9.5 KiB
Go
325 lines
9.5 KiB
Go
package usecase_test
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"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) {
|
||
return nil, 0, nil
|
||
}
|
||
func (f *fakeRepo) CountActiveAdmins(ctx context.Context) (int64, error) { return 1, 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) {
|
||
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)
|
||
}
|
||
|
||
func TestLoginOAuth_Mock(t *testing.T) {
|
||
store := newFake()
|
||
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10)
|
||
sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:g123:u@g.com")
|
||
require.NoError(t, err)
|
||
require.Equal(t, "g123", sub)
|
||
m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, pair.AccessToken)
|
||
require.True(t, m.UID >= domain.MinMemberUID)
|
||
m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name)
|
||
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))
|
||
}
|