subscription_assign_idempotency_test.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. package service
  2. import (
  3. "context"
  4. "strconv"
  5. "testing"
  6. "time"
  7. dbent "github.com/Wei-Shaw/sub2api/ent"
  8. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  11. "github.com/dgraph-io/ristretto"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func TestWithSubscriptionUpdateTx_ReusesExistingTransaction(t *testing.T) {
  15. existingTx := &dbent.Tx{}
  16. ctx := dbent.NewTxContext(context.Background(), existingTx)
  17. svc := &SubscriptionService{entClient: &dbent.Client{}}
  18. called := false
  19. err := svc.withSubscriptionUpdateTx(ctx, func(txCtx context.Context) error {
  20. called = true
  21. require.Same(t, existingTx, dbent.TxFromContext(txCtx))
  22. return nil
  23. })
  24. require.NoError(t, err)
  25. require.True(t, called)
  26. }
  27. func TestMaybeInvalidateAssignmentCaches_DefersForOuterTransactionOwner(t *testing.T) {
  28. cache, err := ristretto.NewCache(&ristretto.Config{NumCounters: 1_000, MaxCost: 100, BufferItems: 64})
  29. require.NoError(t, err)
  30. t.Cleanup(cache.Close)
  31. svc := &SubscriptionService{subCacheL1: cache}
  32. key := subCacheKey(7, 9)
  33. require.True(t, cache.Set(key, &UserSubscription{ID: 42}, 1))
  34. cache.Wait()
  35. svc.maybeInvalidateAssignmentCaches(7, 9, true)
  36. _, cachedBeforeCommit := cache.Get(key)
  37. require.True(t, cachedBeforeCommit, "outer transaction must retain caches until its owner commits")
  38. svc.maybeInvalidateAssignmentCaches(7, 9, false)
  39. cache.Wait()
  40. _, cachedAfterCommit := cache.Get(key)
  41. require.False(t, cachedAfterCommit, "post-commit invalidation must remove the cached subscription")
  42. }
  43. type groupRepoNoop struct{}
  44. func (groupRepoNoop) Create(context.Context, *Group) error { panic("unexpected Create call") }
  45. func (groupRepoNoop) GetByID(context.Context, int64) (*Group, error) {
  46. panic("unexpected GetByID call")
  47. }
  48. func (groupRepoNoop) GetByIDLite(context.Context, int64) (*Group, error) {
  49. panic("unexpected GetByIDLite call")
  50. }
  51. func (groupRepoNoop) Update(context.Context, *Group) error { panic("unexpected Update call") }
  52. func (groupRepoNoop) Delete(context.Context, int64) error { panic("unexpected Delete call") }
  53. func (groupRepoNoop) DeleteCascade(context.Context, int64) ([]int64, error) {
  54. panic("unexpected DeleteCascade call")
  55. }
  56. func (groupRepoNoop) List(context.Context, pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
  57. panic("unexpected List call")
  58. }
  59. func (groupRepoNoop) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]Group, *pagination.PaginationResult, error) {
  60. panic("unexpected ListWithFilters call")
  61. }
  62. func (groupRepoNoop) ListActive(context.Context) ([]Group, error) {
  63. panic("unexpected ListActive call")
  64. }
  65. func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, error) {
  66. panic("unexpected ListActiveByPlatform call")
  67. }
  68. func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) {
  69. panic("unexpected ExistsByName call")
  70. }
  71. func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, int64, error) {
  72. panic("unexpected GetAccountCount call")
  73. }
  74. func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
  75. panic("unexpected DeleteAccountGroupsByGroupID call")
  76. }
  77. func (groupRepoNoop) GetAccountIDsByGroupIDs(context.Context, []int64) ([]int64, error) {
  78. panic("unexpected GetAccountIDsByGroupIDs call")
  79. }
  80. func (groupRepoNoop) BindAccountsToGroup(context.Context, int64, []int64) error {
  81. panic("unexpected BindAccountsToGroup call")
  82. }
  83. func (groupRepoNoop) UpdateSortOrders(context.Context, []GroupSortOrderUpdate) error {
  84. panic("unexpected UpdateSortOrders call")
  85. }
  86. type subscriptionGroupRepoStub struct {
  87. groupRepoNoop
  88. group *Group
  89. }
  90. func (s *subscriptionGroupRepoStub) GetByID(context.Context, int64) (*Group, error) {
  91. return s.group, nil
  92. }
  93. type userSubRepoNoop struct{}
  94. func (userSubRepoNoop) Create(context.Context, *UserSubscription) error {
  95. panic("unexpected Create call")
  96. }
  97. func (userSubRepoNoop) GetByID(context.Context, int64) (*UserSubscription, error) {
  98. panic("unexpected GetByID call")
  99. }
  100. func (userSubRepoNoop) GetByIDForUpdate(context.Context, int64) (*UserSubscription, error) {
  101. panic("unexpected GetByIDForUpdate call")
  102. }
  103. func (userSubRepoNoop) GetByIDIncludeDeleted(context.Context, int64) (*UserSubscription, error) {
  104. panic("unexpected GetByIDIncludeDeleted call")
  105. }
  106. func (userSubRepoNoop) GetByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) {
  107. panic("unexpected GetByUserIDAndGroupID call")
  108. }
  109. func (userSubRepoNoop) GetActiveByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) {
  110. panic("unexpected GetActiveByUserIDAndGroupID call")
  111. }
  112. func (userSubRepoNoop) Update(context.Context, *UserSubscription) error {
  113. panic("unexpected Update call")
  114. }
  115. func (userSubRepoNoop) Delete(context.Context, int64) error { panic("unexpected Delete call") }
  116. func (userSubRepoNoop) Restore(context.Context, int64, string) (*UserSubscription, error) {
  117. panic("unexpected Restore call")
  118. }
  119. func (userSubRepoNoop) ListByUserID(context.Context, int64) ([]UserSubscription, error) {
  120. panic("unexpected ListByUserID call")
  121. }
  122. func (userSubRepoNoop) ListActiveByUserID(context.Context, int64) ([]UserSubscription, error) {
  123. panic("unexpected ListActiveByUserID call")
  124. }
  125. func (userSubRepoNoop) ListByGroupID(context.Context, int64, pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error) {
  126. panic("unexpected ListByGroupID call")
  127. }
  128. func (userSubRepoNoop) List(context.Context, pagination.PaginationParams, *int64, *int64, string, string, string, string) ([]UserSubscription, *pagination.PaginationResult, error) {
  129. panic("unexpected List call")
  130. }
  131. func (userSubRepoNoop) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) {
  132. panic("unexpected ExistsByUserIDAndGroupID call")
  133. }
  134. func (userSubRepoNoop) ExistsActiveByUserIDAndGroupID(context.Context, int64, int64) (bool, error) {
  135. panic("unexpected ExistsActiveByUserIDAndGroupID call")
  136. }
  137. func (userSubRepoNoop) ExtendExpiry(context.Context, int64, time.Time) error {
  138. panic("unexpected ExtendExpiry call")
  139. }
  140. func (userSubRepoNoop) UpdateStatus(context.Context, int64, string) error {
  141. panic("unexpected UpdateStatus call")
  142. }
  143. func (userSubRepoNoop) UpdateNotes(context.Context, int64, string) error {
  144. panic("unexpected UpdateNotes call")
  145. }
  146. func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time, time.Time) error {
  147. panic("unexpected ActivateWindows call")
  148. }
  149. func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
  150. panic("unexpected ResetUsageWindows call")
  151. }
  152. func (userSubRepoNoop) ResetDailyUsage(context.Context, int64, *time.Time, time.Time) error {
  153. panic("unexpected ResetDailyUsage call")
  154. }
  155. func (userSubRepoNoop) ResetWeeklyUsage(context.Context, int64, *time.Time, time.Time) error {
  156. panic("unexpected ResetWeeklyUsage call")
  157. }
  158. func (userSubRepoNoop) ResetMonthlyUsage(context.Context, int64, *time.Time, time.Time) error {
  159. panic("unexpected ResetMonthlyUsage call")
  160. }
  161. func (userSubRepoNoop) IncrementUsage(context.Context, int64, float64) error {
  162. panic("unexpected IncrementUsage call")
  163. }
  164. func (userSubRepoNoop) BatchUpdateExpiredStatus(context.Context) (int64, error) {
  165. panic("unexpected BatchUpdateExpiredStatus call")
  166. }
  167. type subscriptionUserSubRepoStub struct {
  168. userSubRepoNoop
  169. nextID int64
  170. byID map[int64]*UserSubscription
  171. byUserGroup map[string]*UserSubscription
  172. createCalls int
  173. }
  174. func newSubscriptionUserSubRepoStub() *subscriptionUserSubRepoStub {
  175. return &subscriptionUserSubRepoStub{
  176. nextID: 1,
  177. byID: make(map[int64]*UserSubscription),
  178. byUserGroup: make(map[string]*UserSubscription),
  179. }
  180. }
  181. func (s *subscriptionUserSubRepoStub) key(userID, groupID int64) string {
  182. return strconvFormatInt(userID) + ":" + strconvFormatInt(groupID)
  183. }
  184. func (s *subscriptionUserSubRepoStub) seed(sub *UserSubscription) {
  185. if sub == nil {
  186. return
  187. }
  188. cp := *sub
  189. if cp.ID == 0 {
  190. cp.ID = s.nextID
  191. s.nextID++
  192. }
  193. s.byID[cp.ID] = &cp
  194. s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp
  195. }
  196. func (s *subscriptionUserSubRepoStub) ExistsByUserIDAndGroupID(_ context.Context, userID, groupID int64) (bool, error) {
  197. _, ok := s.byUserGroup[s.key(userID, groupID)]
  198. return ok, nil
  199. }
  200. func (s *subscriptionUserSubRepoStub) GetByUserIDAndGroupID(_ context.Context, userID, groupID int64) (*UserSubscription, error) {
  201. sub := s.byUserGroup[s.key(userID, groupID)]
  202. if sub == nil {
  203. return nil, ErrSubscriptionNotFound
  204. }
  205. cp := *sub
  206. return &cp, nil
  207. }
  208. func (s *subscriptionUserSubRepoStub) Create(_ context.Context, sub *UserSubscription) error {
  209. if sub == nil {
  210. return nil
  211. }
  212. s.createCalls++
  213. cp := *sub
  214. if cp.ID == 0 {
  215. cp.ID = s.nextID
  216. s.nextID++
  217. }
  218. sub.ID = cp.ID
  219. s.byID[cp.ID] = &cp
  220. s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp
  221. return nil
  222. }
  223. func (s *subscriptionUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
  224. sub := s.byID[id]
  225. if sub == nil {
  226. return nil, ErrSubscriptionNotFound
  227. }
  228. cp := *sub
  229. return &cp, nil
  230. }
  231. func (s *subscriptionUserSubRepoStub) GetByIDForUpdate(ctx context.Context, id int64) (*UserSubscription, error) {
  232. return s.GetByID(ctx, id)
  233. }
  234. func (s *subscriptionUserSubRepoStub) Update(_ context.Context, sub *UserSubscription) error {
  235. if sub == nil {
  236. return ErrSubscriptionNilInput
  237. }
  238. existing := s.byID[sub.ID]
  239. if existing == nil {
  240. return ErrSubscriptionNotFound
  241. }
  242. oldKey := s.key(existing.UserID, existing.GroupID)
  243. cp := *sub
  244. s.byID[cp.ID] = &cp
  245. if oldKey != s.key(cp.UserID, cp.GroupID) {
  246. delete(s.byUserGroup, oldKey)
  247. }
  248. s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp
  249. return nil
  250. }
  251. func TestAssignSubscriptionReuseWhenSemanticsMatch(t *testing.T) {
  252. start := time.Now().Add(-time.Hour)
  253. groupRepo := &subscriptionGroupRepoStub{
  254. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  255. }
  256. subRepo := newSubscriptionUserSubRepoStub()
  257. subRepo.seed(&UserSubscription{
  258. ID: 10,
  259. UserID: 1001,
  260. GroupID: 1,
  261. StartsAt: start,
  262. ExpiresAt: start.AddDate(0, 0, 30),
  263. Status: SubscriptionStatusActive,
  264. Notes: "init",
  265. })
  266. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  267. sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
  268. UserID: 1001,
  269. GroupID: 1,
  270. ValidityDays: 30,
  271. Notes: "init",
  272. })
  273. require.NoError(t, err)
  274. require.Equal(t, int64(10), sub.ID)
  275. require.Equal(t, 0, subRepo.createCalls, "reuse should not create new subscription")
  276. require.Equal(t, start, sub.StartsAt)
  277. require.Equal(t, start.AddDate(0, 0, 30), sub.ExpiresAt)
  278. }
  279. func TestAssignSubscriptionDoesNotReactivateFutureSuspendedSubscription(t *testing.T) {
  280. start := time.Now().Add(-time.Hour)
  281. groupRepo := &subscriptionGroupRepoStub{
  282. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  283. }
  284. subRepo := newSubscriptionUserSubRepoStub()
  285. subRepo.seed(&UserSubscription{
  286. ID: 13,
  287. UserID: 1003,
  288. GroupID: 1,
  289. StartsAt: start,
  290. ExpiresAt: start.AddDate(0, 0, 30),
  291. Status: SubscriptionStatusSuspended,
  292. Notes: "assignment",
  293. })
  294. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  295. sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
  296. UserID: 1003,
  297. GroupID: 1,
  298. ValidityDays: 30,
  299. Notes: "assignment",
  300. })
  301. require.NoError(t, err)
  302. require.Equal(t, int64(13), sub.ID)
  303. require.Equal(t, SubscriptionStatusSuspended, sub.Status)
  304. require.Equal(t, start, sub.StartsAt)
  305. require.Equal(t, start.AddDate(0, 0, 30), sub.ExpiresAt)
  306. require.Equal(t, "assignment", sub.Notes)
  307. require.Equal(t, 0, subRepo.createCalls)
  308. }
  309. func TestAssignSubscriptionDoesNotReactivatePastExpirySuspendedSubscription(t *testing.T) {
  310. start := time.Now().AddDate(0, 0, -31)
  311. expiresAt := start.AddDate(0, 0, 30)
  312. windowStart := startOfDay(start)
  313. groupRepo := &subscriptionGroupRepoStub{
  314. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  315. }
  316. subRepo := newSubscriptionUserSubRepoStub()
  317. subRepo.seed(&UserSubscription{
  318. ID: 15,
  319. UserID: 1005,
  320. GroupID: 1,
  321. StartsAt: start,
  322. ExpiresAt: expiresAt,
  323. Status: SubscriptionStatusSuspended,
  324. DailyWindowStart: &windowStart,
  325. WeeklyWindowStart: &windowStart,
  326. MonthlyWindowStart: &windowStart,
  327. DailyUsageUSD: 1,
  328. WeeklyUsageUSD: 2,
  329. MonthlyUsageUSD: 3,
  330. Notes: "suspended assignment",
  331. })
  332. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  333. sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
  334. UserID: 1005,
  335. GroupID: 1,
  336. ValidityDays: 30,
  337. Notes: "suspended assignment",
  338. })
  339. require.NoError(t, err)
  340. require.Equal(t, int64(15), sub.ID)
  341. require.Equal(t, SubscriptionStatusSuspended, sub.Status)
  342. require.Equal(t, start, sub.StartsAt)
  343. require.Equal(t, expiresAt, sub.ExpiresAt)
  344. require.Equal(t, "suspended assignment", sub.Notes)
  345. require.Equal(t, &windowStart, sub.DailyWindowStart)
  346. require.Equal(t, &windowStart, sub.WeeklyWindowStart)
  347. require.Equal(t, &windowStart, sub.MonthlyWindowStart)
  348. require.Equal(t, float64(1), sub.DailyUsageUSD)
  349. require.Equal(t, float64(2), sub.WeeklyUsageUSD)
  350. require.Equal(t, float64(3), sub.MonthlyUsageUSD)
  351. require.Equal(t, 0, subRepo.createCalls)
  352. }
  353. func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) {
  354. groupRepo := &subscriptionGroupRepoStub{
  355. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  356. }
  357. subRepo := newSubscriptionUserSubRepoStub()
  358. oldStart := time.Now().Add(-time.Hour)
  359. oldWindowStart := startOfDay(oldStart)
  360. subRepo.seed(&UserSubscription{
  361. ID: 12,
  362. UserID: 1002,
  363. GroupID: 1,
  364. StartsAt: oldStart,
  365. ExpiresAt: oldStart.AddDate(0, 0, 30),
  366. Status: SubscriptionStatusExpired,
  367. DailyWindowStart: &oldWindowStart,
  368. WeeklyWindowStart: &oldWindowStart,
  369. MonthlyWindowStart: &oldWindowStart,
  370. DailyUsageUSD: 1,
  371. WeeklyUsageUSD: 2,
  372. MonthlyUsageUSD: 3,
  373. Notes: " assignment ",
  374. })
  375. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  376. before := time.Now()
  377. sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
  378. UserID: 1002,
  379. GroupID: 1,
  380. ValidityDays: 30,
  381. Notes: "assignment",
  382. })
  383. after := time.Now()
  384. require.NoError(t, err)
  385. require.Equal(t, int64(12), sub.ID)
  386. require.Equal(t, 0, subRepo.createCalls)
  387. require.Equal(t, SubscriptionStatusActive, sub.Status)
  388. require.False(t, sub.StartsAt.Before(before))
  389. require.False(t, sub.StartsAt.After(after))
  390. require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt)
  391. require.Equal(t, timezone.StartOfDay(sub.StartsAt), *sub.DailyWindowStart, "续期后日窗口应锚定当天 0 点")
  392. require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart)
  393. require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart)
  394. require.Zero(t, sub.DailyUsageUSD)
  395. require.Zero(t, sub.WeeklyUsageUSD)
  396. require.Zero(t, sub.MonthlyUsageUSD)
  397. require.Equal(t, " assignment ", sub.Notes)
  398. }
  399. func TestAssignSubscriptionRenewsExpiredAndAppendsDifferentNotes(t *testing.T) {
  400. groupRepo := &subscriptionGroupRepoStub{
  401. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  402. }
  403. subRepo := newSubscriptionUserSubRepoStub()
  404. oldStart := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
  405. subRepo.seed(&UserSubscription{
  406. ID: 14,
  407. UserID: 1004,
  408. GroupID: 1,
  409. StartsAt: oldStart,
  410. ExpiresAt: oldStart.AddDate(0, 0, 30),
  411. Status: SubscriptionStatusExpired,
  412. Notes: "old assignment",
  413. })
  414. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  415. sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
  416. UserID: 1004,
  417. GroupID: 1,
  418. ValidityDays: 30,
  419. Notes: "new assignment",
  420. })
  421. require.NoError(t, err)
  422. require.Equal(t, "old assignment\nnew assignment", sub.Notes)
  423. }
  424. func TestAssignSubscriptionConflictWhenSemanticsMismatch(t *testing.T) {
  425. start := time.Now().Add(-time.Hour)
  426. groupRepo := &subscriptionGroupRepoStub{
  427. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  428. }
  429. subRepo := newSubscriptionUserSubRepoStub()
  430. subRepo.seed(&UserSubscription{
  431. ID: 11,
  432. UserID: 2001,
  433. GroupID: 1,
  434. StartsAt: start,
  435. ExpiresAt: start.AddDate(0, 0, 30),
  436. Status: SubscriptionStatusActive,
  437. Notes: "old-note",
  438. })
  439. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  440. _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
  441. UserID: 2001,
  442. GroupID: 1,
  443. ValidityDays: 30,
  444. Notes: "new-note",
  445. })
  446. require.Error(t, err)
  447. require.Equal(t, "SUBSCRIPTION_ASSIGN_CONFLICT", infraerrorsReason(err))
  448. require.Equal(t, 0, subRepo.createCalls, "conflict should not create or mutate existing subscription")
  449. }
  450. func TestBulkAssignSubscriptionCreatedReusedAndConflict(t *testing.T) {
  451. start := time.Now().Add(-time.Hour)
  452. groupRepo := &subscriptionGroupRepoStub{
  453. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  454. }
  455. subRepo := newSubscriptionUserSubRepoStub()
  456. // user 1: 语义一致,可 reused
  457. subRepo.seed(&UserSubscription{
  458. ID: 21,
  459. UserID: 1,
  460. GroupID: 1,
  461. StartsAt: start,
  462. ExpiresAt: start.AddDate(0, 0, 30),
  463. Status: SubscriptionStatusActive,
  464. Notes: "same-note",
  465. })
  466. // user 3: 语义冲突(有效期不一致),应 failed
  467. subRepo.seed(&UserSubscription{
  468. ID: 23,
  469. UserID: 3,
  470. GroupID: 1,
  471. StartsAt: start,
  472. ExpiresAt: start.AddDate(0, 0, 60),
  473. Status: SubscriptionStatusActive,
  474. Notes: "same-note",
  475. })
  476. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  477. result, err := svc.BulkAssignSubscription(context.Background(), &BulkAssignSubscriptionInput{
  478. UserIDs: []int64{1, 2, 3},
  479. GroupID: 1,
  480. ValidityDays: 30,
  481. AssignedBy: 9,
  482. Notes: "same-note",
  483. })
  484. require.NoError(t, err)
  485. require.Equal(t, 2, result.SuccessCount)
  486. require.Equal(t, 1, result.CreatedCount)
  487. require.Equal(t, 1, result.ReusedCount)
  488. require.Equal(t, 1, result.FailedCount)
  489. require.Equal(t, "reused", result.Statuses[1])
  490. require.Equal(t, "created", result.Statuses[2])
  491. require.Equal(t, "failed", result.Statuses[3])
  492. require.Equal(t, 1, subRepo.createCalls)
  493. }
  494. func TestBulkAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) {
  495. groupRepo := &subscriptionGroupRepoStub{
  496. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  497. }
  498. subRepo := newSubscriptionUserSubRepoStub()
  499. oldStart := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
  500. subRepo.seed(&UserSubscription{
  501. ID: 24,
  502. UserID: 4,
  503. GroupID: 1,
  504. StartsAt: oldStart,
  505. ExpiresAt: oldStart.AddDate(0, 0, 7),
  506. Status: SubscriptionStatusExpired,
  507. DailyUsageUSD: 1,
  508. WeeklyUsageUSD: 2,
  509. MonthlyUsageUSD: 3,
  510. Notes: "bulk",
  511. })
  512. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  513. before := time.Now()
  514. result, err := svc.BulkAssignSubscription(context.Background(), &BulkAssignSubscriptionInput{
  515. UserIDs: []int64{4},
  516. GroupID: 1,
  517. ValidityDays: 7,
  518. Notes: "bulk",
  519. })
  520. after := time.Now()
  521. require.NoError(t, err)
  522. require.Equal(t, 1, result.SuccessCount)
  523. require.Equal(t, 0, result.CreatedCount)
  524. require.Equal(t, 1, result.ReusedCount)
  525. require.Equal(t, "reused", result.Statuses[4])
  526. require.Len(t, result.Subscriptions, 1)
  527. renewed := result.Subscriptions[0]
  528. require.Equal(t, SubscriptionStatusActive, renewed.Status)
  529. require.False(t, renewed.StartsAt.Before(before))
  530. require.False(t, renewed.StartsAt.After(after))
  531. require.Equal(t, renewed.StartsAt.AddDate(0, 0, 7), renewed.ExpiresAt)
  532. require.Zero(t, renewed.DailyUsageUSD)
  533. require.Zero(t, renewed.WeeklyUsageUSD)
  534. require.Zero(t, renewed.MonthlyUsageUSD)
  535. require.Equal(t, "bulk", renewed.Notes)
  536. }
  537. func TestAssignSubscriptionKeepsWorkingWhenIdempotencyStoreUnavailable(t *testing.T) {
  538. groupRepo := &subscriptionGroupRepoStub{
  539. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
  540. }
  541. subRepo := newSubscriptionUserSubRepoStub()
  542. SetDefaultIdempotencyCoordinator(NewIdempotencyCoordinator(failingIdempotencyRepo{}, DefaultIdempotencyConfig()))
  543. t.Cleanup(func() {
  544. SetDefaultIdempotencyCoordinator(nil)
  545. })
  546. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  547. sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
  548. UserID: 9001,
  549. GroupID: 1,
  550. ValidityDays: 30,
  551. Notes: "new",
  552. })
  553. require.NoError(t, err)
  554. require.NotNil(t, sub)
  555. require.Equal(t, 1, subRepo.createCalls, "semantic idempotent endpoint should not depend on idempotency store availability")
  556. }
  557. func TestNormalizeAssignValidityDays(t *testing.T) {
  558. require.Equal(t, 30, normalizeAssignValidityDays(0))
  559. require.Equal(t, 30, normalizeAssignValidityDays(-5))
  560. require.Equal(t, MaxValidityDays, normalizeAssignValidityDays(MaxValidityDays+100))
  561. require.Equal(t, 7, normalizeAssignValidityDays(7))
  562. }
  563. func TestDetectAssignSemanticConflictCases(t *testing.T) {
  564. start := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC)
  565. base := &UserSubscription{
  566. UserID: 1,
  567. GroupID: 1,
  568. StartsAt: start,
  569. ExpiresAt: start.AddDate(0, 0, 30),
  570. Notes: "same",
  571. }
  572. reason, conflict := detectAssignSemanticConflict(base, &AssignSubscriptionInput{
  573. UserID: 1,
  574. GroupID: 1,
  575. ValidityDays: 30,
  576. Notes: "same",
  577. })
  578. require.False(t, conflict)
  579. require.Equal(t, "", reason)
  580. reason, conflict = detectAssignSemanticConflict(base, &AssignSubscriptionInput{
  581. UserID: 1,
  582. GroupID: 1,
  583. ValidityDays: 60,
  584. Notes: "same",
  585. })
  586. require.True(t, conflict)
  587. require.Equal(t, "validity_days_mismatch", reason)
  588. reason, conflict = detectAssignSemanticConflict(base, &AssignSubscriptionInput{
  589. UserID: 1,
  590. GroupID: 1,
  591. ValidityDays: 30,
  592. Notes: "other",
  593. })
  594. require.True(t, conflict)
  595. require.Equal(t, "notes_mismatch", reason)
  596. }
  597. func TestAssignSubscriptionGroupTypeValidation(t *testing.T) {
  598. groupRepo := &subscriptionGroupRepoStub{
  599. group: &Group{ID: 1, SubscriptionType: SubscriptionTypeStandard},
  600. }
  601. subRepo := newSubscriptionUserSubRepoStub()
  602. svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
  603. _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
  604. UserID: 1,
  605. GroupID: 1,
  606. ValidityDays: 30,
  607. })
  608. require.Error(t, err)
  609. require.Equal(t, infraerrors.Code(ErrGroupNotSubscriptionType), infraerrors.Code(err))
  610. }
  611. func strconvFormatInt(v int64) string {
  612. return strconv.FormatInt(v, 10)
  613. }
  614. func infraerrorsReason(err error) string {
  615. return infraerrors.Reason(err)
  616. }