Files
sub2api/backend/internal/service/subscription_renewal_lock_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

144 lines
5.0 KiB
Go

package service
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type lockingRenewalRepo struct {
userSubRepoNoop
mu sync.Mutex
stale UserSubscription
current UserSubscription
lockReads int
}
func (r *lockingRenewalRepo) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) {
return true, nil
}
func (r *lockingRenewalRepo) GetByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) {
copy := r.stale
return &copy, nil
}
func (r *lockingRenewalRepo) GetByID(_ context.Context, _ int64) (*UserSubscription, error) {
r.mu.Lock()
defer r.mu.Unlock()
copy := r.current
return &copy, nil
}
func (r *lockingRenewalRepo) GetByIDForUpdate(_ context.Context, _ int64) (*UserSubscription, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.lockReads++
copy := r.current
return &copy, nil
}
func (r *lockingRenewalRepo) ExtendExpiry(_ context.Context, _ int64, expiresAt time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
r.current.ExpiresAt = expiresAt
return nil
}
func (r *lockingRenewalRepo) UpdateStatus(_ context.Context, _ int64, status string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.current.Status = status
return nil
}
func (r *lockingRenewalRepo) UpdateNotes(_ context.Context, _ int64, notes string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.current.Notes = notes
return nil
}
func (r *lockingRenewalRepo) Update(_ context.Context, sub *UserSubscription) error {
r.mu.Lock()
defer r.mu.Unlock()
r.current = *sub
return nil
}
func TestAssignOrExtendSubscriptionUsesLockedCurrentRow(t *testing.T) {
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
lockedExpiry := now.AddDate(0, 0, 20)
windowStart := now.Add(-24 * time.Hour)
repo := &lockingRenewalRepo{
stale: UserSubscription{ID: 7, UserID: 11, GroupID: 13, ExpiresAt: now.Add(-time.Hour), Status: SubscriptionStatusExpired, Notes: "stale"},
current: UserSubscription{
ID: 7, UserID: 11, GroupID: 13, StartsAt: now.AddDate(0, 0, -10), ExpiresAt: lockedExpiry,
Status: SubscriptionStatusSuspended, Notes: "current", DailyWindowStart: &windowStart, DailyUsageUSD: 4,
},
}
svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 13, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub, extended, err := svc.AssignOrExtendSubscription(context.Background(), &AssignSubscriptionInput{
UserID: 11, GroupID: 13, ValidityDays: 5, Notes: "renewed",
})
require.NoError(t, err)
require.True(t, extended)
require.Equal(t, 1, repo.lockReads)
require.Equal(t, lockedExpiry.AddDate(0, 0, 5), sub.ExpiresAt)
require.Equal(t, SubscriptionStatusActive, sub.Status)
require.Equal(t, "current\nrenewed", sub.Notes)
require.Equal(t, windowStart, *sub.DailyWindowStart)
require.Equal(t, float64(4), sub.DailyUsageUSD)
}
func TestAssignOrExtendSubscriptionSerializedRenewalsAccumulateDays(t *testing.T) {
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
initialExpiry := now.AddDate(0, 0, 10)
stale := UserSubscription{ID: 17, UserID: 21, GroupID: 23, StartsAt: now, ExpiresAt: initialExpiry, Status: SubscriptionStatusActive}
repo := &lockingRenewalRepo{stale: stale, current: stale}
svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 23, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
input := &AssignSubscriptionInput{UserID: 21, GroupID: 23, ValidityDays: 7}
_, _, err := svc.AssignOrExtendSubscription(context.Background(), input)
require.NoError(t, err)
second, _, err := svc.AssignOrExtendSubscription(context.Background(), input)
require.NoError(t, err)
require.Equal(t, 2, repo.lockReads)
require.Equal(t, initialExpiry.AddDate(0, 0, 14), second.ExpiresAt)
}
func TestAssignSubscriptionDoesNotReactivateRowSuspendedAfterStaleRead(t *testing.T) {
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
windowStart := now.Add(-24 * time.Hour)
current := UserSubscription{
ID: 27, UserID: 31, GroupID: 33, StartsAt: now.AddDate(0, 0, -10), ExpiresAt: now.Add(-time.Hour),
Status: SubscriptionStatusSuspended, Notes: "suspended", DailyWindowStart: &windowStart, DailyUsageUSD: 4,
}
repo := &lockingRenewalRepo{
stale: UserSubscription{ID: 27, UserID: 31, GroupID: 33, ExpiresAt: now.Add(-time.Hour), Status: SubscriptionStatusExpired},
current: current,
}
svc := NewSubscriptionService(&subscriptionGroupRepoStub{group: &Group{ID: 33, SubscriptionType: SubscriptionTypeSubscription}}, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
sub, reused, err := svc.assignSubscriptionWithReuse(context.Background(), &AssignSubscriptionInput{
UserID: 31, GroupID: 33, ValidityDays: 5, Notes: "renewed",
})
require.NoError(t, err)
require.True(t, reused)
require.Equal(t, 1, repo.lockReads)
require.Equal(t, current, repo.current)
require.Equal(t, SubscriptionStatusSuspended, sub.Status)
require.Equal(t, current.ExpiresAt, sub.ExpiresAt)
require.Equal(t, current.Notes, sub.Notes)
}