| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191 |
- //go:build integration
- package repository
- import (
- "github.com/Wei-Shaw/sub2api/internal/service"
- )
- // 这一组用例覆盖用户行上的 lost update:调用方手里的快照可能早于并发发生的
- // 原子写入(扣费、状态变更、限额调整、分组授予)。Update 只写显式声明的列,
- // 未声明的列一律保持库中当前值,因此陈旧快照不会回滚这些并发结果。
- func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentBalanceDeduction() {
- user := s.mustCreateUser(&service.User{
- Email: "lost-update-balance@example.com",
- Username: "before",
- Balance: 0.30,
- })
- // 调用方在扣费之前读到的旧快照(余额还是 0.30)。
- stale, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID")
- s.Require().InDelta(0.30, stale.Balance, 1e-9)
- // 与之并发:计费按原子方式扣费。
- s.Require().NoError(s.repo.DeductBalance(s.ctx, user.ID, 0.25), "DeductBalance")
- // 基于旧快照的资料更新这时才落库。
- stale.Username = "after"
- s.Require().NoError(
- s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
- "Update",
- )
- got, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID after update")
- s.Require().Equal("after", got.Username, "declared column must still be written")
- s.Require().InDelta(0.05, got.Balance, 1e-9, "balance must not be reverted by a stale profile save")
- }
- // 同理,风控自动封禁把 status 置为 disabled 后,
- // 基于旧快照的资料更新不得把 status 刷回 active。
- func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentBan() {
- user := s.mustCreateUser(&service.User{
- Email: "lost-update-ban@example.com",
- Username: "before",
- Status: service.StatusActive,
- })
- stale, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID")
- s.Require().Equal(service.StatusActive, stale.Status)
- banned, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID for ban")
- banned.Status = service.StatusDisabled
- s.Require().NoError(
- s.repo.Update(s.ctx, banned, service.UserUpdateFields{Status: true}),
- "ban",
- )
- stale.Username = "after"
- s.Require().NoError(
- s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
- "stale profile save",
- )
- got, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID after update")
- s.Require().Equal("after", got.Username)
- s.Require().Equal(service.StatusDisabled, got.Status, "ban must survive a stale profile save")
- }
- // 未声明的列不写,也意味着并发的限额调整不会被资料保存回滚。
- func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentLimitChanges() {
- user := s.mustCreateUser(&service.User{
- Email: "lost-update-limits@example.com",
- Username: "before",
- Concurrency: 3,
- RPMLimit: 30,
- })
- stale, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID")
- concurrency, rpmLimit := 9, 90
- affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &concurrency, &rpmLimit)
- s.Require().NoError(err, "BatchUpdateLimits")
- s.Require().Equal(1, affected)
- stale.Username = "after"
- s.Require().NoError(
- s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
- "stale profile save",
- )
- got, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID after update")
- s.Require().Equal(9, got.Concurrency, "concurrency must not be reverted")
- s.Require().Equal(90, got.RPMLimit, "rpm limit must not be reverted")
- }
- // AllowedGroups 只在显式声明时才同步,否则并发授予的分组权限会被旧快照删掉。
- func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentAllowedGroupGrant() {
- group := s.mustCreateGroup("lost-update-group")
- user := s.mustCreateUser(&service.User{
- Email: "lost-update-groups@example.com",
- Username: "before",
- })
- stale, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID")
- s.Require().Empty(stale.AllowedGroups)
- s.Require().NoError(s.repo.AddGroupToAllowedGroups(s.ctx, user.ID, group.ID), "AddGroupToAllowedGroups")
- stale.Username = "after"
- s.Require().NoError(
- s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
- "stale profile save",
- )
- got, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID after update")
- s.Require().Equal([]int64{group.ID}, got.AllowedGroups, "granted group must not be reverted")
- }
- func (s *UserRepoSuite) TestAdjustBalance_AppliesDeltaAndReportsChange() {
- user := s.mustCreateUser(&service.User{Email: "adjust-balance@example.com", Balance: 10})
- change, err := s.repo.AdjustBalance(s.ctx, user.ID, 5)
- s.Require().NoError(err, "AdjustBalance add")
- s.Require().InDelta(10, change.Old, 1e-9)
- s.Require().InDelta(15, change.New, 1e-9)
- change, err = s.repo.AdjustBalance(s.ctx, user.ID, -5)
- s.Require().NoError(err, "AdjustBalance subtract")
- s.Require().InDelta(15, change.Old, 1e-9)
- s.Require().InDelta(10, change.New, 1e-9)
- got, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID")
- s.Require().InDelta(10, got.Balance, 1e-9)
- }
- func (s *UserRepoSuite) TestAdjustBalance_RefusesNegativeResult() {
- user := s.mustCreateUser(&service.User{Email: "adjust-balance-negative@example.com", Balance: 3})
- change, err := s.repo.AdjustBalance(s.ctx, user.ID, -4)
- s.Require().ErrorIs(err, service.ErrBalanceNegative)
- s.Require().InDelta(3, change.Old, 1e-9, "error must report the real current balance")
- s.Require().InDelta(-1, change.New, 1e-9)
- got, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID")
- s.Require().InDelta(3, got.Balance, 1e-9, "refused adjustment must not write")
- }
- func (s *UserRepoSuite) TestAdjustBalance_UserNotFound() {
- _, err := s.repo.AdjustBalance(s.ctx, 99999999, 1)
- s.Require().ErrorIs(err, service.ErrUserNotFound)
- }
- func (s *UserRepoSuite) TestSetBalance_ReplacesValueAndReportsPrevious() {
- user := s.mustCreateUser(&service.User{Email: "set-balance@example.com", Balance: 7})
- change, err := s.repo.SetBalance(s.ctx, user.ID, 2)
- s.Require().NoError(err, "SetBalance")
- s.Require().InDelta(7, change.Old, 1e-9)
- s.Require().InDelta(2, change.New, 1e-9)
- got, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID")
- s.Require().InDelta(2, got.Balance, 1e-9)
- }
- func (s *UserRepoSuite) TestSetBalance_RejectsNegativeValue() {
- user := s.mustCreateUser(&service.User{Email: "set-balance-negative@example.com", Balance: 7})
- _, err := s.repo.SetBalance(s.ctx, user.ID, -1)
- s.Require().ErrorIs(err, service.ErrBalanceNegative)
- got, err := s.repo.GetByID(s.ctx, user.ID)
- s.Require().NoError(err, "GetByID")
- s.Require().InDelta(7, got.Balance, 1e-9)
- }
- func (s *UserRepoSuite) TestSetBalance_UserNotFound() {
- _, err := s.repo.SetBalance(s.ctx, 99999999, 1)
- s.Require().ErrorIs(err, service.ErrUserNotFound)
- }
|