Files
sub2api/backend/internal/service/subscription_reset_quota_test.go
T
shaw 99b357083e fix(subscription): restore midnight daily quota reset
日额度窗口恢复日历日语义(配置时区每天 0 点刷新),修复 v0.1.170 引入的回归:
- 手动重置配额后日窗口锚点漂移到重置时刻,此后 0 点不再刷新
- 新建/续费订阅日窗口按购买或首用时刻滚动,而非 0 点刷新

日窗口三处锚点(激活/手动重置/续期)统一写入当天 0 点,自动重置改为跨日历日
边界触发;存量非 0 点锚点在下一个 0 点自愈,无需数据库迁移。周/月窗口保持
期限对齐滚动语义(含到期约束)不变,不回归 issue #5051 的月额度翻倍问题。
日卡一次性额度豁免逻辑保留。
2026-08-07 17:42:51 +08:00

275 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build unit
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/stretchr/testify/require"
)
// resetQuotaUserSubRepoStub 支持 GetByID、ResetUsageWindows,
// 其余方法继承 userSubRepoNoop(panic)。
type resetQuotaUserSubRepoStub struct {
userSubRepoNoop
sub *UserSubscription
resetDailyCalled bool
resetWeeklyCalled bool
resetMonthlyCalled bool
resetDailyErr error
resetWeeklyErr error
resetMonthlyErr error
dailyStart time.Time
periodicStart time.Time
}
func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
if r.sub == nil || r.sub.ID != id {
return nil, ErrSubscriptionNotFound
}
cp := *r.sub
return &cp, nil
}
func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64, resetDaily, resetWeekly, resetMonthly bool, dailyStart, periodicStart time.Time) error {
r.resetDailyCalled = resetDaily
r.resetWeeklyCalled = resetWeekly
r.resetMonthlyCalled = resetMonthly
r.dailyStart = dailyStart
r.periodicStart = periodicStart
if resetDaily && r.resetDailyErr != nil {
return r.resetDailyErr
}
if resetWeekly && r.resetWeeklyErr != nil {
return r.resetWeeklyErr
}
if resetMonthly && r.resetMonthlyErr != nil {
return r.resetMonthlyErr
}
if r.sub == nil {
return nil
}
if resetDaily {
r.sub.DailyUsageUSD = 0
r.sub.DailyWindowStart = &dailyStart
}
if resetWeekly {
r.sub.WeeklyUsageUSD = 0
r.sub.WeeklyWindowStart = &periodicStart
}
if resetMonthly {
r.sub.MonthlyUsageUSD = 0
r.sub.MonthlyWindowStart = &periodicStart
}
return nil
}
func (r *resetQuotaUserSubRepoStub) ResetDailyUsage(_ context.Context, _ int64, _ *time.Time, windowStart time.Time) error {
r.resetDailyCalled = true
if r.resetDailyErr == nil && r.sub != nil {
r.sub.DailyUsageUSD = 0
r.sub.DailyWindowStart = &windowStart
}
return r.resetDailyErr
}
func (r *resetQuotaUserSubRepoStub) ResetWeeklyUsage(_ context.Context, _ int64, _ *time.Time, _ time.Time) error {
r.resetWeeklyCalled = true
return r.resetWeeklyErr
}
func (r *resetQuotaUserSubRepoStub) ResetMonthlyUsage(_ context.Context, _ int64, _ *time.Time, _ time.Time) error {
r.resetMonthlyCalled = true
return r.resetMonthlyErr
}
func newResetQuotaSvc(stub *resetQuotaUserSubRepoStub) *SubscriptionService {
return NewSubscriptionService(groupRepoNoop{}, stub, nil, nil, nil)
}
func TestAdminResetQuota_ResetBoth(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 1, UserID: 10, GroupID: 20},
}
svc := newResetQuotaSvc(stub)
resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC)
svc.now = func() time.Time { return resetAt }
result, err := svc.AdminResetQuota(context.Background(), 1, true, true, false)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
// 手动重置后日窗口锚定当天 0 点(保持 0 点刷新节奏),周窗口锚定重置时刻。
require.Equal(t, timezone.StartOfDay(resetAt), stub.dailyStart)
require.Equal(t, resetAt, stub.periodicStart)
require.Equal(t, timezone.StartOfDay(resetAt), *result.DailyWindowStart)
require.Equal(t, resetAt, *result.WeeklyWindowStart)
}
func TestAdminResetQuota_ResetDailyOnly(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 2, UserID: 10, GroupID: 20},
}
svc := newResetQuotaSvc(stub)
result, err := svc.AdminResetQuota(context.Background(), 2, true, false, false)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
require.False(t, stub.resetWeeklyCalled, "不应调用 ResetWeeklyUsage")
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
}
func TestAdminResetQuota_ResetWeeklyOnly(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 3, UserID: 10, GroupID: 20},
}
svc := newResetQuotaSvc(stub)
result, err := svc.AdminResetQuota(context.Background(), 3, false, true, false)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, stub.resetDailyCalled, "不应调用 ResetDailyUsage")
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
}
func TestAdminResetQuota_BothFalseReturnsError(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 7, UserID: 10, GroupID: 20},
}
svc := newResetQuotaSvc(stub)
_, err := svc.AdminResetQuota(context.Background(), 7, false, false, false)
require.ErrorIs(t, err, ErrInvalidInput)
require.False(t, stub.resetDailyCalled)
require.False(t, stub.resetWeeklyCalled)
require.False(t, stub.resetMonthlyCalled)
}
func TestAdminResetQuota_SubscriptionNotFound(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{sub: nil}
svc := newResetQuotaSvc(stub)
_, err := svc.AdminResetQuota(context.Background(), 999, true, true, true)
require.ErrorIs(t, err, ErrSubscriptionNotFound)
require.False(t, stub.resetDailyCalled)
require.False(t, stub.resetWeeklyCalled)
require.False(t, stub.resetMonthlyCalled)
}
func TestAdminResetQuota_ResetDailyUsageError(t *testing.T) {
dbErr := errors.New("db error")
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 4, UserID: 10, GroupID: 20},
resetDailyErr: dbErr,
}
svc := newResetQuotaSvc(stub)
_, err := svc.AdminResetQuota(context.Background(), 4, true, true, false)
require.ErrorIs(t, err, dbErr)
require.True(t, stub.resetDailyCalled)
require.True(t, stub.resetWeeklyCalled, "原子重置应在一次调用中提交所选窗口")
}
func TestAdminResetQuota_ResetWeeklyUsageError(t *testing.T) {
dbErr := errors.New("db error")
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 5, UserID: 10, GroupID: 20},
resetWeeklyErr: dbErr,
}
svc := newResetQuotaSvc(stub)
_, err := svc.AdminResetQuota(context.Background(), 5, false, true, false)
require.ErrorIs(t, err, dbErr)
require.True(t, stub.resetWeeklyCalled)
}
func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 8, UserID: 10, GroupID: 20},
}
svc := newResetQuotaSvc(stub)
result, err := svc.AdminResetQuota(context.Background(), 8, false, false, true)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, stub.resetDailyCalled, "不应调用 ResetDailyUsage")
require.False(t, stub.resetWeeklyCalled, "不应调用 ResetWeeklyUsage")
require.True(t, stub.resetMonthlyCalled, "应调用 ResetMonthlyUsage")
}
func TestAdminResetQuota_BeforeStartsAtSameDayPreservesAutomaticBoundary(t *testing.T) {
startsAt := time.Date(2026, 7, 1, 15, 0, 0, 0, time.UTC)
resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC)
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{
ID: 10,
UserID: 10,
GroupID: 20,
StartsAt: startsAt,
ExpiresAt: startsAt.Add(45 * 24 * time.Hour),
},
}
svc := newResetQuotaSvc(stub)
svc.now = func() time.Time { return resetAt }
result, err := svc.AdminResetQuota(context.Background(), 10, false, false, true)
require.NoError(t, err)
require.Equal(t, resetAt, *result.MonthlyWindowStart)
boundary, ok := result.automaticWindowStartAt(result.MonthlyWindowStart, 30*24*time.Hour, resetAt.Add(30*24*time.Hour))
require.True(t, ok)
require.Equal(t, resetAt.Add(30*24*time.Hour), boundary)
}
func TestAdminResetQuota_ResetMonthlyUsageError(t *testing.T) {
dbErr := errors.New("db error")
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{ID: 9, UserID: 10, GroupID: 20},
resetMonthlyErr: dbErr,
}
svc := newResetQuotaSvc(stub)
_, err := svc.AdminResetQuota(context.Background(), 9, false, false, true)
require.ErrorIs(t, err, dbErr)
require.True(t, stub.resetMonthlyCalled)
}
func TestAdminResetQuota_ReturnsRefreshedSub(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
sub: &UserSubscription{
ID: 6,
UserID: 10,
GroupID: 20,
DailyUsageUSD: 99.9,
},
}
svc := newResetQuotaSvc(stub)
result, err := svc.AdminResetQuota(context.Background(), 6, true, false, false)
require.NoError(t, err)
// ResetUsageWindows stub 会将 sub.DailyUsageUSD 归零,
// 服务应返回第二次 GetByID 的刷新值而非初始的 99.9
require.Equal(t, float64(0), result.DailyUsageUSD, "返回的订阅应反映已归零的用量")
require.True(t, stub.resetDailyCalled)
}