| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916 |
- //go:build unit
- package service
- import (
- "bytes"
- "context"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "errors"
- "image"
- "image/png"
- "sync"
- "sync/atomic"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- "github.com/stretchr/testify/require"
- )
- // --- mock: UserRepository ---
- type mockUserRepo struct {
- updateBalanceErr error
- updateBalanceFn func(ctx context.Context, id int64, amount float64) error
- deductBalanceFn func(ctx context.Context, id int64, amount float64) error
- deductAvailableBalanceFn func(ctx context.Context, id int64, amount float64) (float64, error)
- getByIDUser *User
- getByIDErr error
- identities []UserAuthIdentityRecord
- unbindIdentityErr error
- unboundProviders []string
- updateLastActiveErr error
- updateLastActiveUserIDs []int64
- updateLastActiveAt []time.Time
- updateFn func(ctx context.Context, user *User) error
- updateCalls int
- updateFields []UserUpdateFields
- upsertAvatarFn func(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error)
- upsertAvatarArgs []UpsertUserAvatarInput
- deleteAvatarFn func(ctx context.Context, userID int64) error
- deleteAvatarIDs []int64
- getAvatarFn func(ctx context.Context, userID int64) (*UserAvatar, error)
- txCalls int
- }
- type mockUserRepoTxKey struct{}
- type mockUserRepoTxState struct {
- getByIDUser *User
- upsertAvatarArgs []UpsertUserAvatarInput
- deleteAvatarIDs []int64
- }
- type mockUserSettingRepo struct {
- values map[string]string
- }
- func (m *mockUserSettingRepo) Get(context.Context, string) (*Setting, error) {
- panic("unexpected Get call")
- }
- func (m *mockUserSettingRepo) GetValue(context.Context, string) (string, error) {
- panic("unexpected GetValue call")
- }
- func (m *mockUserSettingRepo) Set(context.Context, string, string) error {
- panic("unexpected Set call")
- }
- func (m *mockUserSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
- out := make(map[string]string, len(keys))
- for _, key := range keys {
- if value, ok := m.values[key]; ok {
- out[key] = value
- }
- }
- return out, nil
- }
- func (m *mockUserSettingRepo) SetMultiple(context.Context, map[string]string) error {
- panic("unexpected SetMultiple call")
- }
- func (m *mockUserSettingRepo) GetAll(context.Context) (map[string]string, error) {
- panic("unexpected GetAll call")
- }
- func (m *mockUserSettingRepo) Delete(context.Context, string) error {
- panic("unexpected Delete call")
- }
- func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
- func (m *mockUserRepo) CreateWithEmailAliasGuard(context.Context, *User) error { return nil }
- func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) {
- if m.getByIDErr != nil {
- return nil, m.getByIDErr
- }
- if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil && txState.getByIDUser != nil {
- cloned := *txState.getByIDUser
- return &cloned, nil
- }
- if m.getByIDUser != nil {
- cloned := *m.getByIDUser
- return &cloned, nil
- }
- return &User{}, nil
- }
- func (m *mockUserRepo) GetByEmail(context.Context, string) (*User, error) { return &User{}, nil }
- func (m *mockUserRepo) GetFirstAdmin(context.Context) (*User, error) { return &User{}, nil }
- func (m *mockUserRepo) Update(ctx context.Context, user *User, fields UserUpdateFields) error {
- m.updateCalls++
- m.updateFields = append(m.updateFields, fields)
- if m.updateFn != nil {
- return m.updateFn(ctx, user)
- }
- return nil
- }
- func (m *mockUserRepo) Delete(context.Context, int64) error { return nil }
- func (m *mockUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error) {
- if m.getAvatarFn != nil {
- return m.getAvatarFn(ctx, userID)
- }
- return nil, nil
- }
- func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
- if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
- txState.upsertAvatarArgs = append(txState.upsertAvatarArgs, input)
- if txState.getByIDUser != nil {
- txState.getByIDUser.AvatarURL = input.URL
- txState.getByIDUser.AvatarSource = input.StorageProvider
- txState.getByIDUser.AvatarMIME = input.ContentType
- txState.getByIDUser.AvatarByteSize = input.ByteSize
- txState.getByIDUser.AvatarSHA256 = input.SHA256
- }
- if m.upsertAvatarFn != nil {
- return m.upsertAvatarFn(ctx, userID, input)
- }
- return &UserAvatar{
- StorageProvider: input.StorageProvider,
- StorageKey: input.StorageKey,
- URL: input.URL,
- ContentType: input.ContentType,
- ByteSize: input.ByteSize,
- SHA256: input.SHA256,
- }, nil
- }
- m.upsertAvatarArgs = append(m.upsertAvatarArgs, input)
- if m.upsertAvatarFn != nil {
- return m.upsertAvatarFn(ctx, userID, input)
- }
- return &UserAvatar{
- StorageProvider: input.StorageProvider,
- StorageKey: input.StorageKey,
- URL: input.URL,
- ContentType: input.ContentType,
- ByteSize: input.ByteSize,
- SHA256: input.SHA256,
- }, nil
- }
- func (m *mockUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
- if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
- txState.deleteAvatarIDs = append(txState.deleteAvatarIDs, userID)
- if txState.getByIDUser != nil {
- txState.getByIDUser.AvatarURL = ""
- txState.getByIDUser.AvatarSource = ""
- txState.getByIDUser.AvatarMIME = ""
- txState.getByIDUser.AvatarByteSize = 0
- txState.getByIDUser.AvatarSHA256 = ""
- }
- if m.deleteAvatarFn != nil {
- return m.deleteAvatarFn(ctx, userID)
- }
- return nil
- }
- m.deleteAvatarIDs = append(m.deleteAvatarIDs, userID)
- if m.deleteAvatarFn != nil {
- return m.deleteAvatarFn(ctx, userID)
- }
- return nil
- }
- func (m *mockUserRepo) List(context.Context, pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
- return nil, nil, nil
- }
- func (m *mockUserRepo) ListWithFilters(context.Context, pagination.PaginationParams, UserListFilters) ([]User, *pagination.PaginationResult, error) {
- return nil, nil, nil
- }
- func (m *mockUserRepo) UpdateBalance(ctx context.Context, id int64, amount float64) error {
- if m.updateBalanceFn != nil {
- return m.updateBalanceFn(ctx, id, amount)
- }
- return m.updateBalanceErr
- }
- func (m *mockUserRepo) UpdateUserLastActiveAt(_ context.Context, userID int64, activeAt time.Time) error {
- m.updateLastActiveUserIDs = append(m.updateLastActiveUserIDs, userID)
- m.updateLastActiveAt = append(m.updateLastActiveAt, activeAt)
- return m.updateLastActiveErr
- }
- func (m *mockUserRepo) DeductBalance(ctx context.Context, id int64, amount float64) error {
- if m.deductBalanceFn != nil {
- return m.deductBalanceFn(ctx, id, amount)
- }
- return nil
- }
- func (m *mockUserRepo) DeductAvailableBalance(ctx context.Context, id int64, amount float64) (float64, error) {
- if m.deductAvailableBalanceFn != nil {
- return m.deductAvailableBalanceFn(ctx, id, amount)
- }
- return amount, nil
- }
- func (m *mockUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
- panic("unexpected AdjustBalance call")
- }
- func (m *mockUserRepo) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
- panic("unexpected SetBalance call")
- }
- func (m *mockUserRepo) UpdateConcurrency(context.Context, int64, int) error { return nil }
- func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
- func (m *mockUserRepo) ExistsByEmailAlias(context.Context, string) (bool, error) {
- return false, nil
- }
- func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
- return 0, nil
- }
- func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
- func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
- func (m *mockUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
- return 0, nil
- }
- func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
- func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
- out := make([]UserAuthIdentityRecord, len(m.identities))
- copy(out, m.identities)
- return out, nil
- }
- func (m *mockUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
- return map[int64]*time.Time{}, nil
- }
- func (m *mockUserRepo) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) {
- return nil, nil
- }
- func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
- func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil }
- func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil }
- func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
- return nil
- }
- func (m *mockUserRepo) UnbindUserAuthProvider(_ context.Context, _ int64, provider string) error {
- if m.unbindIdentityErr != nil {
- return m.unbindIdentityErr
- }
- m.unboundProviders = append(m.unboundProviders, provider)
- filtered := m.identities[:0]
- for _, identity := range m.identities {
- if identity.ProviderType == provider {
- continue
- }
- filtered = append(filtered, identity)
- }
- m.identities = append([]UserAuthIdentityRecord(nil), filtered...)
- return nil
- }
- func (m *mockUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
- return m.GetByID(ctx, id)
- }
- func (m *mockUserRepo) WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error {
- m.txCalls++
- txState := &mockUserRepoTxState{
- upsertAvatarArgs: append([]UpsertUserAvatarInput(nil), m.upsertAvatarArgs...),
- deleteAvatarIDs: append([]int64(nil), m.deleteAvatarIDs...),
- }
- if m.getByIDUser != nil {
- userCopy := *m.getByIDUser
- txState.getByIDUser = &userCopy
- }
- err := fn(context.WithValue(ctx, mockUserRepoTxKey{}, txState))
- if err != nil {
- return err
- }
- m.getByIDUser = txState.getByIDUser
- m.upsertAvatarArgs = txState.upsertAvatarArgs
- m.deleteAvatarIDs = txState.deleteAvatarIDs
- return nil
- }
- // --- mock: APIKeyAuthCacheInvalidator ---
- type mockAuthCacheInvalidator struct {
- invalidatedUserIDs []int64
- mu sync.Mutex
- }
- func (m *mockAuthCacheInvalidator) InvalidateAuthCacheByKey(context.Context, string) {}
- func (m *mockAuthCacheInvalidator) InvalidateAuthCacheByGroupID(context.Context, int64) {}
- func (m *mockAuthCacheInvalidator) InvalidateAuthCacheByUserID(_ context.Context, userID int64) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.invalidatedUserIDs = append(m.invalidatedUserIDs, userID)
- }
- // --- mock: BillingCache ---
- type mockBillingCache struct {
- invalidateErr error
- invalidateCallCount atomic.Int64
- invalidatedUserIDs []int64
- mu sync.Mutex
- }
- func (m *mockBillingCache) GetUserBalance(context.Context, int64) (float64, error) { return 0, nil }
- func (m *mockBillingCache) SetUserBalance(context.Context, int64, float64) error { return nil }
- func (m *mockBillingCache) DeductUserBalance(context.Context, int64, float64) error { return nil }
- func (m *mockBillingCache) InvalidateUserBalance(_ context.Context, userID int64) error {
- m.invalidateCallCount.Add(1)
- m.mu.Lock()
- defer m.mu.Unlock()
- m.invalidatedUserIDs = append(m.invalidatedUserIDs, userID)
- return m.invalidateErr
- }
- func (m *mockBillingCache) GetSubscriptionCache(context.Context, int64, int64) (*SubscriptionCacheData, error) {
- return nil, nil
- }
- func (m *mockBillingCache) SetSubscriptionCache(context.Context, int64, int64, *SubscriptionCacheData) error {
- return nil
- }
- func (m *mockBillingCache) UpdateSubscriptionUsage(context.Context, int64, int64, float64) error {
- return nil
- }
- func (m *mockBillingCache) InvalidateSubscriptionCache(context.Context, int64, int64) error {
- return nil
- }
- func (m *mockBillingCache) GetAPIKeyRateLimit(context.Context, int64) (*APIKeyRateLimitCacheData, error) {
- return nil, nil
- }
- func (m *mockBillingCache) SetAPIKeyRateLimit(context.Context, int64, *APIKeyRateLimitCacheData) error {
- return nil
- }
- func (m *mockBillingCache) UpdateAPIKeyRateLimitUsage(context.Context, int64, float64) error {
- return nil
- }
- func (m *mockBillingCache) InvalidateAPIKeyRateLimit(context.Context, int64) error {
- return nil
- }
- func (m *mockBillingCache) GetUserPlatformQuotaCache(context.Context, int64, string) (*UserPlatformQuotaCacheEntry, bool, error) {
- return nil, false, nil
- }
- func (m *mockBillingCache) SetUserPlatformQuotaCache(context.Context, int64, string, *UserPlatformQuotaCacheEntry, time.Duration) error {
- return nil
- }
- func (m *mockBillingCache) DeleteUserPlatformQuotaCache(context.Context, int64, string) error {
- return nil
- }
- func (m *mockBillingCache) IncrUserPlatformQuotaUsageCache(context.Context, int64, string, float64, time.Duration, bool) error {
- return nil
- }
- func (m *mockBillingCache) PopDirtyUserPlatformQuotaKeys(context.Context, int) ([]UserPlatformQuotaKey, error) {
- return nil, nil
- }
- func (m *mockBillingCache) ReaddDirtyUserPlatformQuotaKeys(context.Context, []UserPlatformQuotaKey) error {
- return nil
- }
- func (m *mockBillingCache) BatchGetUserPlatformQuotaCache(context.Context, []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error) {
- return nil, nil
- }
- // --- 测试 ---
- func TestUpdateBalance_Success(t *testing.T) {
- repo := &mockUserRepo{}
- cache := &mockBillingCache{}
- svc := NewUserService(repo, nil, nil, cache)
- err := svc.UpdateBalance(context.Background(), 42, 100.0)
- require.NoError(t, err)
- // 等待异步 goroutine 完成
- require.Eventually(t, func() bool {
- return cache.invalidateCallCount.Load() == 1
- }, 2*time.Second, 10*time.Millisecond, "应异步调用 InvalidateUserBalance")
- cache.mu.Lock()
- defer cache.mu.Unlock()
- require.Equal(t, []int64{42}, cache.invalidatedUserIDs, "应对 userID=42 失效缓存")
- }
- func TestGetProfileIdentitySummaries_AllowsUnbindWhenAnotherLoginMethodRemains(t *testing.T) {
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 7,
- Email: "alice@example.com",
- },
- identities: []UserAuthIdentityRecord{
- {
- ProviderType: "email",
- ProviderKey: "email",
- ProviderSubject: "alice@example.com",
- },
- {
- ProviderType: "linuxdo",
- ProviderKey: "linuxdo",
- ProviderSubject: "linuxdo-subject-123456",
- Metadata: map[string]any{
- "username": "linuxdo-handle",
- },
- },
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 7, repo.getByIDUser)
- require.NoError(t, err)
- require.True(t, summaries.LinuxDo.Bound)
- require.True(t, summaries.LinuxDo.CanUnbind)
- require.Equal(t, "linuxdo-handle", summaries.LinuxDo.DisplayName)
- require.NotEmpty(t, summaries.LinuxDo.SubjectHint)
- }
- func TestUnbindUserAuthProviderRejectsLastRemainingLoginMethod(t *testing.T) {
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 9,
- Email: "only-user@linuxdo-connect.invalid",
- },
- identities: []UserAuthIdentityRecord{
- {
- ProviderType: "linuxdo",
- ProviderKey: "linuxdo",
- ProviderSubject: "linuxdo-only-subject",
- },
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- _, err := svc.UnbindUserAuthProvider(context.Background(), 9, "linuxdo")
- require.ErrorIs(t, err, ErrIdentityUnbindLastMethod)
- require.Empty(t, repo.unboundProviders)
- }
- func TestGetProfileIdentitySummaries_DoesNotTreatOAuthOnlyCompatEmailAsAlternativeLoginMethod(t *testing.T) {
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 10,
- Email: "oauth-only@example.com",
- SignupSource: "oidc",
- },
- identities: []UserAuthIdentityRecord{
- {
- ProviderType: "oidc",
- ProviderKey: "https://issuer.example.com",
- ProviderSubject: "oidc-only-subject",
- },
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 10, repo.getByIDUser)
- require.NoError(t, err)
- require.False(t, summaries.OIDC.CanUnbind)
- _, err = svc.UnbindUserAuthProvider(context.Background(), 10, "oidc")
- require.ErrorIs(t, err, ErrIdentityUnbindLastMethod)
- require.Empty(t, repo.unboundProviders)
- }
- func TestGetProfileIdentitySummaries_DoesNotTreatCompatBackfilledEmailIdentityAsAlternativeLoginMethod(t *testing.T) {
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 11,
- Email: "oauth-only@example.com",
- SignupSource: "wechat",
- },
- identities: []UserAuthIdentityRecord{
- {
- ProviderType: "email",
- ProviderKey: "email",
- ProviderSubject: "oauth-only@example.com",
- Metadata: map[string]any{
- "backfill_source": "users.email",
- "migration": "109_auth_identity_compat_backfill",
- },
- },
- {
- ProviderType: "wechat",
- ProviderKey: "wechat",
- ProviderSubject: "wechat-only-subject",
- },
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 11, repo.getByIDUser)
- require.NoError(t, err)
- require.True(t, summaries.Email.Bound)
- require.False(t, summaries.WeChat.CanUnbind)
- _, err = svc.UnbindUserAuthProvider(context.Background(), 11, "wechat")
- require.ErrorIs(t, err, ErrIdentityUnbindLastMethod)
- require.Empty(t, repo.unboundProviders)
- }
- func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testing.T) {
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 12,
- Email: "alice@example.com",
- },
- identities: []UserAuthIdentityRecord{
- {
- ProviderType: "email",
- ProviderKey: "email",
- ProviderSubject: "alice@example.com",
- },
- {
- ProviderType: "linuxdo",
- ProviderKey: "linuxdo",
- ProviderSubject: "linuxdo-subject-12",
- },
- },
- }
- invalidator := &mockAuthCacheInvalidator{}
- svc := NewUserService(repo, nil, invalidator, nil)
- user, err := svc.UnbindUserAuthProvider(context.Background(), 12, "linuxdo")
- require.NoError(t, err)
- require.Equal(t, []string{"linuxdo"}, repo.unboundProviders)
- require.Equal(t, int64(12), user.ID)
- require.Equal(t, []int64{12}, invalidator.invalidatedUserIDs)
- summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 12, user)
- require.NoError(t, err)
- require.False(t, summaries.LinuxDo.Bound)
- require.True(t, summaries.LinuxDo.CanBind)
- }
- func TestGetProfileIdentitySummaries_HidesBindActionWhenProviderExplicitlyDisabled(t *testing.T) {
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 15,
- Email: "alice@example.com",
- },
- identities: []UserAuthIdentityRecord{
- {
- ProviderType: "email",
- ProviderKey: "email",
- ProviderSubject: "alice@example.com",
- },
- },
- }
- settingRepo := &mockUserSettingRepo{
- values: map[string]string{
- SettingKeyLinuxDoConnectEnabled: "false",
- },
- }
- svc := NewUserService(repo, settingRepo, nil, nil)
- summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 15, repo.getByIDUser)
- require.NoError(t, err)
- require.False(t, summaries.LinuxDo.Bound)
- require.False(t, summaries.LinuxDo.CanBind)
- require.Empty(t, summaries.LinuxDo.BindStartPath)
- }
- func TestGetProfileIdentitySummaries_UsesBindStartRoute(t *testing.T) {
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 16,
- Email: "alice@example.com",
- },
- identities: []UserAuthIdentityRecord{
- {
- ProviderType: "email",
- ProviderKey: "email",
- ProviderSubject: "alice@example.com",
- },
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 16, repo.getByIDUser)
- require.NoError(t, err)
- require.Equal(
- t,
- "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile",
- summaries.LinuxDo.BindStartPath,
- )
- require.Equal(
- t,
- "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile",
- summaries.OIDC.BindStartPath,
- )
- require.Equal(
- t,
- "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile",
- summaries.WeChat.BindStartPath,
- )
- }
- func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
- repo := &mockUserRepo{}
- svc := NewUserService(repo, nil, nil, nil) // billingCache = nil
- err := svc.UpdateBalance(context.Background(), 1, 50.0)
- require.NoError(t, err, "billingCache 为 nil 时不应 panic")
- }
- func TestUpdateBalance_CacheFailure_DoesNotAffectReturn(t *testing.T) {
- repo := &mockUserRepo{}
- cache := &mockBillingCache{invalidateErr: errors.New("redis connection refused")}
- svc := NewUserService(repo, nil, nil, cache)
- err := svc.UpdateBalance(context.Background(), 99, 200.0)
- require.NoError(t, err, "缓存失效失败不应影响主流程返回值")
- // 等待异步 goroutine 完成(即使失败也应调用)
- require.Eventually(t, func() bool {
- return cache.invalidateCallCount.Load() == 1
- }, 2*time.Second, 10*time.Millisecond, "即使失败也应调用 InvalidateUserBalance")
- }
- func TestTouchLastActive_UpdatesWhenStale(t *testing.T) {
- stale := time.Now().Add(-11 * time.Minute)
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 42,
- LastActiveAt: &stale,
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- svc.TouchLastActive(context.Background(), 42)
- require.Equal(t, []int64{42}, repo.updateLastActiveUserIDs)
- require.Len(t, repo.updateLastActiveAt, 1)
- require.WithinDuration(t, time.Now(), repo.updateLastActiveAt[0], 2*time.Second)
- }
- func TestTouchLastActive_SkipsWhenRecent(t *testing.T) {
- recent := time.Now().Add(-time.Minute)
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 42,
- LastActiveAt: &recent,
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- svc.TouchLastActive(context.Background(), 42)
- require.Empty(t, repo.updateLastActiveUserIDs)
- require.Empty(t, repo.updateLastActiveAt)
- }
- func TestUpdateBalance_RepoError_ReturnsError(t *testing.T) {
- repo := &mockUserRepo{updateBalanceErr: errors.New("database error")}
- cache := &mockBillingCache{}
- svc := NewUserService(repo, nil, nil, cache)
- err := svc.UpdateBalance(context.Background(), 1, 100.0)
- require.Error(t, err, "repo 失败时应返回错误")
- require.Contains(t, err.Error(), "update balance")
- // repo 失败时不应触发缓存失效
- time.Sleep(100 * time.Millisecond)
- require.Equal(t, int64(0), cache.invalidateCallCount.Load(),
- "repo 失败时不应调用 InvalidateUserBalance")
- }
- func TestUpdateBalance_WithAuthCacheInvalidator(t *testing.T) {
- repo := &mockUserRepo{}
- auth := &mockAuthCacheInvalidator{}
- cache := &mockBillingCache{}
- svc := NewUserService(repo, nil, auth, cache)
- err := svc.UpdateBalance(context.Background(), 77, 300.0)
- require.NoError(t, err)
- // 验证 auth cache 同步失效
- auth.mu.Lock()
- require.Equal(t, []int64{77}, auth.invalidatedUserIDs)
- auth.mu.Unlock()
- // 验证 billing cache 异步失效
- require.Eventually(t, func() bool {
- return cache.invalidateCallCount.Load() == 1
- }, 2*time.Second, 10*time.Millisecond)
- }
- func TestNewUserService_FieldsAssignment(t *testing.T) {
- repo := &mockUserRepo{}
- auth := &mockAuthCacheInvalidator{}
- cache := &mockBillingCache{}
- svc := NewUserService(repo, nil, auth, cache)
- require.NotNil(t, svc)
- require.Equal(t, repo, svc.userRepo)
- require.Equal(t, auth, svc.authCacheInvalidator)
- require.Equal(t, cache, svc.billingCache)
- }
- func TestUpdateProfile_StoresInlineAvatarWithinLimit(t *testing.T) {
- raw := []byte("small-avatar")
- dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
- expectedSum := sha256.Sum256(raw)
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 7,
- Email: "avatar@example.com",
- Username: "avatar-user",
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- updated, err := svc.UpdateProfile(context.Background(), 7, UpdateProfileRequest{
- AvatarURL: &dataURL,
- })
- require.NoError(t, err)
- require.Len(t, repo.upsertAvatarArgs, 1)
- require.Equal(t, "inline", repo.upsertAvatarArgs[0].StorageProvider)
- require.Equal(t, "image/png", repo.upsertAvatarArgs[0].ContentType)
- require.Equal(t, len(raw), repo.upsertAvatarArgs[0].ByteSize)
- require.Equal(t, hex.EncodeToString(expectedSum[:]), repo.upsertAvatarArgs[0].SHA256)
- require.Equal(t, dataURL, updated.AvatarURL)
- require.Equal(t, "inline", updated.AvatarSource)
- require.Equal(t, "image/png", updated.AvatarMIME)
- require.Equal(t, len(raw), updated.AvatarByteSize)
- require.Equal(t, hex.EncodeToString(expectedSum[:]), updated.AvatarSHA256)
- }
- func TestUpdateProfile_CompressesInlineAvatarToTwentyKilobytes(t *testing.T) {
- var encoded bytes.Buffer
- for _, size := range []int{192, 224, 256, 288} {
- encoded.Reset()
- var img image.RGBA
- img.Rect = image.Rect(0, 0, size, size)
- img.Stride = size * 4
- img.Pix = make([]byte, size*size*4)
- for y := 0; y < size; y++ {
- for x := 0; x < size; x++ {
- offset := y*img.Stride + x*4
- img.Pix[offset] = uint8((x*x + y*17) % 255)
- img.Pix[offset+1] = uint8((y*y + x*29) % 255)
- img.Pix[offset+2] = uint8(((x * y) + x*13 + y*7) % 255)
- img.Pix[offset+3] = 0xff
- }
- }
- require.NoError(t, png.Encode(&encoded, &img))
- if encoded.Len() > 20*1024 && encoded.Len() <= maxInlineAvatarBytes {
- break
- }
- }
- require.Greater(t, encoded.Len(), 20*1024)
- require.LessOrEqual(t, encoded.Len(), maxInlineAvatarBytes)
- dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 17,
- Email: "avatar-compress@example.com",
- Username: "avatar-compress",
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- updated, err := svc.UpdateProfile(context.Background(), 17, UpdateProfileRequest{
- AvatarURL: &dataURL,
- })
- require.NoError(t, err)
- require.Len(t, repo.upsertAvatarArgs, 1)
- require.Equal(t, "inline", repo.upsertAvatarArgs[0].StorageProvider)
- require.LessOrEqual(t, repo.upsertAvatarArgs[0].ByteSize, 20*1024)
- require.Equal(t, "image/jpeg", repo.upsertAvatarArgs[0].ContentType)
- require.Contains(t, repo.upsertAvatarArgs[0].URL, "data:image/jpeg;base64,")
- require.Equal(t, "inline", updated.AvatarSource)
- require.Equal(t, "image/jpeg", updated.AvatarMIME)
- require.LessOrEqual(t, updated.AvatarByteSize, 20*1024)
- require.Contains(t, updated.AvatarURL, "data:image/jpeg;base64,")
- require.NotEmpty(t, updated.AvatarSHA256)
- }
- func TestUpdateProfile_RejectsInlineAvatarOverLimit(t *testing.T) {
- raw := make([]byte, maxInlineAvatarBytes+1)
- dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 8,
- Email: "large-avatar@example.com",
- Username: "too-large",
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- _, err := svc.UpdateProfile(context.Background(), 8, UpdateProfileRequest{
- AvatarURL: &dataURL,
- })
- require.ErrorIs(t, err, ErrAvatarTooLarge)
- require.Empty(t, repo.upsertAvatarArgs)
- require.Empty(t, repo.deleteAvatarIDs)
- require.Zero(t, repo.updateCalls)
- }
- func TestUpdateProfile_StoresRemoteAvatarURL(t *testing.T) {
- remoteURL := "https://cdn.example.com/avatar.png"
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 9,
- Email: "remote-avatar@example.com",
- Username: "remote-avatar",
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- updated, err := svc.UpdateProfile(context.Background(), 9, UpdateProfileRequest{
- AvatarURL: &remoteURL,
- })
- require.NoError(t, err)
- require.Len(t, repo.upsertAvatarArgs, 1)
- require.Equal(t, "remote_url", repo.upsertAvatarArgs[0].StorageProvider)
- require.Equal(t, remoteURL, repo.upsertAvatarArgs[0].URL)
- require.Equal(t, remoteURL, updated.AvatarURL)
- require.Equal(t, "remote_url", updated.AvatarSource)
- require.Zero(t, updated.AvatarByteSize)
- }
- func TestUpdateProfile_DeletesAvatarOnEmptyString(t *testing.T) {
- empty := ""
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 10,
- Email: "delete-avatar@example.com",
- Username: "delete-avatar",
- AvatarURL: "https://cdn.example.com/old.png",
- AvatarSource: "remote_url",
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- updated, err := svc.UpdateProfile(context.Background(), 10, UpdateProfileRequest{
- AvatarURL: &empty,
- })
- require.NoError(t, err)
- require.Equal(t, []int64{10}, repo.deleteAvatarIDs)
- require.Empty(t, repo.upsertAvatarArgs)
- require.Empty(t, updated.AvatarURL)
- require.Empty(t, updated.AvatarSource)
- }
- func TestUpdateProfile_RollsBackAvatarMutationWhenUserUpdateFails(t *testing.T) {
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 11,
- Email: "rollback@example.com",
- AvatarURL: "https://cdn.example.com/original.png",
- AvatarSource: "remote_url",
- },
- updateFn: func(context.Context, *User) error {
- return errors.New("write user failed")
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- remoteURL := "https://cdn.example.com/new.png"
- _, err := svc.UpdateProfile(context.Background(), 11, UpdateProfileRequest{
- AvatarURL: &remoteURL,
- })
- require.EqualError(t, err, "update user: write user failed")
- require.Equal(t, 1, repo.txCalls)
- require.Empty(t, repo.upsertAvatarArgs)
- require.Empty(t, repo.deleteAvatarIDs)
- require.Equal(t, "https://cdn.example.com/original.png", repo.getByIDUser.AvatarURL)
- require.Equal(t, "remote_url", repo.getByIDUser.AvatarSource)
- }
- func TestGetProfile_HydratesAvatarFromRepository(t *testing.T) {
- repo := &mockUserRepo{
- getByIDUser: &User{
- ID: 12,
- Email: "profile-avatar@example.com",
- Username: "profile-avatar",
- },
- getAvatarFn: func(context.Context, int64) (*UserAvatar, error) {
- return &UserAvatar{
- StorageProvider: "remote_url",
- URL: "https://cdn.example.com/profile.png",
- }, nil
- },
- }
- svc := NewUserService(repo, nil, nil, nil)
- user, err := svc.GetProfile(context.Background(), 12)
- require.NoError(t, err)
- require.Equal(t, "https://cdn.example.com/profile.png", user.AvatarURL)
- require.Equal(t, "remote_url", user.AvatarSource)
- }
|