Files
sub2api/backend/internal/service/subscription_assign_idempotency_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

675 lines
22 KiB
Go

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)
}