user_repo_lost_update_integration_test.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. //go:build integration
  2. package repository
  3. import (
  4. "github.com/Wei-Shaw/sub2api/internal/service"
  5. )
  6. // 这一组用例覆盖用户行上的 lost update:调用方手里的快照可能早于并发发生的
  7. // 原子写入(扣费、状态变更、限额调整、分组授予)。Update 只写显式声明的列,
  8. // 未声明的列一律保持库中当前值,因此陈旧快照不会回滚这些并发结果。
  9. func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentBalanceDeduction() {
  10. user := s.mustCreateUser(&service.User{
  11. Email: "lost-update-balance@example.com",
  12. Username: "before",
  13. Balance: 0.30,
  14. })
  15. // 调用方在扣费之前读到的旧快照(余额还是 0.30)。
  16. stale, err := s.repo.GetByID(s.ctx, user.ID)
  17. s.Require().NoError(err, "GetByID")
  18. s.Require().InDelta(0.30, stale.Balance, 1e-9)
  19. // 与之并发:计费按原子方式扣费。
  20. s.Require().NoError(s.repo.DeductBalance(s.ctx, user.ID, 0.25), "DeductBalance")
  21. // 基于旧快照的资料更新这时才落库。
  22. stale.Username = "after"
  23. s.Require().NoError(
  24. s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
  25. "Update",
  26. )
  27. got, err := s.repo.GetByID(s.ctx, user.ID)
  28. s.Require().NoError(err, "GetByID after update")
  29. s.Require().Equal("after", got.Username, "declared column must still be written")
  30. s.Require().InDelta(0.05, got.Balance, 1e-9, "balance must not be reverted by a stale profile save")
  31. }
  32. // 同理,风控自动封禁把 status 置为 disabled 后,
  33. // 基于旧快照的资料更新不得把 status 刷回 active。
  34. func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentBan() {
  35. user := s.mustCreateUser(&service.User{
  36. Email: "lost-update-ban@example.com",
  37. Username: "before",
  38. Status: service.StatusActive,
  39. })
  40. stale, err := s.repo.GetByID(s.ctx, user.ID)
  41. s.Require().NoError(err, "GetByID")
  42. s.Require().Equal(service.StatusActive, stale.Status)
  43. banned, err := s.repo.GetByID(s.ctx, user.ID)
  44. s.Require().NoError(err, "GetByID for ban")
  45. banned.Status = service.StatusDisabled
  46. s.Require().NoError(
  47. s.repo.Update(s.ctx, banned, service.UserUpdateFields{Status: true}),
  48. "ban",
  49. )
  50. stale.Username = "after"
  51. s.Require().NoError(
  52. s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
  53. "stale profile save",
  54. )
  55. got, err := s.repo.GetByID(s.ctx, user.ID)
  56. s.Require().NoError(err, "GetByID after update")
  57. s.Require().Equal("after", got.Username)
  58. s.Require().Equal(service.StatusDisabled, got.Status, "ban must survive a stale profile save")
  59. }
  60. // 未声明的列不写,也意味着并发的限额调整不会被资料保存回滚。
  61. func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentLimitChanges() {
  62. user := s.mustCreateUser(&service.User{
  63. Email: "lost-update-limits@example.com",
  64. Username: "before",
  65. Concurrency: 3,
  66. RPMLimit: 30,
  67. })
  68. stale, err := s.repo.GetByID(s.ctx, user.ID)
  69. s.Require().NoError(err, "GetByID")
  70. concurrency, rpmLimit := 9, 90
  71. affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &concurrency, &rpmLimit)
  72. s.Require().NoError(err, "BatchUpdateLimits")
  73. s.Require().Equal(1, affected)
  74. stale.Username = "after"
  75. s.Require().NoError(
  76. s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
  77. "stale profile save",
  78. )
  79. got, err := s.repo.GetByID(s.ctx, user.ID)
  80. s.Require().NoError(err, "GetByID after update")
  81. s.Require().Equal(9, got.Concurrency, "concurrency must not be reverted")
  82. s.Require().Equal(90, got.RPMLimit, "rpm limit must not be reverted")
  83. }
  84. // AllowedGroups 只在显式声明时才同步,否则并发授予的分组权限会被旧快照删掉。
  85. func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentAllowedGroupGrant() {
  86. group := s.mustCreateGroup("lost-update-group")
  87. user := s.mustCreateUser(&service.User{
  88. Email: "lost-update-groups@example.com",
  89. Username: "before",
  90. })
  91. stale, err := s.repo.GetByID(s.ctx, user.ID)
  92. s.Require().NoError(err, "GetByID")
  93. s.Require().Empty(stale.AllowedGroups)
  94. s.Require().NoError(s.repo.AddGroupToAllowedGroups(s.ctx, user.ID, group.ID), "AddGroupToAllowedGroups")
  95. stale.Username = "after"
  96. s.Require().NoError(
  97. s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
  98. "stale profile save",
  99. )
  100. got, err := s.repo.GetByID(s.ctx, user.ID)
  101. s.Require().NoError(err, "GetByID after update")
  102. s.Require().Equal([]int64{group.ID}, got.AllowedGroups, "granted group must not be reverted")
  103. }
  104. func (s *UserRepoSuite) TestAdjustBalance_AppliesDeltaAndReportsChange() {
  105. user := s.mustCreateUser(&service.User{Email: "adjust-balance@example.com", Balance: 10})
  106. change, err := s.repo.AdjustBalance(s.ctx, user.ID, 5)
  107. s.Require().NoError(err, "AdjustBalance add")
  108. s.Require().InDelta(10, change.Old, 1e-9)
  109. s.Require().InDelta(15, change.New, 1e-9)
  110. change, err = s.repo.AdjustBalance(s.ctx, user.ID, -5)
  111. s.Require().NoError(err, "AdjustBalance subtract")
  112. s.Require().InDelta(15, change.Old, 1e-9)
  113. s.Require().InDelta(10, change.New, 1e-9)
  114. got, err := s.repo.GetByID(s.ctx, user.ID)
  115. s.Require().NoError(err, "GetByID")
  116. s.Require().InDelta(10, got.Balance, 1e-9)
  117. }
  118. func (s *UserRepoSuite) TestAdjustBalance_RefusesNegativeResult() {
  119. user := s.mustCreateUser(&service.User{Email: "adjust-balance-negative@example.com", Balance: 3})
  120. change, err := s.repo.AdjustBalance(s.ctx, user.ID, -4)
  121. s.Require().ErrorIs(err, service.ErrBalanceNegative)
  122. s.Require().InDelta(3, change.Old, 1e-9, "error must report the real current balance")
  123. s.Require().InDelta(-1, change.New, 1e-9)
  124. got, err := s.repo.GetByID(s.ctx, user.ID)
  125. s.Require().NoError(err, "GetByID")
  126. s.Require().InDelta(3, got.Balance, 1e-9, "refused adjustment must not write")
  127. }
  128. func (s *UserRepoSuite) TestAdjustBalance_UserNotFound() {
  129. _, err := s.repo.AdjustBalance(s.ctx, 99999999, 1)
  130. s.Require().ErrorIs(err, service.ErrUserNotFound)
  131. }
  132. func (s *UserRepoSuite) TestSetBalance_ReplacesValueAndReportsPrevious() {
  133. user := s.mustCreateUser(&service.User{Email: "set-balance@example.com", Balance: 7})
  134. change, err := s.repo.SetBalance(s.ctx, user.ID, 2)
  135. s.Require().NoError(err, "SetBalance")
  136. s.Require().InDelta(7, change.Old, 1e-9)
  137. s.Require().InDelta(2, change.New, 1e-9)
  138. got, err := s.repo.GetByID(s.ctx, user.ID)
  139. s.Require().NoError(err, "GetByID")
  140. s.Require().InDelta(2, got.Balance, 1e-9)
  141. }
  142. func (s *UserRepoSuite) TestSetBalance_RejectsNegativeValue() {
  143. user := s.mustCreateUser(&service.User{Email: "set-balance-negative@example.com", Balance: 7})
  144. _, err := s.repo.SetBalance(s.ctx, user.ID, -1)
  145. s.Require().ErrorIs(err, service.ErrBalanceNegative)
  146. got, err := s.repo.GetByID(s.ctx, user.ID)
  147. s.Require().NoError(err, "GetByID")
  148. s.Require().InDelta(7, got.Balance, 1e-9)
  149. }
  150. func (s *UserRepoSuite) TestSetBalance_UserNotFound() {
  151. _, err := s.repo.SetBalance(s.ctx, 99999999, 1)
  152. s.Require().ErrorIs(err, service.ErrUserNotFound)
  153. }