| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674 |
- package service
- import (
- "context"
- "strconv"
- "testing"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
- "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
- "github.com/dgraph-io/ristretto"
- "github.com/stretchr/testify/require"
- )
- func TestWithSubscriptionUpdateTx_ReusesExistingTransaction(t *testing.T) {
- existingTx := &dbent.Tx{}
- ctx := dbent.NewTxContext(context.Background(), existingTx)
- svc := &SubscriptionService{entClient: &dbent.Client{}}
- called := false
- err := svc.withSubscriptionUpdateTx(ctx, func(txCtx context.Context) error {
- called = true
- require.Same(t, existingTx, dbent.TxFromContext(txCtx))
- return nil
- })
- require.NoError(t, err)
- require.True(t, called)
- }
- func TestMaybeInvalidateAssignmentCaches_DefersForOuterTransactionOwner(t *testing.T) {
- cache, err := ristretto.NewCache(&ristretto.Config{NumCounters: 1_000, MaxCost: 100, BufferItems: 64})
- require.NoError(t, err)
- t.Cleanup(cache.Close)
- svc := &SubscriptionService{subCacheL1: cache}
- key := subCacheKey(7, 9)
- require.True(t, cache.Set(key, &UserSubscription{ID: 42}, 1))
- cache.Wait()
- svc.maybeInvalidateAssignmentCaches(7, 9, true)
- _, cachedBeforeCommit := cache.Get(key)
- require.True(t, cachedBeforeCommit, "outer transaction must retain caches until its owner commits")
- svc.maybeInvalidateAssignmentCaches(7, 9, false)
- cache.Wait()
- _, cachedAfterCommit := cache.Get(key)
- require.False(t, cachedAfterCommit, "post-commit invalidation must remove the cached subscription")
- }
- type groupRepoNoop struct{}
- func (groupRepoNoop) Create(context.Context, *Group) error { panic("unexpected Create call") }
- func (groupRepoNoop) GetByID(context.Context, int64) (*Group, error) {
- panic("unexpected GetByID call")
- }
- func (groupRepoNoop) GetByIDLite(context.Context, int64) (*Group, error) {
- panic("unexpected GetByIDLite call")
- }
- func (groupRepoNoop) Update(context.Context, *Group) error { panic("unexpected Update call") }
- func (groupRepoNoop) Delete(context.Context, int64) error { panic("unexpected Delete call") }
- func (groupRepoNoop) DeleteCascade(context.Context, int64) ([]int64, error) {
- panic("unexpected DeleteCascade call")
- }
- func (groupRepoNoop) List(context.Context, pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
- panic("unexpected List call")
- }
- func (groupRepoNoop) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]Group, *pagination.PaginationResult, error) {
- panic("unexpected ListWithFilters call")
- }
- func (groupRepoNoop) ListActive(context.Context) ([]Group, error) {
- panic("unexpected ListActive call")
- }
- func (groupRepoNoop) ListActiveByPlatform(context.Context, string) ([]Group, error) {
- panic("unexpected ListActiveByPlatform call")
- }
- func (groupRepoNoop) ExistsByName(context.Context, string) (bool, error) {
- panic("unexpected ExistsByName call")
- }
- func (groupRepoNoop) GetAccountCount(context.Context, int64) (int64, int64, error) {
- panic("unexpected GetAccountCount call")
- }
- func (groupRepoNoop) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
- panic("unexpected DeleteAccountGroupsByGroupID call")
- }
- func (groupRepoNoop) GetAccountIDsByGroupIDs(context.Context, []int64) ([]int64, error) {
- panic("unexpected GetAccountIDsByGroupIDs call")
- }
- func (groupRepoNoop) BindAccountsToGroup(context.Context, int64, []int64) error {
- panic("unexpected BindAccountsToGroup call")
- }
- func (groupRepoNoop) UpdateSortOrders(context.Context, []GroupSortOrderUpdate) error {
- panic("unexpected UpdateSortOrders call")
- }
- type subscriptionGroupRepoStub struct {
- groupRepoNoop
- group *Group
- }
- func (s *subscriptionGroupRepoStub) GetByID(context.Context, int64) (*Group, error) {
- return s.group, nil
- }
- type userSubRepoNoop struct{}
- func (userSubRepoNoop) Create(context.Context, *UserSubscription) error {
- panic("unexpected Create call")
- }
- func (userSubRepoNoop) GetByID(context.Context, int64) (*UserSubscription, error) {
- panic("unexpected GetByID call")
- }
- func (userSubRepoNoop) GetByIDForUpdate(context.Context, int64) (*UserSubscription, error) {
- panic("unexpected GetByIDForUpdate call")
- }
- func (userSubRepoNoop) GetByIDIncludeDeleted(context.Context, int64) (*UserSubscription, error) {
- panic("unexpected GetByIDIncludeDeleted call")
- }
- func (userSubRepoNoop) GetByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) {
- panic("unexpected GetByUserIDAndGroupID call")
- }
- func (userSubRepoNoop) GetActiveByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) {
- panic("unexpected GetActiveByUserIDAndGroupID call")
- }
- func (userSubRepoNoop) Update(context.Context, *UserSubscription) error {
- panic("unexpected Update call")
- }
- func (userSubRepoNoop) Delete(context.Context, int64) error { panic("unexpected Delete call") }
- func (userSubRepoNoop) Restore(context.Context, int64, string) (*UserSubscription, error) {
- panic("unexpected Restore call")
- }
- func (userSubRepoNoop) ListByUserID(context.Context, int64) ([]UserSubscription, error) {
- panic("unexpected ListByUserID call")
- }
- func (userSubRepoNoop) ListActiveByUserID(context.Context, int64) ([]UserSubscription, error) {
- panic("unexpected ListActiveByUserID call")
- }
- func (userSubRepoNoop) ListByGroupID(context.Context, int64, pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error) {
- panic("unexpected ListByGroupID call")
- }
- func (userSubRepoNoop) List(context.Context, pagination.PaginationParams, *int64, *int64, string, string, string, string) ([]UserSubscription, *pagination.PaginationResult, error) {
- panic("unexpected List call")
- }
- func (userSubRepoNoop) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) {
- panic("unexpected ExistsByUserIDAndGroupID call")
- }
- func (userSubRepoNoop) ExistsActiveByUserIDAndGroupID(context.Context, int64, int64) (bool, error) {
- panic("unexpected ExistsActiveByUserIDAndGroupID call")
- }
- func (userSubRepoNoop) ExtendExpiry(context.Context, int64, time.Time) error {
- panic("unexpected ExtendExpiry call")
- }
- func (userSubRepoNoop) UpdateStatus(context.Context, int64, string) error {
- panic("unexpected UpdateStatus call")
- }
- func (userSubRepoNoop) UpdateNotes(context.Context, int64, string) error {
- panic("unexpected UpdateNotes call")
- }
- func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time, time.Time) error {
- panic("unexpected ActivateWindows call")
- }
- func (userSubRepoNoop) ResetUsageWindows(context.Context, int64, bool, bool, bool, time.Time, time.Time) error {
- panic("unexpected ResetUsageWindows call")
- }
- func (userSubRepoNoop) ResetDailyUsage(context.Context, int64, *time.Time, time.Time) error {
- panic("unexpected ResetDailyUsage call")
- }
- func (userSubRepoNoop) ResetWeeklyUsage(context.Context, int64, *time.Time, time.Time) error {
- panic("unexpected ResetWeeklyUsage call")
- }
- func (userSubRepoNoop) ResetMonthlyUsage(context.Context, int64, *time.Time, time.Time) error {
- panic("unexpected ResetMonthlyUsage call")
- }
- func (userSubRepoNoop) IncrementUsage(context.Context, int64, float64) error {
- panic("unexpected IncrementUsage call")
- }
- func (userSubRepoNoop) BatchUpdateExpiredStatus(context.Context) (int64, error) {
- panic("unexpected BatchUpdateExpiredStatus call")
- }
- type subscriptionUserSubRepoStub struct {
- userSubRepoNoop
- nextID int64
- byID map[int64]*UserSubscription
- byUserGroup map[string]*UserSubscription
- createCalls int
- }
- func newSubscriptionUserSubRepoStub() *subscriptionUserSubRepoStub {
- return &subscriptionUserSubRepoStub{
- nextID: 1,
- byID: make(map[int64]*UserSubscription),
- byUserGroup: make(map[string]*UserSubscription),
- }
- }
- func (s *subscriptionUserSubRepoStub) key(userID, groupID int64) string {
- return strconvFormatInt(userID) + ":" + strconvFormatInt(groupID)
- }
- func (s *subscriptionUserSubRepoStub) seed(sub *UserSubscription) {
- if sub == nil {
- return
- }
- cp := *sub
- if cp.ID == 0 {
- cp.ID = s.nextID
- s.nextID++
- }
- s.byID[cp.ID] = &cp
- s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp
- }
- func (s *subscriptionUserSubRepoStub) ExistsByUserIDAndGroupID(_ context.Context, userID, groupID int64) (bool, error) {
- _, ok := s.byUserGroup[s.key(userID, groupID)]
- return ok, nil
- }
- func (s *subscriptionUserSubRepoStub) GetByUserIDAndGroupID(_ context.Context, userID, groupID int64) (*UserSubscription, error) {
- sub := s.byUserGroup[s.key(userID, groupID)]
- if sub == nil {
- return nil, ErrSubscriptionNotFound
- }
- cp := *sub
- return &cp, nil
- }
- func (s *subscriptionUserSubRepoStub) Create(_ context.Context, sub *UserSubscription) error {
- if sub == nil {
- return nil
- }
- s.createCalls++
- cp := *sub
- if cp.ID == 0 {
- cp.ID = s.nextID
- s.nextID++
- }
- sub.ID = cp.ID
- s.byID[cp.ID] = &cp
- s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp
- return nil
- }
- func (s *subscriptionUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
- sub := s.byID[id]
- if sub == nil {
- return nil, ErrSubscriptionNotFound
- }
- cp := *sub
- return &cp, nil
- }
- func (s *subscriptionUserSubRepoStub) GetByIDForUpdate(ctx context.Context, id int64) (*UserSubscription, error) {
- return s.GetByID(ctx, id)
- }
- func (s *subscriptionUserSubRepoStub) Update(_ context.Context, sub *UserSubscription) error {
- if sub == nil {
- return ErrSubscriptionNilInput
- }
- existing := s.byID[sub.ID]
- if existing == nil {
- return ErrSubscriptionNotFound
- }
- oldKey := s.key(existing.UserID, existing.GroupID)
- cp := *sub
- s.byID[cp.ID] = &cp
- if oldKey != s.key(cp.UserID, cp.GroupID) {
- delete(s.byUserGroup, oldKey)
- }
- s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp
- return nil
- }
- func TestAssignSubscriptionReuseWhenSemanticsMatch(t *testing.T) {
- start := time.Now().Add(-time.Hour)
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- subRepo.seed(&UserSubscription{
- ID: 10,
- UserID: 1001,
- GroupID: 1,
- StartsAt: start,
- ExpiresAt: start.AddDate(0, 0, 30),
- Status: SubscriptionStatusActive,
- Notes: "init",
- })
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
- UserID: 1001,
- GroupID: 1,
- ValidityDays: 30,
- Notes: "init",
- })
- require.NoError(t, err)
- require.Equal(t, int64(10), sub.ID)
- require.Equal(t, 0, subRepo.createCalls, "reuse should not create new subscription")
- require.Equal(t, start, sub.StartsAt)
- require.Equal(t, start.AddDate(0, 0, 30), sub.ExpiresAt)
- }
- func TestAssignSubscriptionDoesNotReactivateFutureSuspendedSubscription(t *testing.T) {
- start := time.Now().Add(-time.Hour)
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- subRepo.seed(&UserSubscription{
- ID: 13,
- UserID: 1003,
- GroupID: 1,
- StartsAt: start,
- ExpiresAt: start.AddDate(0, 0, 30),
- Status: SubscriptionStatusSuspended,
- Notes: "assignment",
- })
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
- UserID: 1003,
- GroupID: 1,
- ValidityDays: 30,
- Notes: "assignment",
- })
- require.NoError(t, err)
- require.Equal(t, int64(13), sub.ID)
- require.Equal(t, SubscriptionStatusSuspended, sub.Status)
- require.Equal(t, start, sub.StartsAt)
- require.Equal(t, start.AddDate(0, 0, 30), sub.ExpiresAt)
- require.Equal(t, "assignment", sub.Notes)
- require.Equal(t, 0, subRepo.createCalls)
- }
- func TestAssignSubscriptionDoesNotReactivatePastExpirySuspendedSubscription(t *testing.T) {
- start := time.Now().AddDate(0, 0, -31)
- expiresAt := start.AddDate(0, 0, 30)
- windowStart := startOfDay(start)
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- subRepo.seed(&UserSubscription{
- ID: 15,
- UserID: 1005,
- GroupID: 1,
- StartsAt: start,
- ExpiresAt: expiresAt,
- Status: SubscriptionStatusSuspended,
- DailyWindowStart: &windowStart,
- WeeklyWindowStart: &windowStart,
- MonthlyWindowStart: &windowStart,
- DailyUsageUSD: 1,
- WeeklyUsageUSD: 2,
- MonthlyUsageUSD: 3,
- Notes: "suspended assignment",
- })
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
- UserID: 1005,
- GroupID: 1,
- ValidityDays: 30,
- Notes: "suspended assignment",
- })
- require.NoError(t, err)
- require.Equal(t, int64(15), sub.ID)
- require.Equal(t, SubscriptionStatusSuspended, sub.Status)
- require.Equal(t, start, sub.StartsAt)
- require.Equal(t, expiresAt, sub.ExpiresAt)
- require.Equal(t, "suspended assignment", sub.Notes)
- require.Equal(t, &windowStart, sub.DailyWindowStart)
- require.Equal(t, &windowStart, sub.WeeklyWindowStart)
- require.Equal(t, &windowStart, sub.MonthlyWindowStart)
- require.Equal(t, float64(1), sub.DailyUsageUSD)
- require.Equal(t, float64(2), sub.WeeklyUsageUSD)
- require.Equal(t, float64(3), sub.MonthlyUsageUSD)
- require.Equal(t, 0, subRepo.createCalls)
- }
- func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) {
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- oldStart := time.Now().Add(-time.Hour)
- oldWindowStart := startOfDay(oldStart)
- subRepo.seed(&UserSubscription{
- ID: 12,
- UserID: 1002,
- GroupID: 1,
- StartsAt: oldStart,
- ExpiresAt: oldStart.AddDate(0, 0, 30),
- Status: SubscriptionStatusExpired,
- DailyWindowStart: &oldWindowStart,
- WeeklyWindowStart: &oldWindowStart,
- MonthlyWindowStart: &oldWindowStart,
- DailyUsageUSD: 1,
- WeeklyUsageUSD: 2,
- MonthlyUsageUSD: 3,
- Notes: " assignment ",
- })
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- before := time.Now()
- sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
- UserID: 1002,
- GroupID: 1,
- ValidityDays: 30,
- Notes: "assignment",
- })
- after := time.Now()
- require.NoError(t, err)
- require.Equal(t, int64(12), sub.ID)
- require.Equal(t, 0, subRepo.createCalls)
- require.Equal(t, SubscriptionStatusActive, sub.Status)
- require.False(t, sub.StartsAt.Before(before))
- require.False(t, sub.StartsAt.After(after))
- require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt)
- require.Equal(t, timezone.StartOfDay(sub.StartsAt), *sub.DailyWindowStart, "续期后日窗口应锚定当天 0 点")
- require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart)
- require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart)
- require.Zero(t, sub.DailyUsageUSD)
- require.Zero(t, sub.WeeklyUsageUSD)
- require.Zero(t, sub.MonthlyUsageUSD)
- require.Equal(t, " assignment ", sub.Notes)
- }
- func TestAssignSubscriptionRenewsExpiredAndAppendsDifferentNotes(t *testing.T) {
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- oldStart := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
- subRepo.seed(&UserSubscription{
- ID: 14,
- UserID: 1004,
- GroupID: 1,
- StartsAt: oldStart,
- ExpiresAt: oldStart.AddDate(0, 0, 30),
- Status: SubscriptionStatusExpired,
- Notes: "old assignment",
- })
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
- UserID: 1004,
- GroupID: 1,
- ValidityDays: 30,
- Notes: "new assignment",
- })
- require.NoError(t, err)
- require.Equal(t, "old assignment\nnew assignment", sub.Notes)
- }
- func TestAssignSubscriptionConflictWhenSemanticsMismatch(t *testing.T) {
- start := time.Now().Add(-time.Hour)
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- subRepo.seed(&UserSubscription{
- ID: 11,
- UserID: 2001,
- GroupID: 1,
- StartsAt: start,
- ExpiresAt: start.AddDate(0, 0, 30),
- Status: SubscriptionStatusActive,
- Notes: "old-note",
- })
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
- UserID: 2001,
- GroupID: 1,
- ValidityDays: 30,
- Notes: "new-note",
- })
- require.Error(t, err)
- require.Equal(t, "SUBSCRIPTION_ASSIGN_CONFLICT", infraerrorsReason(err))
- require.Equal(t, 0, subRepo.createCalls, "conflict should not create or mutate existing subscription")
- }
- func TestBulkAssignSubscriptionCreatedReusedAndConflict(t *testing.T) {
- start := time.Now().Add(-time.Hour)
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- // user 1: 语义一致,可 reused
- subRepo.seed(&UserSubscription{
- ID: 21,
- UserID: 1,
- GroupID: 1,
- StartsAt: start,
- ExpiresAt: start.AddDate(0, 0, 30),
- Status: SubscriptionStatusActive,
- Notes: "same-note",
- })
- // user 3: 语义冲突(有效期不一致),应 failed
- subRepo.seed(&UserSubscription{
- ID: 23,
- UserID: 3,
- GroupID: 1,
- StartsAt: start,
- ExpiresAt: start.AddDate(0, 0, 60),
- Status: SubscriptionStatusActive,
- Notes: "same-note",
- })
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- result, err := svc.BulkAssignSubscription(context.Background(), &BulkAssignSubscriptionInput{
- UserIDs: []int64{1, 2, 3},
- GroupID: 1,
- ValidityDays: 30,
- AssignedBy: 9,
- Notes: "same-note",
- })
- require.NoError(t, err)
- require.Equal(t, 2, result.SuccessCount)
- require.Equal(t, 1, result.CreatedCount)
- require.Equal(t, 1, result.ReusedCount)
- require.Equal(t, 1, result.FailedCount)
- require.Equal(t, "reused", result.Statuses[1])
- require.Equal(t, "created", result.Statuses[2])
- require.Equal(t, "failed", result.Statuses[3])
- require.Equal(t, 1, subRepo.createCalls)
- }
- func TestBulkAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) {
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- oldStart := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
- subRepo.seed(&UserSubscription{
- ID: 24,
- UserID: 4,
- GroupID: 1,
- StartsAt: oldStart,
- ExpiresAt: oldStart.AddDate(0, 0, 7),
- Status: SubscriptionStatusExpired,
- DailyUsageUSD: 1,
- WeeklyUsageUSD: 2,
- MonthlyUsageUSD: 3,
- Notes: "bulk",
- })
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- before := time.Now()
- result, err := svc.BulkAssignSubscription(context.Background(), &BulkAssignSubscriptionInput{
- UserIDs: []int64{4},
- GroupID: 1,
- ValidityDays: 7,
- Notes: "bulk",
- })
- after := time.Now()
- require.NoError(t, err)
- require.Equal(t, 1, result.SuccessCount)
- require.Equal(t, 0, result.CreatedCount)
- require.Equal(t, 1, result.ReusedCount)
- require.Equal(t, "reused", result.Statuses[4])
- require.Len(t, result.Subscriptions, 1)
- renewed := result.Subscriptions[0]
- require.Equal(t, SubscriptionStatusActive, renewed.Status)
- require.False(t, renewed.StartsAt.Before(before))
- require.False(t, renewed.StartsAt.After(after))
- require.Equal(t, renewed.StartsAt.AddDate(0, 0, 7), renewed.ExpiresAt)
- require.Zero(t, renewed.DailyUsageUSD)
- require.Zero(t, renewed.WeeklyUsageUSD)
- require.Zero(t, renewed.MonthlyUsageUSD)
- require.Equal(t, "bulk", renewed.Notes)
- }
- func TestAssignSubscriptionKeepsWorkingWhenIdempotencyStoreUnavailable(t *testing.T) {
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeSubscription},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- SetDefaultIdempotencyCoordinator(NewIdempotencyCoordinator(failingIdempotencyRepo{}, DefaultIdempotencyConfig()))
- t.Cleanup(func() {
- SetDefaultIdempotencyCoordinator(nil)
- })
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
- UserID: 9001,
- GroupID: 1,
- ValidityDays: 30,
- Notes: "new",
- })
- require.NoError(t, err)
- require.NotNil(t, sub)
- require.Equal(t, 1, subRepo.createCalls, "semantic idempotent endpoint should not depend on idempotency store availability")
- }
- func TestNormalizeAssignValidityDays(t *testing.T) {
- require.Equal(t, 30, normalizeAssignValidityDays(0))
- require.Equal(t, 30, normalizeAssignValidityDays(-5))
- require.Equal(t, MaxValidityDays, normalizeAssignValidityDays(MaxValidityDays+100))
- require.Equal(t, 7, normalizeAssignValidityDays(7))
- }
- func TestDetectAssignSemanticConflictCases(t *testing.T) {
- start := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC)
- base := &UserSubscription{
- UserID: 1,
- GroupID: 1,
- StartsAt: start,
- ExpiresAt: start.AddDate(0, 0, 30),
- Notes: "same",
- }
- reason, conflict := detectAssignSemanticConflict(base, &AssignSubscriptionInput{
- UserID: 1,
- GroupID: 1,
- ValidityDays: 30,
- Notes: "same",
- })
- require.False(t, conflict)
- require.Equal(t, "", reason)
- reason, conflict = detectAssignSemanticConflict(base, &AssignSubscriptionInput{
- UserID: 1,
- GroupID: 1,
- ValidityDays: 60,
- Notes: "same",
- })
- require.True(t, conflict)
- require.Equal(t, "validity_days_mismatch", reason)
- reason, conflict = detectAssignSemanticConflict(base, &AssignSubscriptionInput{
- UserID: 1,
- GroupID: 1,
- ValidityDays: 30,
- Notes: "other",
- })
- require.True(t, conflict)
- require.Equal(t, "notes_mismatch", reason)
- }
- func TestAssignSubscriptionGroupTypeValidation(t *testing.T) {
- groupRepo := &subscriptionGroupRepoStub{
- group: &Group{ID: 1, SubscriptionType: SubscriptionTypeStandard},
- }
- subRepo := newSubscriptionUserSubRepoStub()
- svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
- _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
- UserID: 1,
- GroupID: 1,
- ValidityDays: 30,
- })
- require.Error(t, err)
- require.Equal(t, infraerrors.Code(ErrGroupNotSubscriptionType), infraerrors.Code(err))
- }
- func strconvFormatInt(v int64) string {
- return strconv.FormatInt(v, 10)
- }
- func infraerrorsReason(err error) string {
- return infraerrors.Reason(err)
- }
|