@@ -0,0 +1,298 @@
//go:build unit
package service
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// errSettingRepo: a SettingRepository that always returns errors on read
// ---------------------------------------------------------------------------
type errSettingRepo struct {
mockSettingRepo // embed the existing mock from backup_service_test.go
readErr error
}
func ( r * errSettingRepo ) GetValue ( _ context . Context , _ string ) ( string , error ) {
return "" , r . readErr
}
func ( r * errSettingRepo ) Get ( _ context . Context , _ string ) ( * Setting , error ) {
return nil , r . readErr
}
// ---------------------------------------------------------------------------
// overloadAccountRepoStub: records SetOverloaded calls
// ---------------------------------------------------------------------------
type overloadAccountRepoStub struct {
mockAccountRepoForGemini
overloadCalls int
lastOverloadID int64
lastOverloadEnd time . Time
}
func ( r * overloadAccountRepoStub ) SetOverloaded ( _ context . Context , id int64 , until time . Time ) error {
r . overloadCalls ++
r . lastOverloadID = id
r . lastOverloadEnd = until
return nil
}
// ===========================================================================
// SettingService: GetOverloadCooldownSettings
// ===========================================================================
func TestGetOverloadCooldownSettings_DefaultsWhenNotSet ( t * testing . T ) {
repo := newMockSettingRepo ( )
svc := NewSettingService ( repo , & config . Config { } )
settings , err := svc . GetOverloadCooldownSettings ( context . Background ( ) )
require . NoError ( t , err )
require . True ( t , settings . Enabled )
require . Equal ( t , 10 , settings . CooldownMinutes )
}
func TestGetOverloadCooldownSettings_ReadsFromDB ( t * testing . T ) {
repo := newMockSettingRepo ( )
data , _ := json . Marshal ( OverloadCooldownSettings { Enabled : false , CooldownMinutes : 30 } )
repo . data [ SettingKeyOverloadCooldownSettings ] = string ( data )
svc := NewSettingService ( repo , & config . Config { } )
settings , err := svc . GetOverloadCooldownSettings ( context . Background ( ) )
require . NoError ( t , err )
require . False ( t , settings . Enabled )
require . Equal ( t , 30 , settings . CooldownMinutes )
}
func TestGetOverloadCooldownSettings_ClampsMinValue ( t * testing . T ) {
repo := newMockSettingRepo ( )
data , _ := json . Marshal ( OverloadCooldownSettings { Enabled : true , CooldownMinutes : 0 } )
repo . data [ SettingKeyOverloadCooldownSettings ] = string ( data )
svc := NewSettingService ( repo , & config . Config { } )
settings , err := svc . GetOverloadCooldownSettings ( context . Background ( ) )
require . NoError ( t , err )
require . Equal ( t , 1 , settings . CooldownMinutes )
}
func TestGetOverloadCooldownSettings_ClampsMaxValue ( t * testing . T ) {
repo := newMockSettingRepo ( )
data , _ := json . Marshal ( OverloadCooldownSettings { Enabled : true , CooldownMinutes : 999 } )
repo . data [ SettingKeyOverloadCooldownSettings ] = string ( data )
svc := NewSettingService ( repo , & config . Config { } )
settings , err := svc . GetOverloadCooldownSettings ( context . Background ( ) )
require . NoError ( t , err )
require . Equal ( t , 120 , settings . CooldownMinutes )
}
func TestGetOverloadCooldownSettings_InvalidJSON_ReturnsDefaults ( t * testing . T ) {
repo := newMockSettingRepo ( )
repo . data [ SettingKeyOverloadCooldownSettings ] = "not-json"
svc := NewSettingService ( repo , & config . Config { } )
settings , err := svc . GetOverloadCooldownSettings ( context . Background ( ) )
require . NoError ( t , err )
require . True ( t , settings . Enabled )
require . Equal ( t , 10 , settings . CooldownMinutes )
}
func TestGetOverloadCooldownSettings_EmptyValue_ReturnsDefaults ( t * testing . T ) {
repo := newMockSettingRepo ( )
repo . data [ SettingKeyOverloadCooldownSettings ] = ""
svc := NewSettingService ( repo , & config . Config { } )
settings , err := svc . GetOverloadCooldownSettings ( context . Background ( ) )
require . NoError ( t , err )
require . True ( t , settings . Enabled )
require . Equal ( t , 10 , settings . CooldownMinutes )
}
// ===========================================================================
// SettingService: SetOverloadCooldownSettings
// ===========================================================================
func TestSetOverloadCooldownSettings_Success ( t * testing . T ) {
repo := newMockSettingRepo ( )
svc := NewSettingService ( repo , & config . Config { } )
err := svc . SetOverloadCooldownSettings ( context . Background ( ) , & OverloadCooldownSettings {
Enabled : false ,
CooldownMinutes : 25 ,
} )
require . NoError ( t , err )
// Verify round-trip
settings , err := svc . GetOverloadCooldownSettings ( context . Background ( ) )
require . NoError ( t , err )
require . False ( t , settings . Enabled )
require . Equal ( t , 25 , settings . CooldownMinutes )
}
func TestSetOverloadCooldownSettings_RejectsNil ( t * testing . T ) {
svc := NewSettingService ( newMockSettingRepo ( ) , & config . Config { } )
err := svc . SetOverloadCooldownSettings ( context . Background ( ) , nil )
require . Error ( t , err )
}
func TestSetOverloadCooldownSettings_EnabledRejectsOutOfRange ( t * testing . T ) {
svc := NewSettingService ( newMockSettingRepo ( ) , & config . Config { } )
for _ , minutes := range [ ] int { 0 , - 1 , 121 , 999 } {
err := svc . SetOverloadCooldownSettings ( context . Background ( ) , & OverloadCooldownSettings {
Enabled : true , CooldownMinutes : minutes ,
} )
require . Error ( t , err , "should reject enabled=true + cooldown_minutes=%d" , minutes )
require . Contains ( t , err . Error ( ) , "cooldown_minutes must be between 1-120" )
}
}
func TestSetOverloadCooldownSettings_DisabledNormalizesOutOfRange ( t * testing . T ) {
repo := newMockSettingRepo ( )
svc := NewSettingService ( repo , & config . Config { } )
// enabled=false + cooldown_minutes=0 应该保存成功, 值被归一化为10
err := svc . SetOverloadCooldownSettings ( context . Background ( ) , & OverloadCooldownSettings {
Enabled : false , CooldownMinutes : 0 ,
} )
require . NoError ( t , err , "disabled with invalid minutes should NOT be rejected" )
// 验证持久化后读回来的值
settings , err := svc . GetOverloadCooldownSettings ( context . Background ( ) )
require . NoError ( t , err )
require . False ( t , settings . Enabled )
require . Equal ( t , 10 , settings . CooldownMinutes , "should be normalized to default" )
}
func TestSetOverloadCooldownSettings_AcceptsBoundaries ( t * testing . T ) {
svc := NewSettingService ( newMockSettingRepo ( ) , & config . Config { } )
for _ , minutes := range [ ] int { 1 , 60 , 120 } {
err := svc . SetOverloadCooldownSettings ( context . Background ( ) , & OverloadCooldownSettings {
Enabled : true , CooldownMinutes : minutes ,
} )
require . NoError ( t , err , "should accept cooldown_minutes=%d" , minutes )
}
}
// ===========================================================================
// RateLimitService: handle529 behaviour
// ===========================================================================
func TestHandle529_EnabledFromDB_PausesAccount ( t * testing . T ) {
accountRepo := & overloadAccountRepoStub { }
settingRepo := newMockSettingRepo ( )
data , _ := json . Marshal ( OverloadCooldownSettings { Enabled : true , CooldownMinutes : 15 } )
settingRepo . data [ SettingKeyOverloadCooldownSettings ] = string ( data )
settingSvc := NewSettingService ( settingRepo , & config . Config { } )
svc := NewRateLimitService ( accountRepo , nil , & config . Config { } , nil , nil )
svc . SetSettingService ( settingSvc )
account := & Account { ID : 42 , Platform : PlatformAnthropic , Type : AccountTypeOAuth }
before := time . Now ( )
svc . handle529 ( context . Background ( ) , account )
require . Equal ( t , 1 , accountRepo . overloadCalls )
require . Equal ( t , int64 ( 42 ) , accountRepo . lastOverloadID )
require . WithinDuration ( t , before . Add ( 15 * time . Minute ) , accountRepo . lastOverloadEnd , 2 * time . Second )
}
func TestHandle529_DisabledFromDB_SkipsAccount ( t * testing . T ) {
accountRepo := & overloadAccountRepoStub { }
settingRepo := newMockSettingRepo ( )
data , _ := json . Marshal ( OverloadCooldownSettings { Enabled : false , CooldownMinutes : 15 } )
settingRepo . data [ SettingKeyOverloadCooldownSettings ] = string ( data )
settingSvc := NewSettingService ( settingRepo , & config . Config { } )
svc := NewRateLimitService ( accountRepo , nil , & config . Config { } , nil , nil )
svc . SetSettingService ( settingSvc )
account := & Account { ID : 42 , Platform : PlatformAnthropic , Type : AccountTypeOAuth }
svc . handle529 ( context . Background ( ) , account )
require . Equal ( t , 0 , accountRepo . overloadCalls , "should NOT pause when disabled" )
}
func TestHandle529_NilSettingService_FallsBackToConfig ( t * testing . T ) {
accountRepo := & overloadAccountRepoStub { }
cfg := & config . Config { }
cfg . RateLimit . OverloadCooldownMinutes = 20
svc := NewRateLimitService ( accountRepo , nil , cfg , nil , nil )
// NOT calling SetSettingService — remains nil
account := & Account { ID : 77 , Platform : PlatformAnthropic , Type : AccountTypeOAuth }
before := time . Now ( )
svc . handle529 ( context . Background ( ) , account )
require . Equal ( t , 1 , accountRepo . overloadCalls )
require . WithinDuration ( t , before . Add ( 20 * time . Minute ) , accountRepo . lastOverloadEnd , 2 * time . Second )
}
func TestHandle529_NilSettingService_ZeroConfig_DefaultsTen ( t * testing . T ) {
accountRepo := & overloadAccountRepoStub { }
svc := NewRateLimitService ( accountRepo , nil , & config . Config { } , nil , nil )
account := & Account { ID : 88 , Platform : PlatformAnthropic , Type : AccountTypeOAuth }
before := time . Now ( )
svc . handle529 ( context . Background ( ) , account )
require . Equal ( t , 1 , accountRepo . overloadCalls )
require . WithinDuration ( t , before . Add ( 10 * time . Minute ) , accountRepo . lastOverloadEnd , 2 * time . Second )
}
func TestHandle529_DBReadError_FallsBackToConfig ( t * testing . T ) {
accountRepo := & overloadAccountRepoStub { }
errRepo := & errSettingRepo { readErr : context . DeadlineExceeded }
errRepo . data = make ( map [ string ] string )
cfg := & config . Config { }
cfg . RateLimit . OverloadCooldownMinutes = 7
settingSvc := NewSettingService ( errRepo , cfg )
svc := NewRateLimitService ( accountRepo , nil , cfg , nil , nil )
svc . SetSettingService ( settingSvc )
account := & Account { ID : 99 , Platform : PlatformAnthropic , Type : AccountTypeOAuth }
before := time . Now ( )
svc . handle529 ( context . Background ( ) , account )
require . Equal ( t , 1 , accountRepo . overloadCalls )
require . WithinDuration ( t , before . Add ( 7 * time . Minute ) , accountRepo . lastOverloadEnd , 2 * time . Second )
}
// ===========================================================================
// Model: defaults & JSON round-trip
// ===========================================================================
func TestDefaultOverloadCooldownSettings ( t * testing . T ) {
d := DefaultOverloadCooldownSettings ( )
require . True ( t , d . Enabled )
require . Equal ( t , 10 , d . CooldownMinutes )
}
func TestOverloadCooldownSettings_JSONRoundTrip ( t * testing . T ) {
original := OverloadCooldownSettings { Enabled : false , CooldownMinutes : 42 }
data , err := json . Marshal ( original )
require . NoError ( t , err )
var decoded OverloadCooldownSettings
require . NoError ( t , json . Unmarshal ( data , & decoded ) )
require . Equal ( t , original , decoded )
// Verify JSON uses snake_case field names
var raw map [ string ] any
require . NoError ( t , json . Unmarshal ( data , & raw ) )
_ , hasEnabled := raw [ "enabled" ]
_ , hasCooldown := raw [ "cooldown_minutes" ]
require . True ( t , hasEnabled , "JSON must use 'enabled'" )
require . True ( t , hasCooldown , "JSON must use 'cooldown_minutes'" )
}