mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-11 14:32:36 +00:00
231 lines
7.9 KiB
Go
231 lines
7.9 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type bulkActionSubscriptionRepo struct {
|
|
UserSubscriptionRepository
|
|
subscriptions map[int64]*UserSubscription
|
|
mutations []int64
|
|
afterMutation func()
|
|
}
|
|
|
|
func (r *bulkActionSubscriptionRepo) GetByIDIncludeDeleted(_ context.Context, id int64) (*UserSubscription, error) {
|
|
sub := r.subscriptions[id]
|
|
if sub == nil {
|
|
return nil, ErrSubscriptionNotFound
|
|
}
|
|
copy := *sub
|
|
return ©, nil
|
|
}
|
|
|
|
func (r *bulkActionSubscriptionRepo) GetByID(ctx context.Context, id int64) (*UserSubscription, error) {
|
|
sub, err := r.GetByIDIncludeDeleted(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sub.DeletedAt != nil {
|
|
return nil, ErrSubscriptionNotFound
|
|
}
|
|
return sub, nil
|
|
}
|
|
|
|
func (r *bulkActionSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*UserSubscription, error) {
|
|
return r.GetByID(ctx, id)
|
|
}
|
|
|
|
func (r *bulkActionSubscriptionRepo) mutated(id int64) {
|
|
r.mutations = append(r.mutations, id)
|
|
if r.afterMutation != nil {
|
|
r.afterMutation()
|
|
}
|
|
}
|
|
|
|
func (r *bulkActionSubscriptionRepo) ExtendExpiry(_ context.Context, id int64, expiresAt time.Time) error {
|
|
r.subscriptions[id].ExpiresAt = expiresAt
|
|
r.mutated(id)
|
|
return nil
|
|
}
|
|
|
|
func (r *bulkActionSubscriptionRepo) ResetUsageWindows(_ context.Context, id int64, daily, weekly, monthly bool, dailyStart, periodicStart time.Time) error {
|
|
sub := r.subscriptions[id]
|
|
if daily {
|
|
sub.DailyUsageUSD, sub.DailyWindowStart = 0, &dailyStart
|
|
}
|
|
if weekly {
|
|
sub.WeeklyUsageUSD, sub.WeeklyWindowStart = 0, &periodicStart
|
|
}
|
|
if monthly {
|
|
sub.MonthlyUsageUSD, sub.MonthlyWindowStart = 0, &periodicStart
|
|
}
|
|
r.mutated(id)
|
|
return nil
|
|
}
|
|
|
|
func (r *bulkActionSubscriptionRepo) Delete(_ context.Context, id int64) error {
|
|
now := time.Now()
|
|
r.subscriptions[id].DeletedAt = &now
|
|
r.mutated(id)
|
|
return nil
|
|
}
|
|
|
|
func (r *bulkActionSubscriptionRepo) ExistsActiveByUserIDAndGroupID(_ context.Context, userID, groupID int64) (bool, error) {
|
|
for _, sub := range r.subscriptions {
|
|
if sub.UserID == userID && sub.GroupID == groupID && sub.DeletedAt == nil {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (r *bulkActionSubscriptionRepo) Restore(_ context.Context, id int64, status string) (*UserSubscription, error) {
|
|
sub := r.subscriptions[id]
|
|
sub.DeletedAt, sub.Status = nil, status
|
|
r.mutated(id)
|
|
copy := *sub
|
|
return ©, nil
|
|
}
|
|
|
|
func TestBulkSubscriptionAction_PartialSuccessAndDeduplication(t *testing.T) {
|
|
for _, action := range []string{"extend", "reset_quota", "revoke", "restore"} {
|
|
t.Run(action, func(t *testing.T) {
|
|
expiresAt := time.Now().AddDate(0, 0, 30)
|
|
repo := &bulkActionSubscriptionRepo{subscriptions: map[int64]*UserSubscription{}}
|
|
for _, id := range []int64{1, 2} {
|
|
sub := &UserSubscription{ID: id, UserID: id, GroupID: 10, Status: SubscriptionStatusActive, ExpiresAt: expiresAt, DailyUsageUSD: 2, WeeklyUsageUSD: 5, MonthlyUsageUSD: 8}
|
|
if action == "restore" {
|
|
deletedAt := time.Now().Add(-time.Hour)
|
|
sub.DeletedAt = &deletedAt
|
|
}
|
|
repo.subscriptions[id] = sub
|
|
}
|
|
svc := NewSubscriptionService(nil, repo, nil, nil, nil)
|
|
t.Cleanup(svc.Stop)
|
|
|
|
result, err := svc.BulkSubscriptionAction(context.Background(), &BulkSubscriptionActionInput{
|
|
SubscriptionIDs: []int64{2, 404, 1, 2, 404}, Action: action, Days: 7, Daily: true, Weekly: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 2, result.SuccessCount)
|
|
require.Equal(t, 1, result.FailedCount)
|
|
require.Equal(t, []BulkSubscriptionActionItemResult{
|
|
{SubscriptionID: 2, Success: true},
|
|
{SubscriptionID: 404, Error: "subscription not found"},
|
|
{SubscriptionID: 1, Success: true},
|
|
}, result.Results)
|
|
require.Equal(t, []int64{2, 1}, repo.mutations)
|
|
for _, sub := range repo.subscriptions {
|
|
switch action {
|
|
case "extend":
|
|
require.Equal(t, expiresAt.AddDate(0, 0, 7), sub.ExpiresAt)
|
|
case "reset_quota":
|
|
require.Zero(t, sub.DailyUsageUSD)
|
|
require.Zero(t, sub.WeeklyUsageUSD)
|
|
require.Equal(t, float64(8), sub.MonthlyUsageUSD)
|
|
case "revoke":
|
|
require.NotNil(t, sub.DeletedAt)
|
|
case "restore":
|
|
require.Nil(t, sub.DeletedAt)
|
|
require.Equal(t, SubscriptionStatusActive, sub.Status)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBulkSubscriptionAction_ValidatesBeforeAnyRepositoryAccess(t *testing.T) {
|
|
tooMany := make([]int64, MaxBulkSubscriptionActions+1)
|
|
for i := range tooMany {
|
|
tooMany[i] = 1
|
|
}
|
|
for name, input := range map[string]*BulkSubscriptionActionInput{
|
|
"nil": nil,
|
|
"empty IDs": {Action: "revoke"},
|
|
"too many IDs": {SubscriptionIDs: tooMany, Action: "revoke"},
|
|
"invalid later ID": {SubscriptionIDs: []int64{1, 0}, Action: "revoke"},
|
|
"negative ID": {SubscriptionIDs: []int64{1, -1}, Action: "revoke"},
|
|
"unknown action": {SubscriptionIDs: []int64{1}, Action: "delete"},
|
|
"missing action": {SubscriptionIDs: []int64{1}},
|
|
"zero adjustment": {SubscriptionIDs: []int64{1}, Action: "extend"},
|
|
"large adjustment": {SubscriptionIDs: []int64{1}, Action: "extend", Days: MaxValidityDays + 1},
|
|
"small adjustment": {SubscriptionIDs: []int64{1}, Action: "extend", Days: -MaxValidityDays - 1},
|
|
"no reset windows": {SubscriptionIDs: []int64{1}, Action: "reset_quota"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
// A nil repository would panic if validation allowed any execution.
|
|
svc := &SubscriptionService{}
|
|
result, err := svc.BulkSubscriptionAction(context.Background(), input)
|
|
require.Error(t, err)
|
|
require.Equal(t, 400, infraerrors.Code(err))
|
|
require.Nil(t, result)
|
|
})
|
|
}
|
|
for _, days := range []int{-MaxValidityDays, -1, 1, MaxValidityDays} {
|
|
input := BulkSubscriptionActionInput{SubscriptionIDs: []int64{1}, Action: "extend", Days: days}
|
|
require.NoError(t, input.Validate())
|
|
}
|
|
}
|
|
|
|
func TestBulkSubscriptionAction_CancellationPreservesCompletedResults(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
repo := &bulkActionSubscriptionRepo{
|
|
subscriptions: map[int64]*UserSubscription{1: {ID: 1, UserID: 1, GroupID: 10}},
|
|
afterMutation: cancel,
|
|
}
|
|
svc := NewSubscriptionService(nil, repo, nil, nil, nil)
|
|
t.Cleanup(svc.Stop)
|
|
result, err := svc.BulkSubscriptionAction(ctx, &BulkSubscriptionActionInput{SubscriptionIDs: []int64{1, 2, 3}, Action: "revoke"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, result.SuccessCount)
|
|
require.Equal(t, 2, result.FailedCount)
|
|
require.Equal(t, []int64{1}, repo.mutations)
|
|
require.True(t, result.Results[0].Success)
|
|
for _, item := range result.Results[1:] {
|
|
require.False(t, item.Success)
|
|
require.Equal(t, context.Canceled.Error(), item.Error)
|
|
}
|
|
}
|
|
|
|
type failingBulkActionSubscriptionRepo struct {
|
|
*bulkActionSubscriptionRepo
|
|
err error
|
|
}
|
|
|
|
func (r failingBulkActionSubscriptionRepo) Delete(context.Context, int64) error {
|
|
return r.err
|
|
}
|
|
|
|
func TestBulkSubscriptionAction_DoesNotExposeInternalErrors(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{name: "internal failure", err: errors.New("postgres: internal connection details"), want: "internal error"},
|
|
{name: "wrapped cancellation", err: fmt.Errorf("postgres: internal connection details: %w", context.Canceled), want: context.Canceled.Error()},
|
|
{name: "wrapped deadline", err: fmt.Errorf("postgres: internal connection details: %w", context.DeadlineExceeded), want: context.DeadlineExceeded.Error()},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
repo := failingBulkActionSubscriptionRepo{
|
|
bulkActionSubscriptionRepo: &bulkActionSubscriptionRepo{subscriptions: map[int64]*UserSubscription{1: {ID: 1}}},
|
|
err: tc.err,
|
|
}
|
|
svc := NewSubscriptionService(nil, repo, nil, nil, nil)
|
|
t.Cleanup(svc.Stop)
|
|
result, err := svc.BulkSubscriptionAction(context.Background(), &BulkSubscriptionActionInput{SubscriptionIDs: []int64{1}, Action: "revoke"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, result.FailedCount)
|
|
require.Equal(t, tc.want, result.Results[0].Error)
|
|
})
|
|
}
|
|
}
|