user_service_test.go 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "crypto/sha256"
  7. "encoding/base64"
  8. "encoding/hex"
  9. "errors"
  10. "image"
  11. "image/png"
  12. "sync"
  13. "sync/atomic"
  14. "testing"
  15. "time"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  17. "github.com/stretchr/testify/require"
  18. )
  19. // --- mock: UserRepository ---
  20. type mockUserRepo struct {
  21. updateBalanceErr error
  22. updateBalanceFn func(ctx context.Context, id int64, amount float64) error
  23. deductBalanceFn func(ctx context.Context, id int64, amount float64) error
  24. deductAvailableBalanceFn func(ctx context.Context, id int64, amount float64) (float64, error)
  25. getByIDUser *User
  26. getByIDErr error
  27. identities []UserAuthIdentityRecord
  28. unbindIdentityErr error
  29. unboundProviders []string
  30. updateLastActiveErr error
  31. updateLastActiveUserIDs []int64
  32. updateLastActiveAt []time.Time
  33. updateFn func(ctx context.Context, user *User) error
  34. updateCalls int
  35. updateFields []UserUpdateFields
  36. upsertAvatarFn func(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error)
  37. upsertAvatarArgs []UpsertUserAvatarInput
  38. deleteAvatarFn func(ctx context.Context, userID int64) error
  39. deleteAvatarIDs []int64
  40. getAvatarFn func(ctx context.Context, userID int64) (*UserAvatar, error)
  41. txCalls int
  42. }
  43. type mockUserRepoTxKey struct{}
  44. type mockUserRepoTxState struct {
  45. getByIDUser *User
  46. upsertAvatarArgs []UpsertUserAvatarInput
  47. deleteAvatarIDs []int64
  48. }
  49. type mockUserSettingRepo struct {
  50. values map[string]string
  51. }
  52. func (m *mockUserSettingRepo) Get(context.Context, string) (*Setting, error) {
  53. panic("unexpected Get call")
  54. }
  55. func (m *mockUserSettingRepo) GetValue(context.Context, string) (string, error) {
  56. panic("unexpected GetValue call")
  57. }
  58. func (m *mockUserSettingRepo) Set(context.Context, string, string) error {
  59. panic("unexpected Set call")
  60. }
  61. func (m *mockUserSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
  62. out := make(map[string]string, len(keys))
  63. for _, key := range keys {
  64. if value, ok := m.values[key]; ok {
  65. out[key] = value
  66. }
  67. }
  68. return out, nil
  69. }
  70. func (m *mockUserSettingRepo) SetMultiple(context.Context, map[string]string) error {
  71. panic("unexpected SetMultiple call")
  72. }
  73. func (m *mockUserSettingRepo) GetAll(context.Context) (map[string]string, error) {
  74. panic("unexpected GetAll call")
  75. }
  76. func (m *mockUserSettingRepo) Delete(context.Context, string) error {
  77. panic("unexpected Delete call")
  78. }
  79. func (m *mockUserRepo) Create(context.Context, *User) error { return nil }
  80. func (m *mockUserRepo) CreateWithEmailAliasGuard(context.Context, *User) error { return nil }
  81. func (m *mockUserRepo) GetByID(ctx context.Context, _ int64) (*User, error) {
  82. if m.getByIDErr != nil {
  83. return nil, m.getByIDErr
  84. }
  85. if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil && txState.getByIDUser != nil {
  86. cloned := *txState.getByIDUser
  87. return &cloned, nil
  88. }
  89. if m.getByIDUser != nil {
  90. cloned := *m.getByIDUser
  91. return &cloned, nil
  92. }
  93. return &User{}, nil
  94. }
  95. func (m *mockUserRepo) GetByEmail(context.Context, string) (*User, error) { return &User{}, nil }
  96. func (m *mockUserRepo) GetFirstAdmin(context.Context) (*User, error) { return &User{}, nil }
  97. func (m *mockUserRepo) Update(ctx context.Context, user *User, fields UserUpdateFields) error {
  98. m.updateCalls++
  99. m.updateFields = append(m.updateFields, fields)
  100. if m.updateFn != nil {
  101. return m.updateFn(ctx, user)
  102. }
  103. return nil
  104. }
  105. func (m *mockUserRepo) Delete(context.Context, int64) error { return nil }
  106. func (m *mockUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error) {
  107. if m.getAvatarFn != nil {
  108. return m.getAvatarFn(ctx, userID)
  109. }
  110. return nil, nil
  111. }
  112. func (m *mockUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
  113. if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
  114. txState.upsertAvatarArgs = append(txState.upsertAvatarArgs, input)
  115. if txState.getByIDUser != nil {
  116. txState.getByIDUser.AvatarURL = input.URL
  117. txState.getByIDUser.AvatarSource = input.StorageProvider
  118. txState.getByIDUser.AvatarMIME = input.ContentType
  119. txState.getByIDUser.AvatarByteSize = input.ByteSize
  120. txState.getByIDUser.AvatarSHA256 = input.SHA256
  121. }
  122. if m.upsertAvatarFn != nil {
  123. return m.upsertAvatarFn(ctx, userID, input)
  124. }
  125. return &UserAvatar{
  126. StorageProvider: input.StorageProvider,
  127. StorageKey: input.StorageKey,
  128. URL: input.URL,
  129. ContentType: input.ContentType,
  130. ByteSize: input.ByteSize,
  131. SHA256: input.SHA256,
  132. }, nil
  133. }
  134. m.upsertAvatarArgs = append(m.upsertAvatarArgs, input)
  135. if m.upsertAvatarFn != nil {
  136. return m.upsertAvatarFn(ctx, userID, input)
  137. }
  138. return &UserAvatar{
  139. StorageProvider: input.StorageProvider,
  140. StorageKey: input.StorageKey,
  141. URL: input.URL,
  142. ContentType: input.ContentType,
  143. ByteSize: input.ByteSize,
  144. SHA256: input.SHA256,
  145. }, nil
  146. }
  147. func (m *mockUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
  148. if txState, _ := ctx.Value(mockUserRepoTxKey{}).(*mockUserRepoTxState); txState != nil {
  149. txState.deleteAvatarIDs = append(txState.deleteAvatarIDs, userID)
  150. if txState.getByIDUser != nil {
  151. txState.getByIDUser.AvatarURL = ""
  152. txState.getByIDUser.AvatarSource = ""
  153. txState.getByIDUser.AvatarMIME = ""
  154. txState.getByIDUser.AvatarByteSize = 0
  155. txState.getByIDUser.AvatarSHA256 = ""
  156. }
  157. if m.deleteAvatarFn != nil {
  158. return m.deleteAvatarFn(ctx, userID)
  159. }
  160. return nil
  161. }
  162. m.deleteAvatarIDs = append(m.deleteAvatarIDs, userID)
  163. if m.deleteAvatarFn != nil {
  164. return m.deleteAvatarFn(ctx, userID)
  165. }
  166. return nil
  167. }
  168. func (m *mockUserRepo) List(context.Context, pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
  169. return nil, nil, nil
  170. }
  171. func (m *mockUserRepo) ListWithFilters(context.Context, pagination.PaginationParams, UserListFilters) ([]User, *pagination.PaginationResult, error) {
  172. return nil, nil, nil
  173. }
  174. func (m *mockUserRepo) UpdateBalance(ctx context.Context, id int64, amount float64) error {
  175. if m.updateBalanceFn != nil {
  176. return m.updateBalanceFn(ctx, id, amount)
  177. }
  178. return m.updateBalanceErr
  179. }
  180. func (m *mockUserRepo) UpdateUserLastActiveAt(_ context.Context, userID int64, activeAt time.Time) error {
  181. m.updateLastActiveUserIDs = append(m.updateLastActiveUserIDs, userID)
  182. m.updateLastActiveAt = append(m.updateLastActiveAt, activeAt)
  183. return m.updateLastActiveErr
  184. }
  185. func (m *mockUserRepo) DeductBalance(ctx context.Context, id int64, amount float64) error {
  186. if m.deductBalanceFn != nil {
  187. return m.deductBalanceFn(ctx, id, amount)
  188. }
  189. return nil
  190. }
  191. func (m *mockUserRepo) DeductAvailableBalance(ctx context.Context, id int64, amount float64) (float64, error) {
  192. if m.deductAvailableBalanceFn != nil {
  193. return m.deductAvailableBalanceFn(ctx, id, amount)
  194. }
  195. return amount, nil
  196. }
  197. func (m *mockUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
  198. panic("unexpected AdjustBalance call")
  199. }
  200. func (m *mockUserRepo) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
  201. panic("unexpected SetBalance call")
  202. }
  203. func (m *mockUserRepo) UpdateConcurrency(context.Context, int64, int) error { return nil }
  204. func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
  205. func (m *mockUserRepo) ExistsByEmailAlias(context.Context, string) (bool, error) {
  206. return false, nil
  207. }
  208. func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
  209. return 0, nil
  210. }
  211. func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
  212. func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
  213. func (m *mockUserRepo) BatchUpdateLimits(context.Context, []int64, *int, *int) (int, error) {
  214. return 0, nil
  215. }
  216. func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
  217. func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
  218. out := make([]UserAuthIdentityRecord, len(m.identities))
  219. copy(out, m.identities)
  220. return out, nil
  221. }
  222. func (m *mockUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
  223. return map[int64]*time.Time{}, nil
  224. }
  225. func (m *mockUserRepo) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) {
  226. return nil, nil
  227. }
  228. func (m *mockUserRepo) UpdateTotpSecret(context.Context, int64, *string) error { return nil }
  229. func (m *mockUserRepo) EnableTotp(context.Context, int64) error { return nil }
  230. func (m *mockUserRepo) DisableTotp(context.Context, int64) error { return nil }
  231. func (m *mockUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
  232. return nil
  233. }
  234. func (m *mockUserRepo) UnbindUserAuthProvider(_ context.Context, _ int64, provider string) error {
  235. if m.unbindIdentityErr != nil {
  236. return m.unbindIdentityErr
  237. }
  238. m.unboundProviders = append(m.unboundProviders, provider)
  239. filtered := m.identities[:0]
  240. for _, identity := range m.identities {
  241. if identity.ProviderType == provider {
  242. continue
  243. }
  244. filtered = append(filtered, identity)
  245. }
  246. m.identities = append([]UserAuthIdentityRecord(nil), filtered...)
  247. return nil
  248. }
  249. func (m *mockUserRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*User, error) {
  250. return m.GetByID(ctx, id)
  251. }
  252. func (m *mockUserRepo) WithUserProfileIdentityTx(ctx context.Context, fn func(txCtx context.Context) error) error {
  253. m.txCalls++
  254. txState := &mockUserRepoTxState{
  255. upsertAvatarArgs: append([]UpsertUserAvatarInput(nil), m.upsertAvatarArgs...),
  256. deleteAvatarIDs: append([]int64(nil), m.deleteAvatarIDs...),
  257. }
  258. if m.getByIDUser != nil {
  259. userCopy := *m.getByIDUser
  260. txState.getByIDUser = &userCopy
  261. }
  262. err := fn(context.WithValue(ctx, mockUserRepoTxKey{}, txState))
  263. if err != nil {
  264. return err
  265. }
  266. m.getByIDUser = txState.getByIDUser
  267. m.upsertAvatarArgs = txState.upsertAvatarArgs
  268. m.deleteAvatarIDs = txState.deleteAvatarIDs
  269. return nil
  270. }
  271. // --- mock: APIKeyAuthCacheInvalidator ---
  272. type mockAuthCacheInvalidator struct {
  273. invalidatedUserIDs []int64
  274. mu sync.Mutex
  275. }
  276. func (m *mockAuthCacheInvalidator) InvalidateAuthCacheByKey(context.Context, string) {}
  277. func (m *mockAuthCacheInvalidator) InvalidateAuthCacheByGroupID(context.Context, int64) {}
  278. func (m *mockAuthCacheInvalidator) InvalidateAuthCacheByUserID(_ context.Context, userID int64) {
  279. m.mu.Lock()
  280. defer m.mu.Unlock()
  281. m.invalidatedUserIDs = append(m.invalidatedUserIDs, userID)
  282. }
  283. // --- mock: BillingCache ---
  284. type mockBillingCache struct {
  285. invalidateErr error
  286. invalidateCallCount atomic.Int64
  287. invalidatedUserIDs []int64
  288. mu sync.Mutex
  289. }
  290. func (m *mockBillingCache) GetUserBalance(context.Context, int64) (float64, error) { return 0, nil }
  291. func (m *mockBillingCache) SetUserBalance(context.Context, int64, float64) error { return nil }
  292. func (m *mockBillingCache) DeductUserBalance(context.Context, int64, float64) error { return nil }
  293. func (m *mockBillingCache) InvalidateUserBalance(_ context.Context, userID int64) error {
  294. m.invalidateCallCount.Add(1)
  295. m.mu.Lock()
  296. defer m.mu.Unlock()
  297. m.invalidatedUserIDs = append(m.invalidatedUserIDs, userID)
  298. return m.invalidateErr
  299. }
  300. func (m *mockBillingCache) GetSubscriptionCache(context.Context, int64, int64) (*SubscriptionCacheData, error) {
  301. return nil, nil
  302. }
  303. func (m *mockBillingCache) SetSubscriptionCache(context.Context, int64, int64, *SubscriptionCacheData) error {
  304. return nil
  305. }
  306. func (m *mockBillingCache) UpdateSubscriptionUsage(context.Context, int64, int64, float64) error {
  307. return nil
  308. }
  309. func (m *mockBillingCache) InvalidateSubscriptionCache(context.Context, int64, int64) error {
  310. return nil
  311. }
  312. func (m *mockBillingCache) GetAPIKeyRateLimit(context.Context, int64) (*APIKeyRateLimitCacheData, error) {
  313. return nil, nil
  314. }
  315. func (m *mockBillingCache) SetAPIKeyRateLimit(context.Context, int64, *APIKeyRateLimitCacheData) error {
  316. return nil
  317. }
  318. func (m *mockBillingCache) UpdateAPIKeyRateLimitUsage(context.Context, int64, float64) error {
  319. return nil
  320. }
  321. func (m *mockBillingCache) InvalidateAPIKeyRateLimit(context.Context, int64) error {
  322. return nil
  323. }
  324. func (m *mockBillingCache) GetUserPlatformQuotaCache(context.Context, int64, string) (*UserPlatformQuotaCacheEntry, bool, error) {
  325. return nil, false, nil
  326. }
  327. func (m *mockBillingCache) SetUserPlatformQuotaCache(context.Context, int64, string, *UserPlatformQuotaCacheEntry, time.Duration) error {
  328. return nil
  329. }
  330. func (m *mockBillingCache) DeleteUserPlatformQuotaCache(context.Context, int64, string) error {
  331. return nil
  332. }
  333. func (m *mockBillingCache) IncrUserPlatformQuotaUsageCache(context.Context, int64, string, float64, time.Duration, bool) error {
  334. return nil
  335. }
  336. func (m *mockBillingCache) PopDirtyUserPlatformQuotaKeys(context.Context, int) ([]UserPlatformQuotaKey, error) {
  337. return nil, nil
  338. }
  339. func (m *mockBillingCache) ReaddDirtyUserPlatformQuotaKeys(context.Context, []UserPlatformQuotaKey) error {
  340. return nil
  341. }
  342. func (m *mockBillingCache) BatchGetUserPlatformQuotaCache(context.Context, []UserPlatformQuotaKey) ([]*UserPlatformQuotaCacheEntry, error) {
  343. return nil, nil
  344. }
  345. // --- 测试 ---
  346. func TestUpdateBalance_Success(t *testing.T) {
  347. repo := &mockUserRepo{}
  348. cache := &mockBillingCache{}
  349. svc := NewUserService(repo, nil, nil, cache)
  350. err := svc.UpdateBalance(context.Background(), 42, 100.0)
  351. require.NoError(t, err)
  352. // 等待异步 goroutine 完成
  353. require.Eventually(t, func() bool {
  354. return cache.invalidateCallCount.Load() == 1
  355. }, 2*time.Second, 10*time.Millisecond, "应异步调用 InvalidateUserBalance")
  356. cache.mu.Lock()
  357. defer cache.mu.Unlock()
  358. require.Equal(t, []int64{42}, cache.invalidatedUserIDs, "应对 userID=42 失效缓存")
  359. }
  360. func TestGetProfileIdentitySummaries_AllowsUnbindWhenAnotherLoginMethodRemains(t *testing.T) {
  361. repo := &mockUserRepo{
  362. getByIDUser: &User{
  363. ID: 7,
  364. Email: "alice@example.com",
  365. },
  366. identities: []UserAuthIdentityRecord{
  367. {
  368. ProviderType: "email",
  369. ProviderKey: "email",
  370. ProviderSubject: "alice@example.com",
  371. },
  372. {
  373. ProviderType: "linuxdo",
  374. ProviderKey: "linuxdo",
  375. ProviderSubject: "linuxdo-subject-123456",
  376. Metadata: map[string]any{
  377. "username": "linuxdo-handle",
  378. },
  379. },
  380. },
  381. }
  382. svc := NewUserService(repo, nil, nil, nil)
  383. summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 7, repo.getByIDUser)
  384. require.NoError(t, err)
  385. require.True(t, summaries.LinuxDo.Bound)
  386. require.True(t, summaries.LinuxDo.CanUnbind)
  387. require.Equal(t, "linuxdo-handle", summaries.LinuxDo.DisplayName)
  388. require.NotEmpty(t, summaries.LinuxDo.SubjectHint)
  389. }
  390. func TestUnbindUserAuthProviderRejectsLastRemainingLoginMethod(t *testing.T) {
  391. repo := &mockUserRepo{
  392. getByIDUser: &User{
  393. ID: 9,
  394. Email: "only-user@linuxdo-connect.invalid",
  395. },
  396. identities: []UserAuthIdentityRecord{
  397. {
  398. ProviderType: "linuxdo",
  399. ProviderKey: "linuxdo",
  400. ProviderSubject: "linuxdo-only-subject",
  401. },
  402. },
  403. }
  404. svc := NewUserService(repo, nil, nil, nil)
  405. _, err := svc.UnbindUserAuthProvider(context.Background(), 9, "linuxdo")
  406. require.ErrorIs(t, err, ErrIdentityUnbindLastMethod)
  407. require.Empty(t, repo.unboundProviders)
  408. }
  409. func TestGetProfileIdentitySummaries_DoesNotTreatOAuthOnlyCompatEmailAsAlternativeLoginMethod(t *testing.T) {
  410. repo := &mockUserRepo{
  411. getByIDUser: &User{
  412. ID: 10,
  413. Email: "oauth-only@example.com",
  414. SignupSource: "oidc",
  415. },
  416. identities: []UserAuthIdentityRecord{
  417. {
  418. ProviderType: "oidc",
  419. ProviderKey: "https://issuer.example.com",
  420. ProviderSubject: "oidc-only-subject",
  421. },
  422. },
  423. }
  424. svc := NewUserService(repo, nil, nil, nil)
  425. summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 10, repo.getByIDUser)
  426. require.NoError(t, err)
  427. require.False(t, summaries.OIDC.CanUnbind)
  428. _, err = svc.UnbindUserAuthProvider(context.Background(), 10, "oidc")
  429. require.ErrorIs(t, err, ErrIdentityUnbindLastMethod)
  430. require.Empty(t, repo.unboundProviders)
  431. }
  432. func TestGetProfileIdentitySummaries_DoesNotTreatCompatBackfilledEmailIdentityAsAlternativeLoginMethod(t *testing.T) {
  433. repo := &mockUserRepo{
  434. getByIDUser: &User{
  435. ID: 11,
  436. Email: "oauth-only@example.com",
  437. SignupSource: "wechat",
  438. },
  439. identities: []UserAuthIdentityRecord{
  440. {
  441. ProviderType: "email",
  442. ProviderKey: "email",
  443. ProviderSubject: "oauth-only@example.com",
  444. Metadata: map[string]any{
  445. "backfill_source": "users.email",
  446. "migration": "109_auth_identity_compat_backfill",
  447. },
  448. },
  449. {
  450. ProviderType: "wechat",
  451. ProviderKey: "wechat",
  452. ProviderSubject: "wechat-only-subject",
  453. },
  454. },
  455. }
  456. svc := NewUserService(repo, nil, nil, nil)
  457. summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 11, repo.getByIDUser)
  458. require.NoError(t, err)
  459. require.True(t, summaries.Email.Bound)
  460. require.False(t, summaries.WeChat.CanUnbind)
  461. _, err = svc.UnbindUserAuthProvider(context.Background(), 11, "wechat")
  462. require.ErrorIs(t, err, ErrIdentityUnbindLastMethod)
  463. require.Empty(t, repo.unboundProviders)
  464. }
  465. func TestUnbindUserAuthProviderRemovesProviderAndReturnsUpdatedProfile(t *testing.T) {
  466. repo := &mockUserRepo{
  467. getByIDUser: &User{
  468. ID: 12,
  469. Email: "alice@example.com",
  470. },
  471. identities: []UserAuthIdentityRecord{
  472. {
  473. ProviderType: "email",
  474. ProviderKey: "email",
  475. ProviderSubject: "alice@example.com",
  476. },
  477. {
  478. ProviderType: "linuxdo",
  479. ProviderKey: "linuxdo",
  480. ProviderSubject: "linuxdo-subject-12",
  481. },
  482. },
  483. }
  484. invalidator := &mockAuthCacheInvalidator{}
  485. svc := NewUserService(repo, nil, invalidator, nil)
  486. user, err := svc.UnbindUserAuthProvider(context.Background(), 12, "linuxdo")
  487. require.NoError(t, err)
  488. require.Equal(t, []string{"linuxdo"}, repo.unboundProviders)
  489. require.Equal(t, int64(12), user.ID)
  490. require.Equal(t, []int64{12}, invalidator.invalidatedUserIDs)
  491. summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 12, user)
  492. require.NoError(t, err)
  493. require.False(t, summaries.LinuxDo.Bound)
  494. require.True(t, summaries.LinuxDo.CanBind)
  495. }
  496. func TestGetProfileIdentitySummaries_HidesBindActionWhenProviderExplicitlyDisabled(t *testing.T) {
  497. repo := &mockUserRepo{
  498. getByIDUser: &User{
  499. ID: 15,
  500. Email: "alice@example.com",
  501. },
  502. identities: []UserAuthIdentityRecord{
  503. {
  504. ProviderType: "email",
  505. ProviderKey: "email",
  506. ProviderSubject: "alice@example.com",
  507. },
  508. },
  509. }
  510. settingRepo := &mockUserSettingRepo{
  511. values: map[string]string{
  512. SettingKeyLinuxDoConnectEnabled: "false",
  513. },
  514. }
  515. svc := NewUserService(repo, settingRepo, nil, nil)
  516. summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 15, repo.getByIDUser)
  517. require.NoError(t, err)
  518. require.False(t, summaries.LinuxDo.Bound)
  519. require.False(t, summaries.LinuxDo.CanBind)
  520. require.Empty(t, summaries.LinuxDo.BindStartPath)
  521. }
  522. func TestGetProfileIdentitySummaries_UsesBindStartRoute(t *testing.T) {
  523. repo := &mockUserRepo{
  524. getByIDUser: &User{
  525. ID: 16,
  526. Email: "alice@example.com",
  527. },
  528. identities: []UserAuthIdentityRecord{
  529. {
  530. ProviderType: "email",
  531. ProviderKey: "email",
  532. ProviderSubject: "alice@example.com",
  533. },
  534. },
  535. }
  536. svc := NewUserService(repo, nil, nil, nil)
  537. summaries, err := svc.GetProfileIdentitySummaries(context.Background(), 16, repo.getByIDUser)
  538. require.NoError(t, err)
  539. require.Equal(
  540. t,
  541. "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile",
  542. summaries.LinuxDo.BindStartPath,
  543. )
  544. require.Equal(
  545. t,
  546. "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile",
  547. summaries.OIDC.BindStartPath,
  548. )
  549. require.Equal(
  550. t,
  551. "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile",
  552. summaries.WeChat.BindStartPath,
  553. )
  554. }
  555. func TestUpdateBalance_NilBillingCache_NoPanic(t *testing.T) {
  556. repo := &mockUserRepo{}
  557. svc := NewUserService(repo, nil, nil, nil) // billingCache = nil
  558. err := svc.UpdateBalance(context.Background(), 1, 50.0)
  559. require.NoError(t, err, "billingCache 为 nil 时不应 panic")
  560. }
  561. func TestUpdateBalance_CacheFailure_DoesNotAffectReturn(t *testing.T) {
  562. repo := &mockUserRepo{}
  563. cache := &mockBillingCache{invalidateErr: errors.New("redis connection refused")}
  564. svc := NewUserService(repo, nil, nil, cache)
  565. err := svc.UpdateBalance(context.Background(), 99, 200.0)
  566. require.NoError(t, err, "缓存失效失败不应影响主流程返回值")
  567. // 等待异步 goroutine 完成(即使失败也应调用)
  568. require.Eventually(t, func() bool {
  569. return cache.invalidateCallCount.Load() == 1
  570. }, 2*time.Second, 10*time.Millisecond, "即使失败也应调用 InvalidateUserBalance")
  571. }
  572. func TestTouchLastActive_UpdatesWhenStale(t *testing.T) {
  573. stale := time.Now().Add(-11 * time.Minute)
  574. repo := &mockUserRepo{
  575. getByIDUser: &User{
  576. ID: 42,
  577. LastActiveAt: &stale,
  578. },
  579. }
  580. svc := NewUserService(repo, nil, nil, nil)
  581. svc.TouchLastActive(context.Background(), 42)
  582. require.Equal(t, []int64{42}, repo.updateLastActiveUserIDs)
  583. require.Len(t, repo.updateLastActiveAt, 1)
  584. require.WithinDuration(t, time.Now(), repo.updateLastActiveAt[0], 2*time.Second)
  585. }
  586. func TestTouchLastActive_SkipsWhenRecent(t *testing.T) {
  587. recent := time.Now().Add(-time.Minute)
  588. repo := &mockUserRepo{
  589. getByIDUser: &User{
  590. ID: 42,
  591. LastActiveAt: &recent,
  592. },
  593. }
  594. svc := NewUserService(repo, nil, nil, nil)
  595. svc.TouchLastActive(context.Background(), 42)
  596. require.Empty(t, repo.updateLastActiveUserIDs)
  597. require.Empty(t, repo.updateLastActiveAt)
  598. }
  599. func TestUpdateBalance_RepoError_ReturnsError(t *testing.T) {
  600. repo := &mockUserRepo{updateBalanceErr: errors.New("database error")}
  601. cache := &mockBillingCache{}
  602. svc := NewUserService(repo, nil, nil, cache)
  603. err := svc.UpdateBalance(context.Background(), 1, 100.0)
  604. require.Error(t, err, "repo 失败时应返回错误")
  605. require.Contains(t, err.Error(), "update balance")
  606. // repo 失败时不应触发缓存失效
  607. time.Sleep(100 * time.Millisecond)
  608. require.Equal(t, int64(0), cache.invalidateCallCount.Load(),
  609. "repo 失败时不应调用 InvalidateUserBalance")
  610. }
  611. func TestUpdateBalance_WithAuthCacheInvalidator(t *testing.T) {
  612. repo := &mockUserRepo{}
  613. auth := &mockAuthCacheInvalidator{}
  614. cache := &mockBillingCache{}
  615. svc := NewUserService(repo, nil, auth, cache)
  616. err := svc.UpdateBalance(context.Background(), 77, 300.0)
  617. require.NoError(t, err)
  618. // 验证 auth cache 同步失效
  619. auth.mu.Lock()
  620. require.Equal(t, []int64{77}, auth.invalidatedUserIDs)
  621. auth.mu.Unlock()
  622. // 验证 billing cache 异步失效
  623. require.Eventually(t, func() bool {
  624. return cache.invalidateCallCount.Load() == 1
  625. }, 2*time.Second, 10*time.Millisecond)
  626. }
  627. func TestNewUserService_FieldsAssignment(t *testing.T) {
  628. repo := &mockUserRepo{}
  629. auth := &mockAuthCacheInvalidator{}
  630. cache := &mockBillingCache{}
  631. svc := NewUserService(repo, nil, auth, cache)
  632. require.NotNil(t, svc)
  633. require.Equal(t, repo, svc.userRepo)
  634. require.Equal(t, auth, svc.authCacheInvalidator)
  635. require.Equal(t, cache, svc.billingCache)
  636. }
  637. func TestUpdateProfile_StoresInlineAvatarWithinLimit(t *testing.T) {
  638. raw := []byte("small-avatar")
  639. dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
  640. expectedSum := sha256.Sum256(raw)
  641. repo := &mockUserRepo{
  642. getByIDUser: &User{
  643. ID: 7,
  644. Email: "avatar@example.com",
  645. Username: "avatar-user",
  646. },
  647. }
  648. svc := NewUserService(repo, nil, nil, nil)
  649. updated, err := svc.UpdateProfile(context.Background(), 7, UpdateProfileRequest{
  650. AvatarURL: &dataURL,
  651. })
  652. require.NoError(t, err)
  653. require.Len(t, repo.upsertAvatarArgs, 1)
  654. require.Equal(t, "inline", repo.upsertAvatarArgs[0].StorageProvider)
  655. require.Equal(t, "image/png", repo.upsertAvatarArgs[0].ContentType)
  656. require.Equal(t, len(raw), repo.upsertAvatarArgs[0].ByteSize)
  657. require.Equal(t, hex.EncodeToString(expectedSum[:]), repo.upsertAvatarArgs[0].SHA256)
  658. require.Equal(t, dataURL, updated.AvatarURL)
  659. require.Equal(t, "inline", updated.AvatarSource)
  660. require.Equal(t, "image/png", updated.AvatarMIME)
  661. require.Equal(t, len(raw), updated.AvatarByteSize)
  662. require.Equal(t, hex.EncodeToString(expectedSum[:]), updated.AvatarSHA256)
  663. }
  664. func TestUpdateProfile_CompressesInlineAvatarToTwentyKilobytes(t *testing.T) {
  665. var encoded bytes.Buffer
  666. for _, size := range []int{192, 224, 256, 288} {
  667. encoded.Reset()
  668. var img image.RGBA
  669. img.Rect = image.Rect(0, 0, size, size)
  670. img.Stride = size * 4
  671. img.Pix = make([]byte, size*size*4)
  672. for y := 0; y < size; y++ {
  673. for x := 0; x < size; x++ {
  674. offset := y*img.Stride + x*4
  675. img.Pix[offset] = uint8((x*x + y*17) % 255)
  676. img.Pix[offset+1] = uint8((y*y + x*29) % 255)
  677. img.Pix[offset+2] = uint8(((x * y) + x*13 + y*7) % 255)
  678. img.Pix[offset+3] = 0xff
  679. }
  680. }
  681. require.NoError(t, png.Encode(&encoded, &img))
  682. if encoded.Len() > 20*1024 && encoded.Len() <= maxInlineAvatarBytes {
  683. break
  684. }
  685. }
  686. require.Greater(t, encoded.Len(), 20*1024)
  687. require.LessOrEqual(t, encoded.Len(), maxInlineAvatarBytes)
  688. dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())
  689. repo := &mockUserRepo{
  690. getByIDUser: &User{
  691. ID: 17,
  692. Email: "avatar-compress@example.com",
  693. Username: "avatar-compress",
  694. },
  695. }
  696. svc := NewUserService(repo, nil, nil, nil)
  697. updated, err := svc.UpdateProfile(context.Background(), 17, UpdateProfileRequest{
  698. AvatarURL: &dataURL,
  699. })
  700. require.NoError(t, err)
  701. require.Len(t, repo.upsertAvatarArgs, 1)
  702. require.Equal(t, "inline", repo.upsertAvatarArgs[0].StorageProvider)
  703. require.LessOrEqual(t, repo.upsertAvatarArgs[0].ByteSize, 20*1024)
  704. require.Equal(t, "image/jpeg", repo.upsertAvatarArgs[0].ContentType)
  705. require.Contains(t, repo.upsertAvatarArgs[0].URL, "data:image/jpeg;base64,")
  706. require.Equal(t, "inline", updated.AvatarSource)
  707. require.Equal(t, "image/jpeg", updated.AvatarMIME)
  708. require.LessOrEqual(t, updated.AvatarByteSize, 20*1024)
  709. require.Contains(t, updated.AvatarURL, "data:image/jpeg;base64,")
  710. require.NotEmpty(t, updated.AvatarSHA256)
  711. }
  712. func TestUpdateProfile_RejectsInlineAvatarOverLimit(t *testing.T) {
  713. raw := make([]byte, maxInlineAvatarBytes+1)
  714. dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
  715. repo := &mockUserRepo{
  716. getByIDUser: &User{
  717. ID: 8,
  718. Email: "large-avatar@example.com",
  719. Username: "too-large",
  720. },
  721. }
  722. svc := NewUserService(repo, nil, nil, nil)
  723. _, err := svc.UpdateProfile(context.Background(), 8, UpdateProfileRequest{
  724. AvatarURL: &dataURL,
  725. })
  726. require.ErrorIs(t, err, ErrAvatarTooLarge)
  727. require.Empty(t, repo.upsertAvatarArgs)
  728. require.Empty(t, repo.deleteAvatarIDs)
  729. require.Zero(t, repo.updateCalls)
  730. }
  731. func TestUpdateProfile_StoresRemoteAvatarURL(t *testing.T) {
  732. remoteURL := "https://cdn.example.com/avatar.png"
  733. repo := &mockUserRepo{
  734. getByIDUser: &User{
  735. ID: 9,
  736. Email: "remote-avatar@example.com",
  737. Username: "remote-avatar",
  738. },
  739. }
  740. svc := NewUserService(repo, nil, nil, nil)
  741. updated, err := svc.UpdateProfile(context.Background(), 9, UpdateProfileRequest{
  742. AvatarURL: &remoteURL,
  743. })
  744. require.NoError(t, err)
  745. require.Len(t, repo.upsertAvatarArgs, 1)
  746. require.Equal(t, "remote_url", repo.upsertAvatarArgs[0].StorageProvider)
  747. require.Equal(t, remoteURL, repo.upsertAvatarArgs[0].URL)
  748. require.Equal(t, remoteURL, updated.AvatarURL)
  749. require.Equal(t, "remote_url", updated.AvatarSource)
  750. require.Zero(t, updated.AvatarByteSize)
  751. }
  752. func TestUpdateProfile_DeletesAvatarOnEmptyString(t *testing.T) {
  753. empty := ""
  754. repo := &mockUserRepo{
  755. getByIDUser: &User{
  756. ID: 10,
  757. Email: "delete-avatar@example.com",
  758. Username: "delete-avatar",
  759. AvatarURL: "https://cdn.example.com/old.png",
  760. AvatarSource: "remote_url",
  761. },
  762. }
  763. svc := NewUserService(repo, nil, nil, nil)
  764. updated, err := svc.UpdateProfile(context.Background(), 10, UpdateProfileRequest{
  765. AvatarURL: &empty,
  766. })
  767. require.NoError(t, err)
  768. require.Equal(t, []int64{10}, repo.deleteAvatarIDs)
  769. require.Empty(t, repo.upsertAvatarArgs)
  770. require.Empty(t, updated.AvatarURL)
  771. require.Empty(t, updated.AvatarSource)
  772. }
  773. func TestUpdateProfile_RollsBackAvatarMutationWhenUserUpdateFails(t *testing.T) {
  774. repo := &mockUserRepo{
  775. getByIDUser: &User{
  776. ID: 11,
  777. Email: "rollback@example.com",
  778. AvatarURL: "https://cdn.example.com/original.png",
  779. AvatarSource: "remote_url",
  780. },
  781. updateFn: func(context.Context, *User) error {
  782. return errors.New("write user failed")
  783. },
  784. }
  785. svc := NewUserService(repo, nil, nil, nil)
  786. remoteURL := "https://cdn.example.com/new.png"
  787. _, err := svc.UpdateProfile(context.Background(), 11, UpdateProfileRequest{
  788. AvatarURL: &remoteURL,
  789. })
  790. require.EqualError(t, err, "update user: write user failed")
  791. require.Equal(t, 1, repo.txCalls)
  792. require.Empty(t, repo.upsertAvatarArgs)
  793. require.Empty(t, repo.deleteAvatarIDs)
  794. require.Equal(t, "https://cdn.example.com/original.png", repo.getByIDUser.AvatarURL)
  795. require.Equal(t, "remote_url", repo.getByIDUser.AvatarSource)
  796. }
  797. func TestGetProfile_HydratesAvatarFromRepository(t *testing.T) {
  798. repo := &mockUserRepo{
  799. getByIDUser: &User{
  800. ID: 12,
  801. Email: "profile-avatar@example.com",
  802. Username: "profile-avatar",
  803. },
  804. getAvatarFn: func(context.Context, int64) (*UserAvatar, error) {
  805. return &UserAvatar{
  806. StorageProvider: "remote_url",
  807. URL: "https://cdn.example.com/profile.png",
  808. }, nil
  809. },
  810. }
  811. svc := NewUserService(repo, nil, nil, nil)
  812. user, err := svc.GetProfile(context.Background(), 12)
  813. require.NoError(t, err)
  814. require.Equal(t, "https://cdn.example.com/profile.png", user.AvatarURL)
  815. require.Equal(t, "remote_url", user.AvatarSource)
  816. }