user_repo_integration_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "errors"
  6. "strings"
  7. "sync"
  8. "testing"
  9. "time"
  10. dbent "github.com/Wei-Shaw/sub2api/ent"
  11. "github.com/Wei-Shaw/sub2api/ent/authidentity"
  12. "github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  14. "github.com/Wei-Shaw/sub2api/internal/service"
  15. "github.com/stretchr/testify/suite"
  16. )
  17. type UserRepoSuite struct {
  18. suite.Suite
  19. ctx context.Context
  20. client *dbent.Client
  21. repo *userRepository
  22. }
  23. func (s *UserRepoSuite) SetupTest() {
  24. s.ctx = context.Background()
  25. s.client = testEntClient(s.T())
  26. s.repo = newUserRepositoryWithSQL(s.client, integrationDB)
  27. // 清理测试数据,确保每个测试从干净状态开始
  28. _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM auth_identity_channels")
  29. _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM auth_identities")
  30. _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_subscriptions")
  31. _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_allowed_groups")
  32. _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM users")
  33. }
  34. func TestUserRepoSuite(t *testing.T) {
  35. suite.Run(t, new(UserRepoSuite))
  36. }
  37. func (s *UserRepoSuite) mustCreateUser(u *service.User) *service.User {
  38. s.T().Helper()
  39. if u.Email == "" {
  40. u.Email = "user-" + time.Now().Format(time.RFC3339Nano) + "@example.com"
  41. }
  42. if u.PasswordHash == "" {
  43. u.PasswordHash = "test-password-hash"
  44. }
  45. if u.Role == "" {
  46. u.Role = service.RoleUser
  47. }
  48. if u.Status == "" {
  49. u.Status = service.StatusActive
  50. }
  51. if u.Concurrency == 0 {
  52. u.Concurrency = 5
  53. }
  54. s.Require().NoError(s.repo.Create(s.ctx, u), "create user")
  55. return u
  56. }
  57. func (s *UserRepoSuite) TestCreateWithEmailAliasGuardAndDomainLimitConcurrent() {
  58. domain := "race-" + strings.ToLower(strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "")) + ".example"
  59. users := []*service.User{
  60. {Email: "first@" + domain, PasswordHash: "hash", Role: service.RoleUser, Status: service.StatusActive},
  61. {Email: "second@" + domain, PasswordHash: "hash", Role: service.RoleUser, Status: service.StatusActive},
  62. }
  63. errs := make(chan error, len(users))
  64. var wg sync.WaitGroup
  65. for _, user := range users {
  66. wg.Add(1)
  67. go func(user *service.User) {
  68. defer wg.Done()
  69. errs <- s.repo.CreateWithEmailAliasGuardAndDomainLimit(s.ctx, user, domain)
  70. }(user)
  71. }
  72. wg.Wait()
  73. close(errs)
  74. var success, limited int
  75. for err := range errs {
  76. switch {
  77. case err == nil:
  78. success++
  79. case errors.Is(err, service.ErrEmailDomainRegistrationLimit):
  80. limited++
  81. default:
  82. s.Require().NoError(err)
  83. }
  84. }
  85. s.Require().Equal(1, success)
  86. s.Require().Equal(1, limited)
  87. count, err := s.repo.CountUsersByEmailDomain(s.ctx, domain)
  88. s.Require().NoError(err)
  89. s.Require().Equal(1, count)
  90. }
  91. func (s *UserRepoSuite) mustCreateGroup(name string) *service.Group {
  92. s.T().Helper()
  93. g, err := s.client.Group.Create().
  94. SetName(name).
  95. SetStatus(service.StatusActive).
  96. Save(s.ctx)
  97. s.Require().NoError(err, "create group")
  98. return groupEntityToService(g)
  99. }
  100. func (s *UserRepoSuite) mustCreateSubscription(userID, groupID int64, mutate func(*dbent.UserSubscriptionCreate)) *dbent.UserSubscription {
  101. s.T().Helper()
  102. now := time.Now()
  103. create := s.client.UserSubscription.Create().
  104. SetUserID(userID).
  105. SetGroupID(groupID).
  106. SetStartsAt(now.Add(-1 * time.Hour)).
  107. SetExpiresAt(now.Add(24 * time.Hour)).
  108. SetStatus(service.SubscriptionStatusActive).
  109. SetAssignedAt(now).
  110. SetNotes("")
  111. if mutate != nil {
  112. mutate(create)
  113. }
  114. sub, err := create.Save(s.ctx)
  115. s.Require().NoError(err, "create subscription")
  116. return sub
  117. }
  118. // --- Create / GetByID / GetByEmail / Update / Delete ---
  119. func (s *UserRepoSuite) TestCreate() {
  120. user := s.mustCreateUser(&service.User{
  121. Email: "create@test.com",
  122. Username: "testuser",
  123. PasswordHash: "test-password-hash",
  124. Role: service.RoleUser,
  125. Status: service.StatusActive,
  126. })
  127. s.Require().NotZero(user.ID, "expected ID to be set")
  128. got, err := s.repo.GetByID(s.ctx, user.ID)
  129. s.Require().NoError(err, "GetByID")
  130. s.Require().Equal("create@test.com", got.Email)
  131. }
  132. func (s *UserRepoSuite) TestGetByID_NotFound() {
  133. _, err := s.repo.GetByID(s.ctx, 999999)
  134. s.Require().Error(err, "expected error for non-existent ID")
  135. }
  136. func (s *UserRepoSuite) TestGetByEmail() {
  137. user := s.mustCreateUser(&service.User{Email: "byemail@test.com"})
  138. got, err := s.repo.GetByEmail(s.ctx, user.Email)
  139. s.Require().NoError(err, "GetByEmail")
  140. s.Require().Equal(user.ID, got.ID)
  141. }
  142. func (s *UserRepoSuite) TestGetByEmail_NormalizesSpacingAndCaseOnPostgres() {
  143. user := s.mustCreateUser(&service.User{Email: " Legacy@Example.com "})
  144. got, err := s.repo.GetByEmail(s.ctx, " legacy@example.com ")
  145. s.Require().NoError(err, "GetByEmail normalized lookup")
  146. s.Require().Equal(user.ID, got.ID)
  147. }
  148. func (s *UserRepoSuite) TestGetByEmail_NotFound() {
  149. _, err := s.repo.GetByEmail(s.ctx, "nonexistent@test.com")
  150. s.Require().Error(err, "expected error for non-existent email")
  151. }
  152. func (s *UserRepoSuite) TestExistsByEmail_NormalizesSpacingAndCaseOnPostgres() {
  153. s.mustCreateUser(&service.User{Email: " Legacy@Example.com "})
  154. exists, err := s.repo.ExistsByEmail(s.ctx, " LEGACY@example.com ")
  155. s.Require().NoError(err, "ExistsByEmail normalized lookup")
  156. s.Require().True(exists)
  157. }
  158. func (s *UserRepoSuite) TestUpdate() {
  159. user := s.mustCreateUser(&service.User{Email: "update@test.com", Username: "original"})
  160. got, err := s.repo.GetByID(s.ctx, user.ID)
  161. s.Require().NoError(err)
  162. got.Username = "updated"
  163. s.Require().NoError(s.repo.Update(s.ctx, got, service.UserUpdateFields{Username: true}), "Update")
  164. updated, err := s.repo.GetByID(s.ctx, user.ID)
  165. s.Require().NoError(err, "GetByID after update")
  166. s.Require().Equal("updated", updated.Username)
  167. }
  168. func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesOnlyProvidedFields() {
  169. user := s.mustCreateUser(&service.User{
  170. Email: "batch-limits-one-field@test.com",
  171. Concurrency: 4,
  172. RPMLimit: 20,
  173. })
  174. concurrency := 9
  175. affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &concurrency, nil)
  176. s.Require().NoError(err)
  177. s.Equal(1, affected)
  178. updated, err := s.repo.GetByID(s.ctx, user.ID)
  179. s.Require().NoError(err)
  180. s.Equal(9, updated.Concurrency)
  181. s.Equal(20, updated.RPMLimit)
  182. }
  183. func (s *UserRepoSuite) TestBatchUpdateLimitsUpdatesBothFieldsToZero() {
  184. user := s.mustCreateUser(&service.User{
  185. Email: "batch-limits-zero@test.com",
  186. Concurrency: 4,
  187. RPMLimit: 20,
  188. })
  189. zero := 0
  190. affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &zero, &zero)
  191. s.Require().NoError(err)
  192. s.Equal(1, affected)
  193. updated, err := s.repo.GetByID(s.ctx, user.ID)
  194. s.Require().NoError(err)
  195. s.Zero(updated.Concurrency)
  196. s.Zero(updated.RPMLimit)
  197. }
  198. func (s *UserRepoSuite) TestBatchUpdateLimitsIgnoresDeletedUsersAndReturnsAffectedRows() {
  199. active := s.mustCreateUser(&service.User{Email: "batch-limits-active@test.com", RPMLimit: 10})
  200. deleted := s.mustCreateUser(&service.User{Email: "batch-limits-deleted@test.com", RPMLimit: 10})
  201. s.Require().NoError(s.client.User.DeleteOneID(deleted.ID).Exec(s.ctx))
  202. rpmLimit := 45
  203. affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{active.ID, deleted.ID}, nil, &rpmLimit)
  204. s.Require().NoError(err)
  205. s.Equal(1, affected)
  206. updatedActive, err := s.repo.GetByID(s.ctx, active.ID)
  207. s.Require().NoError(err)
  208. s.Equal(45, updatedActive.RPMLimit)
  209. updatedDeleted, err := s.repo.GetByIDIncludeDeleted(s.ctx, deleted.ID)
  210. s.Require().NoError(err)
  211. s.Equal(10, updatedDeleted.RPMLimit)
  212. }
  213. func (s *UserRepoSuite) TestUpdateIgnoresNoRowsFromConflictingEmailIdentityUpsert() {
  214. user := s.mustCreateUser(&service.User{Email: "update-existing-identity@test.com", Username: "original"})
  215. identityCount, err := s.client.AuthIdentity.Query().
  216. Where(
  217. authidentity.UserIDEQ(user.ID),
  218. authidentity.ProviderTypeEQ("email"),
  219. authidentity.ProviderKeyEQ("email"),
  220. authidentity.ProviderSubjectEQ("update-existing-identity@test.com"),
  221. ).
  222. Count(s.ctx)
  223. s.Require().NoError(err)
  224. s.Require().Equal(1, identityCount)
  225. got, err := s.repo.GetByID(s.ctx, user.ID)
  226. s.Require().NoError(err)
  227. got.Username = "updated"
  228. s.Require().NoError(s.repo.Update(s.ctx, got, service.UserUpdateFields{Username: true}), "Update should tolerate ON CONFLICT DO NOTHING returning no rows")
  229. updated, err := s.repo.GetByID(s.ctx, user.ID)
  230. s.Require().NoError(err)
  231. s.Require().Equal("updated", updated.Username)
  232. }
  233. func (s *UserRepoSuite) TestDelete() {
  234. user := s.mustCreateUser(&service.User{Email: "delete@test.com"})
  235. err := s.repo.Delete(s.ctx, user.ID)
  236. s.Require().NoError(err, "Delete")
  237. _, err = s.repo.GetByID(s.ctx, user.ID)
  238. s.Require().Error(err, "expected error after delete")
  239. }
  240. func (s *UserRepoSuite) TestDeleteRemovesAuthIdentitiesAndChannels() {
  241. user := s.mustCreateUser(&service.User{Email: "delete-oauth@test.com"})
  242. identity, err := s.client.AuthIdentity.Create().
  243. SetUserID(user.ID).
  244. SetProviderType("linuxdo").
  245. SetProviderKey("linuxdo").
  246. SetProviderSubject("delete-oauth-subject").
  247. Save(s.ctx)
  248. s.Require().NoError(err)
  249. _, err = s.client.AuthIdentityChannel.Create().
  250. SetIdentityID(identity.ID).
  251. SetProviderType("wechat").
  252. SetProviderKey("wechat").
  253. SetChannel("open").
  254. SetChannelAppID("app-id").
  255. SetChannelSubject("openid-123").
  256. Save(s.ctx)
  257. s.Require().NoError(err)
  258. err = s.repo.Delete(s.ctx, user.ID)
  259. s.Require().NoError(err)
  260. identityCount, err := s.client.AuthIdentity.Query().Where(authidentity.UserIDEQ(user.ID)).Count(s.ctx)
  261. s.Require().NoError(err)
  262. s.Require().Zero(identityCount)
  263. channelCount, err := s.client.AuthIdentityChannel.Query().Where(authidentitychannel.IdentityIDEQ(identity.ID)).Count(s.ctx)
  264. s.Require().NoError(err)
  265. s.Require().Zero(channelCount)
  266. }
  267. // --- List / ListWithFilters ---
  268. func (s *UserRepoSuite) TestList() {
  269. s.mustCreateUser(&service.User{Email: "list1@test.com"})
  270. s.mustCreateUser(&service.User{Email: "list2@test.com"})
  271. users, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
  272. s.Require().NoError(err, "List")
  273. s.Require().Len(users, 2)
  274. s.Require().Equal(int64(2), page.Total)
  275. }
  276. func (s *UserRepoSuite) TestListWithFilters_Status() {
  277. s.mustCreateUser(&service.User{Email: "active@test.com", Status: service.StatusActive})
  278. s.mustCreateUser(&service.User{Email: "disabled@test.com", Status: service.StatusDisabled})
  279. users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.UserListFilters{Status: service.StatusActive})
  280. s.Require().NoError(err)
  281. s.Require().Len(users, 1)
  282. s.Require().Equal(service.StatusActive, users[0].Status)
  283. }
  284. func (s *UserRepoSuite) TestListWithFilters_Role() {
  285. s.mustCreateUser(&service.User{Email: "user@test.com", Role: service.RoleUser})
  286. s.mustCreateUser(&service.User{Email: "admin@test.com", Role: service.RoleAdmin})
  287. users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.UserListFilters{Role: service.RoleAdmin})
  288. s.Require().NoError(err)
  289. s.Require().Len(users, 1)
  290. s.Require().Equal(service.RoleAdmin, users[0].Role)
  291. }
  292. func (s *UserRepoSuite) TestListWithFilters_Search() {
  293. s.mustCreateUser(&service.User{Email: "alice@test.com", Username: "Alice"})
  294. s.mustCreateUser(&service.User{Email: "bob@test.com", Username: "Bob"})
  295. users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.UserListFilters{Search: "alice"})
  296. s.Require().NoError(err)
  297. s.Require().Len(users, 1)
  298. s.Require().Contains(users[0].Email, "alice")
  299. }
  300. func (s *UserRepoSuite) TestListWithFilters_SearchByUsername() {
  301. s.mustCreateUser(&service.User{Email: "u1@test.com", Username: "JohnDoe"})
  302. s.mustCreateUser(&service.User{Email: "u2@test.com", Username: "JaneSmith"})
  303. users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.UserListFilters{Search: "john"})
  304. s.Require().NoError(err)
  305. s.Require().Len(users, 1)
  306. s.Require().Equal("JohnDoe", users[0].Username)
  307. }
  308. func (s *UserRepoSuite) TestListWithFilters_LoadsActiveSubscriptions() {
  309. user := s.mustCreateUser(&service.User{Email: "sub@test.com", Status: service.StatusActive})
  310. groupActive := s.mustCreateGroup("g-sub-active")
  311. groupExpired := s.mustCreateGroup("g-sub-expired")
  312. _ = s.mustCreateSubscription(user.ID, groupActive.ID, func(c *dbent.UserSubscriptionCreate) {
  313. c.SetStatus(service.SubscriptionStatusActive)
  314. c.SetExpiresAt(time.Now().Add(1 * time.Hour))
  315. })
  316. _ = s.mustCreateSubscription(user.ID, groupExpired.ID, func(c *dbent.UserSubscriptionCreate) {
  317. c.SetStatus(service.SubscriptionStatusExpired)
  318. c.SetExpiresAt(time.Now().Add(-1 * time.Hour))
  319. })
  320. users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.UserListFilters{Search: "sub@"})
  321. s.Require().NoError(err, "ListWithFilters")
  322. s.Require().Len(users, 1, "expected 1 user")
  323. s.Require().Len(users[0].Subscriptions, 1, "expected 1 active subscription")
  324. s.Require().NotNil(users[0].Subscriptions[0].Group, "expected subscription group preload")
  325. s.Require().Equal(groupActive.ID, users[0].Subscriptions[0].Group.ID, "group ID mismatch")
  326. }
  327. func (s *UserRepoSuite) TestListWithFilters_CombinedFilters() {
  328. s.mustCreateUser(&service.User{
  329. Email: "a@example.com",
  330. Username: "Alice",
  331. Role: service.RoleUser,
  332. Status: service.StatusActive,
  333. Balance: 10,
  334. })
  335. target := s.mustCreateUser(&service.User{
  336. Email: "b@example.com",
  337. Username: "Bob",
  338. Role: service.RoleAdmin,
  339. Status: service.StatusActive,
  340. Balance: 1,
  341. })
  342. s.mustCreateUser(&service.User{
  343. Email: "c@example.com",
  344. Role: service.RoleAdmin,
  345. Status: service.StatusDisabled,
  346. })
  347. users, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.UserListFilters{Status: service.StatusActive, Role: service.RoleAdmin, Search: "b@"})
  348. s.Require().NoError(err, "ListWithFilters")
  349. s.Require().Equal(int64(1), page.Total, "ListWithFilters total mismatch")
  350. s.Require().Len(users, 1, "ListWithFilters len mismatch")
  351. s.Require().Equal(target.ID, users[0].ID, "ListWithFilters result mismatch")
  352. }
  353. // --- Balance operations ---
  354. func (s *UserRepoSuite) TestUpdateBalance() {
  355. user := s.mustCreateUser(&service.User{Email: "bal@test.com", Balance: 10})
  356. err := s.repo.UpdateBalance(s.ctx, user.ID, 2.5)
  357. s.Require().NoError(err, "UpdateBalance")
  358. got, err := s.repo.GetByID(s.ctx, user.ID)
  359. s.Require().NoError(err)
  360. s.Require().InDelta(12.5, got.Balance, 1e-6)
  361. }
  362. func (s *UserRepoSuite) TestUpdateBalance_Negative() {
  363. user := s.mustCreateUser(&service.User{Email: "balneg@test.com", Balance: 10})
  364. err := s.repo.UpdateBalance(s.ctx, user.ID, -3)
  365. s.Require().NoError(err, "UpdateBalance with negative")
  366. got, err := s.repo.GetByID(s.ctx, user.ID)
  367. s.Require().NoError(err)
  368. s.Require().InDelta(7.0, got.Balance, 1e-6)
  369. }
  370. func (s *UserRepoSuite) TestApplyRedeemBalanceAdjustment_ConcurrentNeverNegative() {
  371. user := s.mustCreateUser(&service.User{Email: "redeem-bal-concurrent@test.com", Balance: 10})
  372. var wg sync.WaitGroup
  373. errs := make(chan error, 2)
  374. for i := 0; i < 2; i++ {
  375. wg.Add(1)
  376. go func() {
  377. defer wg.Done()
  378. errs <- s.repo.ApplyRedeemBalanceAdjustment(context.Background(), user.ID, -7)
  379. }()
  380. }
  381. wg.Wait()
  382. close(errs)
  383. for err := range errs {
  384. s.Require().NoError(err)
  385. }
  386. got, err := s.repo.GetByID(s.ctx, user.ID)
  387. s.Require().NoError(err)
  388. s.Require().InDelta(0, got.Balance, 1e-6)
  389. }
  390. func (s *UserRepoSuite) TestDeductBalance() {
  391. user := s.mustCreateUser(&service.User{Email: "deduct@test.com", Balance: 10})
  392. err := s.repo.DeductBalance(s.ctx, user.ID, 5)
  393. s.Require().NoError(err, "DeductBalance")
  394. got, err := s.repo.GetByID(s.ctx, user.ID)
  395. s.Require().NoError(err)
  396. s.Require().InDelta(5.0, got.Balance, 1e-6)
  397. }
  398. func (s *UserRepoSuite) TestDeductBalance_InsufficientFunds() {
  399. user := s.mustCreateUser(&service.User{Email: "insuf@test.com", Balance: 5})
  400. // 透支策略:允许扣除超过余额的金额
  401. err := s.repo.DeductBalance(s.ctx, user.ID, 999)
  402. s.Require().NoError(err, "DeductBalance should allow overdraft")
  403. // 验证余额变为负数
  404. got, err := s.repo.GetByID(s.ctx, user.ID)
  405. s.Require().NoError(err)
  406. s.Require().InDelta(-994.0, got.Balance, 1e-6, "Balance should be negative after overdraft")
  407. }
  408. func (s *UserRepoSuite) TestDeductBalance_ExactAmount() {
  409. user := s.mustCreateUser(&service.User{Email: "exact@test.com", Balance: 10})
  410. err := s.repo.DeductBalance(s.ctx, user.ID, 10)
  411. s.Require().NoError(err, "DeductBalance exact amount")
  412. got, err := s.repo.GetByID(s.ctx, user.ID)
  413. s.Require().NoError(err)
  414. s.Require().InDelta(0.0, got.Balance, 1e-6)
  415. }
  416. func (s *UserRepoSuite) TestDeductBalance_AllowsOverdraft() {
  417. user := s.mustCreateUser(&service.User{Email: "overdraft@test.com", Balance: 5.0})
  418. // 扣除超过余额的金额 - 应该成功
  419. err := s.repo.DeductBalance(s.ctx, user.ID, 10.0)
  420. s.Require().NoError(err, "DeductBalance should allow overdraft")
  421. // 验证余额为负
  422. got, err := s.repo.GetByID(s.ctx, user.ID)
  423. s.Require().NoError(err)
  424. s.Require().InDelta(-5.0, got.Balance, 1e-6, "Balance should be -5.0 after overdraft")
  425. }
  426. func (s *UserRepoSuite) TestDeductAvailableBalance_ClampsToNonnegativeBalance() {
  427. for _, tc := range []struct {
  428. name string
  429. balance float64
  430. requested float64
  431. wantDeduct float64
  432. wantBalance float64
  433. }{
  434. {name: "enough balance", balance: 10, requested: 4, wantDeduct: 4, wantBalance: 6},
  435. {name: "insufficient balance", balance: 5, requested: 10, wantDeduct: 5, wantBalance: 0},
  436. {name: "negative balance unchanged", balance: -3, requested: 10, wantDeduct: 0, wantBalance: -3},
  437. } {
  438. s.Run(tc.name, func() {
  439. user := s.mustCreateUser(&service.User{Email: "available-" + strings.ReplaceAll(tc.name, " ", "-") + "@test.com", Balance: tc.balance})
  440. deducted, err := s.repo.DeductAvailableBalance(s.ctx, user.ID, tc.requested)
  441. s.Require().NoError(err)
  442. s.Require().InDelta(tc.wantDeduct, deducted, 1e-6)
  443. got, err := s.repo.GetByID(s.ctx, user.ID)
  444. s.Require().NoError(err)
  445. s.Require().InDelta(tc.wantBalance, got.Balance, 1e-6)
  446. })
  447. }
  448. }
  449. // --- Concurrency ---
  450. func (s *UserRepoSuite) TestUpdateConcurrency() {
  451. user := s.mustCreateUser(&service.User{Email: "conc@test.com", Concurrency: 5})
  452. err := s.repo.UpdateConcurrency(s.ctx, user.ID, 3)
  453. s.Require().NoError(err, "UpdateConcurrency")
  454. got, err := s.repo.GetByID(s.ctx, user.ID)
  455. s.Require().NoError(err)
  456. s.Require().Equal(8, got.Concurrency)
  457. }
  458. func (s *UserRepoSuite) TestUpdateConcurrency_Negative() {
  459. user := s.mustCreateUser(&service.User{Email: "concneg@test.com", Concurrency: 5})
  460. err := s.repo.UpdateConcurrency(s.ctx, user.ID, -2)
  461. s.Require().NoError(err, "UpdateConcurrency negative")
  462. got, err := s.repo.GetByID(s.ctx, user.ID)
  463. s.Require().NoError(err)
  464. s.Require().Equal(3, got.Concurrency)
  465. }
  466. func (s *UserRepoSuite) TestApplyRedeemConcurrencyAdjustment_ConcurrentNeverNegative() {
  467. user := s.mustCreateUser(&service.User{Email: "redeem-concurrency-concurrent@test.com", Concurrency: 10})
  468. var wg sync.WaitGroup
  469. errs := make(chan error, 2)
  470. for i := 0; i < 2; i++ {
  471. wg.Add(1)
  472. go func() {
  473. defer wg.Done()
  474. errs <- s.repo.ApplyRedeemConcurrencyAdjustment(context.Background(), user.ID, -7)
  475. }()
  476. }
  477. wg.Wait()
  478. close(errs)
  479. for err := range errs {
  480. s.Require().NoError(err)
  481. }
  482. got, err := s.repo.GetByID(s.ctx, user.ID)
  483. s.Require().NoError(err)
  484. s.Require().Equal(0, got.Concurrency)
  485. }
  486. // --- ExistsByEmail ---
  487. func (s *UserRepoSuite) TestExistsByEmail() {
  488. s.mustCreateUser(&service.User{Email: "exists@test.com"})
  489. exists, err := s.repo.ExistsByEmail(s.ctx, "exists@test.com")
  490. s.Require().NoError(err, "ExistsByEmail")
  491. s.Require().True(exists)
  492. notExists, err := s.repo.ExistsByEmail(s.ctx, "notexists@test.com")
  493. s.Require().NoError(err)
  494. s.Require().False(notExists)
  495. }
  496. // --- RemoveGroupFromAllowedGroups ---
  497. func (s *UserRepoSuite) TestRemoveGroupFromAllowedGroups() {
  498. target := s.mustCreateGroup("target-42")
  499. other := s.mustCreateGroup("other-7")
  500. userA := s.mustCreateUser(&service.User{
  501. Email: "a1@example.com",
  502. AllowedGroups: []int64{target.ID, other.ID},
  503. })
  504. s.mustCreateUser(&service.User{
  505. Email: "a2@example.com",
  506. AllowedGroups: []int64{other.ID},
  507. })
  508. affected, err := s.repo.RemoveGroupFromAllowedGroups(s.ctx, target.ID)
  509. s.Require().NoError(err, "RemoveGroupFromAllowedGroups")
  510. s.Require().Equal(int64(1), affected, "expected 1 affected row")
  511. got, err := s.repo.GetByID(s.ctx, userA.ID)
  512. s.Require().NoError(err, "GetByID")
  513. s.Require().NotContains(got.AllowedGroups, target.ID)
  514. s.Require().Contains(got.AllowedGroups, other.ID)
  515. }
  516. func (s *UserRepoSuite) TestRemoveGroupFromAllowedGroups_NoMatch() {
  517. groupA := s.mustCreateGroup("nomatch-a")
  518. groupB := s.mustCreateGroup("nomatch-b")
  519. s.mustCreateUser(&service.User{
  520. Email: "nomatch@test.com",
  521. AllowedGroups: []int64{groupA.ID, groupB.ID},
  522. })
  523. affected, err := s.repo.RemoveGroupFromAllowedGroups(s.ctx, 999999)
  524. s.Require().NoError(err)
  525. s.Require().Zero(affected, "expected no affected rows")
  526. }
  527. // --- GetFirstAdmin ---
  528. func (s *UserRepoSuite) TestGetFirstAdmin() {
  529. admin1 := s.mustCreateUser(&service.User{
  530. Email: "admin1@example.com",
  531. Role: service.RoleAdmin,
  532. Status: service.StatusActive,
  533. })
  534. s.mustCreateUser(&service.User{
  535. Email: "admin2@example.com",
  536. Role: service.RoleAdmin,
  537. Status: service.StatusActive,
  538. })
  539. got, err := s.repo.GetFirstAdmin(s.ctx)
  540. s.Require().NoError(err, "GetFirstAdmin")
  541. s.Require().Equal(admin1.ID, got.ID, "GetFirstAdmin mismatch")
  542. }
  543. func (s *UserRepoSuite) TestGetFirstAdmin_NoAdmin() {
  544. s.mustCreateUser(&service.User{
  545. Email: "user@example.com",
  546. Role: service.RoleUser,
  547. Status: service.StatusActive,
  548. })
  549. _, err := s.repo.GetFirstAdmin(s.ctx)
  550. s.Require().Error(err, "expected error when no admin exists")
  551. }
  552. func (s *UserRepoSuite) TestGetFirstAdmin_DisabledAdminIgnored() {
  553. s.mustCreateUser(&service.User{
  554. Email: "disabled@example.com",
  555. Role: service.RoleAdmin,
  556. Status: service.StatusDisabled,
  557. })
  558. activeAdmin := s.mustCreateUser(&service.User{
  559. Email: "active@example.com",
  560. Role: service.RoleAdmin,
  561. Status: service.StatusActive,
  562. })
  563. got, err := s.repo.GetFirstAdmin(s.ctx)
  564. s.Require().NoError(err, "GetFirstAdmin")
  565. s.Require().Equal(activeAdmin.ID, got.ID, "should return only active admin")
  566. }
  567. // --- Combined ---
  568. func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
  569. user1 := s.mustCreateUser(&service.User{
  570. Email: "a@example.com",
  571. Username: "Alice",
  572. Role: service.RoleUser,
  573. Status: service.StatusActive,
  574. Balance: 10,
  575. })
  576. user2 := s.mustCreateUser(&service.User{
  577. Email: "b@example.com",
  578. Username: "Bob",
  579. Role: service.RoleAdmin,
  580. Status: service.StatusActive,
  581. Balance: 1,
  582. })
  583. s.mustCreateUser(&service.User{
  584. Email: "c@example.com",
  585. Role: service.RoleAdmin,
  586. Status: service.StatusDisabled,
  587. })
  588. got, err := s.repo.GetByID(s.ctx, user1.ID)
  589. s.Require().NoError(err, "GetByID")
  590. s.Require().Equal(user1.Email, got.Email, "GetByID email mismatch")
  591. gotByEmail, err := s.repo.GetByEmail(s.ctx, user2.Email)
  592. s.Require().NoError(err, "GetByEmail")
  593. s.Require().Equal(user2.ID, gotByEmail.ID, "GetByEmail ID mismatch")
  594. got.Username = "Alice2"
  595. s.Require().NoError(s.repo.Update(s.ctx, got, service.UserUpdateFields{Username: true}), "Update")
  596. got2, err := s.repo.GetByID(s.ctx, user1.ID)
  597. s.Require().NoError(err, "GetByID after update")
  598. s.Require().Equal("Alice2", got2.Username, "Update did not persist")
  599. s.Require().NoError(s.repo.UpdateBalance(s.ctx, user1.ID, 2.5), "UpdateBalance")
  600. got3, err := s.repo.GetByID(s.ctx, user1.ID)
  601. s.Require().NoError(err, "GetByID after UpdateBalance")
  602. s.Require().InDelta(12.5, got3.Balance, 1e-6)
  603. s.Require().NoError(s.repo.DeductBalance(s.ctx, user1.ID, 5), "DeductBalance")
  604. got4, err := s.repo.GetByID(s.ctx, user1.ID)
  605. s.Require().NoError(err, "GetByID after DeductBalance")
  606. s.Require().InDelta(7.5, got4.Balance, 1e-6)
  607. // 透支策略:允许扣除超过余额的金额
  608. err = s.repo.DeductBalance(s.ctx, user1.ID, 999)
  609. s.Require().NoError(err, "DeductBalance should allow overdraft")
  610. gotOverdraft, err := s.repo.GetByID(s.ctx, user1.ID)
  611. s.Require().NoError(err, "GetByID after overdraft")
  612. s.Require().Less(gotOverdraft.Balance, 0.0, "Balance should be negative after overdraft")
  613. s.Require().NoError(s.repo.UpdateConcurrency(s.ctx, user1.ID, 3), "UpdateConcurrency")
  614. got5, err := s.repo.GetByID(s.ctx, user1.ID)
  615. s.Require().NoError(err, "GetByID after UpdateConcurrency")
  616. s.Require().Equal(user1.Concurrency+3, got5.Concurrency)
  617. params := pagination.PaginationParams{Page: 1, PageSize: 10}
  618. users, page, err := s.repo.ListWithFilters(s.ctx, params, service.UserListFilters{Status: service.StatusActive, Role: service.RoleAdmin, Search: "b@"})
  619. s.Require().NoError(err, "ListWithFilters")
  620. s.Require().Equal(int64(1), page.Total, "ListWithFilters total mismatch")
  621. s.Require().Len(users, 1, "ListWithFilters len mismatch")
  622. s.Require().Equal(user2.ID, users[0].ID, "ListWithFilters result mismatch")
  623. }
  624. // --- UpdateBalance/UpdateConcurrency 影响行数校验测试 ---
  625. func (s *UserRepoSuite) TestUpdateBalance_NotFound() {
  626. err := s.repo.UpdateBalance(s.ctx, 999999, 10.0)
  627. s.Require().Error(err, "expected error for non-existent user")
  628. s.Require().ErrorIs(err, service.ErrUserNotFound)
  629. }
  630. func (s *UserRepoSuite) TestUpdateConcurrency_NotFound() {
  631. err := s.repo.UpdateConcurrency(s.ctx, 999999, 5)
  632. s.Require().Error(err, "expected error for non-existent user")
  633. s.Require().ErrorIs(err, service.ErrUserNotFound)
  634. }
  635. func (s *UserRepoSuite) TestDeductBalance_NotFound() {
  636. err := s.repo.DeductBalance(s.ctx, 999999, 5)
  637. s.Require().Error(err, "expected error for non-existent user")
  638. // DeductBalance 在用户不存在时返回 ErrUserNotFound
  639. s.Require().ErrorIs(err, service.ErrUserNotFound)
  640. }