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
144 lines
5.0 KiB
Go
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 ©, nil
|
|
}
|
|
|
|
func (r *lockingRenewalRepo) GetByID(_ context.Context, _ int64) (*UserSubscription, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
copy := r.current
|
|
return ©, nil
|
|
}
|
|
|
|
func (r *lockingRenewalRepo) GetByIDForUpdate(_ context.Context, _ int64) (*UserSubscription, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.lockReads++
|
|
copy := r.current
|
|
return ©, 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)
|
|
}
|