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
211 lines
8.7 KiB
Go
211 lines
8.7 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestSparkRoutingByModel(t *testing.T) {
|
||
ctx := context.Background()
|
||
sparkModel := "gpt-5.3-codex-spark"
|
||
normalModel := "gpt-5.3-codex"
|
||
sparkCreds := map[string]any{"model_mapping": defaultSparkShadowModelMapping()}
|
||
|
||
newScheduler := func(snapshot map[int64]*Account) *defaultOpenAIAccountScheduler {
|
||
return &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{
|
||
schedulerSnapshot: &SchedulerSnapshotService{
|
||
cache: &openAISnapshotCacheStub{accountsByID: snapshot},
|
||
},
|
||
cfg: &config.Config{},
|
||
}}
|
||
}
|
||
sparkReq := OpenAIAccountScheduleRequest{RequestedModel: sparkModel, Platform: PlatformOpenAI}
|
||
normalReq := OpenAIAccountScheduleRequest{RequestedModel: normalModel, Platform: PlatformOpenAI}
|
||
|
||
t.Run("normal_account_with_spark_mapping_accepts_spark", func(t *testing.T) {
|
||
acc := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Credentials: sparkCreds}
|
||
require.True(t, newScheduler(nil).isAccountRequestCompatible(ctx, acc, sparkReq),
|
||
"普通账号配了 spark → 可承接 spark(类型门已移除)")
|
||
})
|
||
|
||
t.Run("normal_account_without_spark_rejects_spark", func(t *testing.T) {
|
||
acc := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true,
|
||
Credentials: map[string]any{"model_mapping": map[string]any{normalModel: normalModel}}}
|
||
require.False(t, newScheduler(nil).isAccountRequestCompatible(ctx, acc, sparkReq),
|
||
"普通账号未配 spark → 拒 spark(按配置而非类型)")
|
||
})
|
||
|
||
t.Run("shadow_with_spark_mapping_accepts_spark_rejects_non_spark", func(t *testing.T) {
|
||
pid := int64(100)
|
||
parent := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true}
|
||
shadow := &Account{ID: 200, ParentAccountID: &pid, QuotaDimension: QuotaDimensionSpark,
|
||
Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: sparkCreds}
|
||
s := newScheduler(map[int64]*Account{100: parent})
|
||
require.True(t, s.isAccountRequestCompatible(ctx, shadow, sparkReq), "影子配 spark + 健康母 → 接 spark")
|
||
require.False(t, s.isAccountRequestCompatible(ctx, shadow, normalReq), "影子(仅 spark mapping)→ 拒非 spark")
|
||
})
|
||
|
||
t.Run("empty_model_shadow_is_eligible_under_a2", func(t *testing.T) {
|
||
// 有意的纯 A2 行为(用户裁决 2026-06-30):空 model 请求不经模型门过滤
|
||
// (isAccountRequestCompatible 的 `req.RequestedModel != ""` 短路),故影子与普通账号
|
||
// 一样成为候选。旧类型门曾在空 model 时排除影子(opt-in),该 opt-in 已随类型门移除——
|
||
// routing 路径不再有任何类型判断。此测试锁定该决策,防被未来改动静默改回。
|
||
pid := int64(100)
|
||
parent := &Account{ID: 100, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true}
|
||
shadow := &Account{ID: 200, ParentAccountID: &pid, QuotaDimension: QuotaDimensionSpark,
|
||
Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Concurrency: 1, Credentials: sparkCreds}
|
||
emptyReq := OpenAIAccountScheduleRequest{RequestedModel: "", Platform: PlatformOpenAI}
|
||
s := newScheduler(map[int64]*Account{100: parent})
|
||
require.True(t, s.isAccountRequestCompatible(ctx, shadow, emptyReq),
|
||
"空 model 时影子可被选中(有意的纯 A2 行为:类型门移除后无 opt-in 排除)")
|
||
})
|
||
}
|
||
|
||
// TestParentHealthSchedulerIntegration 通过 isAccountRequestCompatible 验证「母账号不可调度时影子被
|
||
// 调度器拒绝」这一联动在调度器层面端到端生效。
|
||
//
|
||
// 使用的接缝:defaultOpenAIAccountScheduler.isAccountRequestCompatible,它通过
|
||
// s.service.schedulerSnapshot.GetAccount(ctx, parentID) 解析母账号;
|
||
// openAISnapshotCacheStub.accountsByID 提供对应的测试桩。
|
||
func TestParentHealthSchedulerIntegration(t *testing.T) {
|
||
ctx := context.Background()
|
||
pid := int64(78100)
|
||
sparkModel := "gpt-5.3-codex-spark"
|
||
|
||
shadow := &Account{
|
||
ID: 78200,
|
||
ParentAccountID: &pid,
|
||
QuotaDimension: QuotaDimensionSpark,
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
Concurrency: 1,
|
||
}
|
||
|
||
req := OpenAIAccountScheduleRequest{
|
||
RequestedModel: sparkModel,
|
||
Platform: PlatformOpenAI,
|
||
}
|
||
|
||
makeScheduler := func(parent *Account) *defaultOpenAIAccountScheduler {
|
||
snapshotCache := &openAISnapshotCacheStub{
|
||
accountsByID: map[int64]*Account{parent.ID: parent},
|
||
}
|
||
snapshotSvc := &SchedulerSnapshotService{cache: snapshotCache}
|
||
svc := &OpenAIGatewayService{
|
||
schedulerSnapshot: snapshotSvc,
|
||
cfg: &config.Config{},
|
||
}
|
||
return &defaultOpenAIAccountScheduler{service: svc}
|
||
}
|
||
|
||
t.Run("unhealthy_parent_status_error_rejects_shadow", func(t *testing.T) {
|
||
unhealthyParent := &Account{
|
||
ID: 78100,
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusError, // IsActive()==false → IsSchedulable()==false
|
||
Schedulable: true,
|
||
}
|
||
require.False(t, unhealthyParent.IsSchedulable(), "前提:Status=error 的母账号不可调度")
|
||
scheduler := makeScheduler(unhealthyParent)
|
||
require.False(t, scheduler.isAccountRequestCompatible(ctx, shadow, req),
|
||
"母账号不可调度时,影子账号必须被调度器拒绝")
|
||
})
|
||
|
||
t.Run("manual_schedulable_false_parent_does_not_reject_shadow", func(t *testing.T) {
|
||
// F1 决策 A:母账号手动暂停(Schedulable=false)不传播到影子 —— 凭据仍可用,影子应被接受。
|
||
manualPausedParent := &Account{
|
||
ID: 78100,
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: false, // 显式手动暂停
|
||
}
|
||
require.False(t, manualPausedParent.IsSchedulable(), "前提:手动暂停的母账号自身不可调度")
|
||
scheduler := makeScheduler(manualPausedParent)
|
||
require.True(t, scheduler.isAccountRequestCompatible(ctx, shadow, req),
|
||
"母账号手动暂停不应连坐影子(凭据仍可用)")
|
||
})
|
||
|
||
t.Run("global_rate_limited_parent_does_not_reject_shadow", func(t *testing.T) {
|
||
// F1 核心修复:母账号 global 429(RateLimitResetAt)不连坐 spark 影子。
|
||
resetAt := time.Now().Add(1 * time.Hour)
|
||
rateLimitedParent := &Account{
|
||
ID: 78100,
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
RateLimitResetAt: &resetAt,
|
||
}
|
||
require.False(t, rateLimitedParent.IsSchedulable(), "前提:global 限流母账号自身不可调度")
|
||
scheduler := makeScheduler(rateLimitedParent)
|
||
require.True(t, scheduler.isAccountRequestCompatible(ctx, shadow, req),
|
||
"母账号 global 限流不应连坐 spark 影子")
|
||
})
|
||
|
||
t.Run("healthy_parent_accepts_shadow_control", func(t *testing.T) {
|
||
healthyParent := &Account{
|
||
ID: 78100,
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
}
|
||
require.True(t, healthyParent.IsSchedulable(), "前提:健康母账号必须可调度")
|
||
scheduler := makeScheduler(healthyParent)
|
||
require.True(t, scheduler.isAccountRequestCompatible(ctx, shadow, req),
|
||
"健康母账号时,影子账号必须被调度器接受(对照组)")
|
||
})
|
||
}
|
||
|
||
func TestParentHealthSchedulerFallsBackToRepoWhenSnapshotMissesParent(t *testing.T) {
|
||
ctx := context.Background()
|
||
parentID := int64(79100)
|
||
parent := Account{
|
||
ID: parentID,
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
}
|
||
shadow := &Account{
|
||
ID: 79200,
|
||
ParentAccountID: &parentID,
|
||
QuotaDimension: QuotaDimensionSpark,
|
||
Platform: PlatformOpenAI,
|
||
Type: AccountTypeOAuth,
|
||
Status: StatusActive,
|
||
Schedulable: true,
|
||
Concurrency: 1,
|
||
}
|
||
|
||
repo := schedulerTestOpenAIAccountRepo{accounts: []Account{parent}}
|
||
scheduler := &defaultOpenAIAccountScheduler{service: &OpenAIGatewayService{
|
||
accountRepo: repo,
|
||
schedulerSnapshot: &SchedulerSnapshotService{
|
||
cache: &openAISnapshotCacheStub{},
|
||
accountRepo: repo,
|
||
cfg: &config.Config{
|
||
Gateway: config.GatewayConfig{
|
||
Scheduling: config.GatewaySchedulingConfig{
|
||
DbFallbackEnabled: false,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
cfg: &config.Config{},
|
||
}}
|
||
|
||
require.True(t, scheduler.isAccountRequestCompatible(ctx, shadow, OpenAIAccountScheduleRequest{
|
||
RequestedModel: "gpt-5.3-codex-spark",
|
||
Platform: PlatformOpenAI,
|
||
}), "快照缺失母账号且调度快照 DB fallback 关闭时,应回退 repo 解析健康母账号")
|
||
}
|