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
625 lines
23 KiB
Go
625 lines
23 KiB
Go
//go:build unit
|
||
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// --- repo / fetcher 装配 ---
|
||
|
||
// quotaModeRepoStub 记录 RunCheck 落库行为(历史行 + MarkChecked)。
|
||
type quotaModeRepoStub struct {
|
||
ChannelMonitorRepository
|
||
monitor *ChannelMonitor
|
||
history []*ChannelMonitorHistoryRow
|
||
markedIDs []int64
|
||
updated []*ChannelMonitor
|
||
}
|
||
|
||
func (r *quotaModeRepoStub) GetByID(_ context.Context, id int64) (*ChannelMonitor, error) {
|
||
if r.monitor == nil || r.monitor.ID != id {
|
||
return nil, ErrChannelMonitorNotFound
|
||
}
|
||
clone := *r.monitor
|
||
return &clone, nil
|
||
}
|
||
|
||
func (r *quotaModeRepoStub) InsertHistoryBatch(_ context.Context, rows []*ChannelMonitorHistoryRow) error {
|
||
r.history = append(r.history, rows...)
|
||
return nil
|
||
}
|
||
|
||
func (r *quotaModeRepoStub) MarkChecked(_ context.Context, id int64, _ time.Time) error {
|
||
r.markedIDs = append(r.markedIDs, id)
|
||
return nil
|
||
}
|
||
|
||
func (r *quotaModeRepoStub) Update(_ context.Context, m *ChannelMonitor) error {
|
||
clone := *m
|
||
r.updated = append(r.updated, &clone)
|
||
return nil
|
||
}
|
||
|
||
// newQuotaModeService 构造启用 V1 探活的 service(复用 retirement/duplicate 测试的 stub)。
|
||
func newQuotaModeService(repo *quotaModeRepoStub) *ChannelMonitorService {
|
||
svc := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{})
|
||
svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{
|
||
Enabled: true,
|
||
Mode: ChannelMonitorModeV1,
|
||
}})
|
||
return svc
|
||
}
|
||
|
||
func newQuotaModeFetcher(accounts map[int64]*Account, usage *stubMonitorUsageSource) *ChannelMonitorQuotaFetcher {
|
||
if accounts == nil {
|
||
accounts = make(map[int64]*Account)
|
||
}
|
||
if usage == nil {
|
||
usage = &stubMonitorUsageSource{}
|
||
}
|
||
return &ChannelMonitorQuotaFetcher{
|
||
usage: usage,
|
||
accounts: &stubMonitorAccountSource{accounts: accounts},
|
||
cache: make(map[int64]monitorQuotaCacheEntry),
|
||
}
|
||
}
|
||
|
||
// --- RunCheck 分派 ---
|
||
|
||
func TestRunCheck_QuotaModeProducesSingleQuotaResult(t *testing.T) {
|
||
repo := "aModeRepoStub{monitor: &ChannelMonitor{
|
||
ID: 1,
|
||
Name: "kimi-quota",
|
||
Provider: MonitorProviderKimi,
|
||
APIMode: MonitorAPIModeChatCompletions,
|
||
PrimaryModel: "quota",
|
||
Enabled: true,
|
||
IntervalSeconds: 60,
|
||
CheckMode: MonitorCheckModeQuota,
|
||
AccountID: int64Ptr(9),
|
||
}}
|
||
svc := newQuotaModeService(repo)
|
||
fetcher := newQuotaModeFetcher(map[int64]*Account{
|
||
9: {ID: 9, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}},
|
||
}, nil)
|
||
fetcher.cnQuota = &stubMonitorCNQuotaSource{result: &CNProviderQuotaProbeResult{
|
||
Success: true,
|
||
CredentialValid: true,
|
||
Tiers: []CNQuotaTier{{Window: "5h", UsedPercent: 30}},
|
||
}}
|
||
svc.SetQuotaFetcher(fetcher)
|
||
|
||
results, err := svc.RunCheck(context.Background(), 1)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 1)
|
||
|
||
res := results[0]
|
||
require.Equal(t, "quota", res.Model)
|
||
require.Equal(t, MonitorStatusOperational, res.Status)
|
||
require.Nil(t, res.LatencyMs)
|
||
require.Nil(t, res.PingLatencyMs)
|
||
require.NotNil(t, res.Quota)
|
||
require.True(t, res.Quota.Success)
|
||
require.Equal(t, "cn_quota", res.Quota.Source)
|
||
|
||
// 历史行携带配额快照,并推进 last_checked_at。
|
||
require.Len(t, repo.history, 1)
|
||
require.Equal(t, "quota", repo.history[0].Model)
|
||
require.NotNil(t, repo.history[0].Quota)
|
||
require.Equal(t, []int64{1}, repo.markedIDs)
|
||
}
|
||
|
||
func TestRunCheck_QuotaModeUnlinkedAccountDegrades(t *testing.T) {
|
||
repo := "aModeRepoStub{monitor: &ChannelMonitor{
|
||
ID: 2,
|
||
Provider: MonitorProviderDeepseek,
|
||
APIMode: MonitorAPIModeChatCompletions,
|
||
Endpoint: "",
|
||
PrimaryModel: "quota",
|
||
Enabled: true,
|
||
IntervalSeconds: 60,
|
||
CheckMode: MonitorCheckModeQuota,
|
||
AccountID: nil, // FK ON DELETE SET NULL 后的形态
|
||
}}
|
||
svc := newQuotaModeService(repo)
|
||
svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil))
|
||
|
||
results, err := svc.RunCheck(context.Background(), 2)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 1)
|
||
require.Equal(t, MonitorStatusDegraded, results[0].Status)
|
||
require.Contains(t, results[0].Message, "linked account not found")
|
||
require.False(t, results[0].Quota.Success)
|
||
}
|
||
|
||
func TestRunCheck_QuotaModeNilFetcherFailsClosed(t *testing.T) {
|
||
repo := "aModeRepoStub{monitor: &ChannelMonitor{
|
||
ID: 3,
|
||
Provider: MonitorProviderZhipu,
|
||
APIMode: MonitorAPIModeChatCompletions,
|
||
PrimaryModel: "quota",
|
||
Enabled: true,
|
||
IntervalSeconds: 60,
|
||
CheckMode: MonitorCheckModeQuota,
|
||
AccountID: int64Ptr(5),
|
||
}}
|
||
svc := newQuotaModeService(repo) // 不注入 fetcher
|
||
|
||
results, err := svc.RunCheck(context.Background(), 3)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 1)
|
||
require.Equal(t, MonitorStatusError, results[0].Status)
|
||
require.Contains(t, results[0].Message, "not configured")
|
||
}
|
||
|
||
func TestRunCheck_QuotaProbeAttachesSnapshotToPrimaryRowOnly(t *testing.T) {
|
||
h := &openAICaptureHandler{}
|
||
endpoint := setupFakeOpenAI(t, h)
|
||
repo := "aModeRepoStub{monitor: &ChannelMonitor{
|
||
ID: 4,
|
||
Provider: MonitorProviderOpenAI,
|
||
APIMode: MonitorAPIModeChatCompletions,
|
||
Endpoint: endpoint,
|
||
APIKey: "OLD:sk-openai",
|
||
PrimaryModel: "gpt-test",
|
||
ExtraModels: []string{"gpt-extra"},
|
||
Enabled: true,
|
||
IntervalSeconds: 60,
|
||
CheckMode: MonitorCheckModeQuotaProbe,
|
||
AccountID: int64Ptr(12),
|
||
}}
|
||
svc := newQuotaModeService(repo)
|
||
usage := &stubMonitorUsageSource{usage: &UsageInfo{
|
||
FiveHour: &UsageProgress{Utilization: 20},
|
||
}}
|
||
svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
|
||
12: {ID: 12, Platform: domain.PlatformOpenAI},
|
||
}, usage))
|
||
|
||
results, err := svc.RunCheck(context.Background(), 4)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 2)
|
||
|
||
// 探活状态为准,配额只挂主模型行。
|
||
require.Equal(t, MonitorStatusOperational, results[0].Status)
|
||
require.NotNil(t, results[0].Quota)
|
||
require.True(t, results[0].Quota.Success)
|
||
require.Equal(t, "usage", results[0].Quota.Source)
|
||
require.Nil(t, results[1].Quota, "extra model rows must not carry quota")
|
||
|
||
// 历史落库时同样只有主模型行带快照。
|
||
require.Len(t, repo.history, 2)
|
||
require.NotNil(t, repo.history[0].Quota)
|
||
require.Equal(t, "gpt-test", repo.history[0].Model)
|
||
require.Nil(t, repo.history[1].Quota)
|
||
}
|
||
|
||
func TestRunCheck_QuotaProbeQuotaFailureKeepsProbeStatus(t *testing.T) {
|
||
h := &openAICaptureHandler{}
|
||
endpoint := setupFakeOpenAI(t, h)
|
||
repo := "aModeRepoStub{monitor: &ChannelMonitor{
|
||
ID: 5,
|
||
Provider: MonitorProviderOpenAI,
|
||
APIMode: MonitorAPIModeChatCompletions,
|
||
Endpoint: endpoint,
|
||
APIKey: "OLD:sk-openai",
|
||
PrimaryModel: "gpt-test",
|
||
Enabled: true,
|
||
IntervalSeconds: 60,
|
||
CheckMode: MonitorCheckModeQuotaProbe,
|
||
AccountID: nil, // 配额侧失效
|
||
}}
|
||
svc := newQuotaModeService(repo)
|
||
svc.SetQuotaFetcher(newQuotaModeFetcher(nil, nil))
|
||
|
||
results, err := svc.RunCheck(context.Background(), 5)
|
||
require.NoError(t, err)
|
||
require.Len(t, results, 1)
|
||
require.Equal(t, MonitorStatusOperational, results[0].Status, "quota failure must not flip probe status")
|
||
require.False(t, results[0].Quota.Success)
|
||
}
|
||
|
||
// --- attachQuotaSnapshot 细节 ---
|
||
|
||
func TestAttachQuotaSnapshot_NoteOnlyWhenProbeMessageEmpty(t *testing.T) {
|
||
results := []*CheckResult{
|
||
{Model: "primary", Status: MonitorStatusOperational, Message: "challenge passed"},
|
||
{Model: "extra"},
|
||
}
|
||
failed := &domain.MonitorQuotaSnapshot{Success: false, Error: "boom"}
|
||
|
||
attachQuotaSnapshot(results, failed)
|
||
|
||
require.Equal(t, "challenge passed", results[0].Message, "existing probe message wins")
|
||
require.Equal(t, failed, results[0].Quota)
|
||
require.Nil(t, results[1].Quota)
|
||
|
||
quiet := []*CheckResult{{Model: "primary", Status: MonitorStatusOperational}}
|
||
attachQuotaSnapshot(quiet, failed)
|
||
require.Contains(t, quiet[0].Message, "quota fetch failed: boom")
|
||
|
||
attachQuotaSnapshot(nil, failed) // 空结果不 panic
|
||
attachQuotaSnapshot(results, nil) // 空快照不动结果
|
||
}
|
||
|
||
// --- 校验矩阵 ---
|
||
|
||
func TestValidateCreateParams_CheckModeMatrix(t *testing.T) {
|
||
accountID := int64(9)
|
||
|
||
cases := []struct {
|
||
name string
|
||
params ChannelMonitorCreateParams
|
||
wantErr error
|
||
}{
|
||
{
|
||
name: "probe requires endpoint",
|
||
params: ChannelMonitorCreateParams{
|
||
Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe,
|
||
APIKey: "sk", IntervalSeconds: 60, PrimaryModel: "gpt-5",
|
||
},
|
||
wantErr: ErrChannelMonitorInvalidEndpoint,
|
||
},
|
||
{
|
||
name: "probe requires api key",
|
||
params: ChannelMonitorCreateParams{
|
||
Provider: MonitorProviderOpenAI, CheckMode: MonitorCheckModeProbe,
|
||
Endpoint: "https://api.openai.com", IntervalSeconds: 60, PrimaryModel: "gpt-5",
|
||
},
|
||
wantErr: ErrChannelMonitorMissingAPIKey,
|
||
},
|
||
{
|
||
name: "quota drops endpoint and api key requirements",
|
||
params: ChannelMonitorCreateParams{
|
||
Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuota,
|
||
IntervalSeconds: 60, AccountID: &accountID,
|
||
},
|
||
wantErr: nil, // primary_model 默认 "quota"
|
||
},
|
||
{
|
||
name: "quota requires account",
|
||
params: ChannelMonitorCreateParams{
|
||
Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota,
|
||
IntervalSeconds: 60, PrimaryModel: "quota",
|
||
},
|
||
wantErr: ErrChannelMonitorAccountRequired,
|
||
},
|
||
{
|
||
name: "quota_probe requires endpoint and api key too",
|
||
params: ChannelMonitorCreateParams{
|
||
Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe,
|
||
IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "kimi-k2",
|
||
},
|
||
wantErr: ErrChannelMonitorInvalidEndpoint,
|
||
},
|
||
{
|
||
name: "antigravity probe unsupported",
|
||
params: ChannelMonitorCreateParams{
|
||
Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeProbe,
|
||
Endpoint: "https://example.com", APIKey: "k",
|
||
IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro",
|
||
},
|
||
wantErr: ErrChannelMonitorInvalidCheckMode,
|
||
},
|
||
{
|
||
name: "antigravity quota_probe unsupported",
|
||
params: ChannelMonitorCreateParams{
|
||
Provider: MonitorProviderAntigravity, CheckMode: MonitorCheckModeQuotaProbe,
|
||
Endpoint: "https://example.com", APIKey: "k",
|
||
IntervalSeconds: 60, AccountID: &accountID, PrimaryModel: "gemini-3-pro",
|
||
},
|
||
wantErr: ErrChannelMonitorInvalidCheckMode,
|
||
},
|
||
{
|
||
name: "unknown mode rejected",
|
||
params: ChannelMonitorCreateParams{
|
||
Provider: MonitorProviderOpenAI, CheckMode: "auto",
|
||
Endpoint: "https://api.openai.com", APIKey: "sk",
|
||
IntervalSeconds: 60, PrimaryModel: "gpt-5",
|
||
},
|
||
wantErr: ErrChannelMonitorInvalidCheckMode,
|
||
},
|
||
{
|
||
// quota_probe 仍要打真实探活请求:空模型必须报错,不再用 "quota" 占位。
|
||
name: "quota_probe requires primary model",
|
||
params: ChannelMonitorCreateParams{
|
||
Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuotaProbe,
|
||
Endpoint: "https://api.kimi.com", APIKey: "sk",
|
||
IntervalSeconds: 60, AccountID: &accountID,
|
||
},
|
||
wantErr: ErrChannelMonitorMissingPrimaryModel,
|
||
},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
err := validateCreateParams(tc.params)
|
||
if tc.wantErr == nil {
|
||
require.NoError(t, err)
|
||
} else {
|
||
require.ErrorIs(t, err, tc.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestNormalizeMonitorPrimaryModel_QuotaDefault(t *testing.T) {
|
||
require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuota, ""))
|
||
require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderAntigravity, MonitorCheckModeQuota, " "))
|
||
// quota_probe 仍要打真实探活请求:空模型返回 ""(由上层报 MissingPrimaryModel),
|
||
// 不再用 "quota" 占位打 model="quota" 的请求。
|
||
require.Equal(t, "", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, ""))
|
||
// grok 分支在纯 quota 占位之后:grok+quota 占位 "quota",
|
||
// grok 探活(probe/quota_probe)默认轻量测活模型。
|
||
require.Equal(t, "quota", normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuota, ""))
|
||
require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeProbe, ""))
|
||
require.Equal(t, MonitorDefaultGrokModel, normalizeMonitorPrimaryModel(MonitorProviderGrok, MonitorCheckModeQuotaProbe, ""))
|
||
// 探活模式沿用原语义:其余必填(空串报错在 validateCreateParams)。
|
||
require.Equal(t, "kimi-k2", normalizeMonitorPrimaryModel(MonitorProviderKimi, MonitorCheckModeQuotaProbe, "kimi-k2"))
|
||
}
|
||
|
||
func TestProviderProbeCapabilityMatrix(t *testing.T) {
|
||
require.False(t, providerSupportsProbe(MonitorProviderAntigravity))
|
||
for _, p := range []string{
|
||
MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini,
|
||
MonitorProviderGrok, MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek,
|
||
} {
|
||
require.True(t, providerSupportsProbe(p), p)
|
||
}
|
||
for _, p := range []string{
|
||
MonitorProviderOpenAI, MonitorProviderAnthropic, MonitorProviderGemini,
|
||
MonitorProviderGrok, MonitorProviderAntigravity,
|
||
MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek,
|
||
} {
|
||
require.NoError(t, validateProvider(p), p)
|
||
}
|
||
}
|
||
|
||
// --- 关联账号校验 ---
|
||
|
||
func TestValidateLinkedAccount_Matrix(t *testing.T) {
|
||
svc := NewChannelMonitorService(nil, nil)
|
||
fetcher := newQuotaModeFetcher(map[int64]*Account{
|
||
1: {ID: 1, Platform: domain.PlatformKimi},
|
||
}, nil)
|
||
svc.SetQuotaFetcher(fetcher)
|
||
|
||
require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, nil))
|
||
require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(0)))
|
||
require.NoError(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1)))
|
||
require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderZhipu, int64Ptr(1)), ErrChannelMonitorProviderIncompatible)
|
||
require.ErrorIs(t, svc.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(404)), ErrChannelMonitorAccountRequired)
|
||
|
||
noFetcher := NewChannelMonitorService(nil, nil)
|
||
require.ErrorIs(t, noFetcher.validateLinkedAccount(context.Background(), MonitorProviderKimi, int64Ptr(1)), ErrChannelMonitorAccountRequired)
|
||
}
|
||
|
||
func TestRevalidateLinkedAccount_QuotaErrorsProbeUnbinds(t *testing.T) {
|
||
fetcher := newQuotaModeFetcher(nil, nil) // 账号一律加载失败
|
||
svc := NewChannelMonitorService(nil, nil)
|
||
svc.SetQuotaFetcher(fetcher)
|
||
|
||
quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(9)}
|
||
require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountRequired)
|
||
require.NotNil(t, quota.AccountID)
|
||
|
||
probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(9)}
|
||
require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe))
|
||
require.Nil(t, probe.AccountID, "probe mode should silently unbind stale account")
|
||
|
||
quotaNoAccount := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota}
|
||
require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quotaNoAccount), ErrChannelMonitorAccountRequired)
|
||
}
|
||
|
||
func TestRevalidateLinkedAccount_PlatformMismatch(t *testing.T) {
|
||
svc := NewChannelMonitorService(nil, nil)
|
||
svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
|
||
2: {ID: 2, Platform: domain.PlatformDeepseek},
|
||
}, nil))
|
||
|
||
quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)}
|
||
require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorProviderIncompatible)
|
||
|
||
probe := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)}
|
||
require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe))
|
||
require.Nil(t, probe.AccountID)
|
||
}
|
||
|
||
// 能力矩阵:与 fetchUncached 路由一一对应,创建期拦截注定运行期永久 error 的组合。
|
||
func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
account *Account
|
||
wantErr error
|
||
}{
|
||
{
|
||
name: "deepseek coding has no quota endpoint",
|
||
account: &Account{ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}},
|
||
wantErr: ErrChannelMonitorAccountNotSupportable,
|
||
},
|
||
{
|
||
// 自定义域名 kimi coding:GetCodingPlanProvider 识别不到 → 无额度端点。
|
||
name: "custom-domain kimi coding unsupported",
|
||
account: &Account{ID: 2, Platform: domain.PlatformKimi, Type: AccountTypeAPIKey,
|
||
Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://cw.example.com"}},
|
||
wantErr: ErrChannelMonitorAccountNotSupportable,
|
||
},
|
||
{
|
||
name: "kimi coding default endpoint ok",
|
||
account: &Account{ID: 3, Platform: domain.PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}},
|
||
},
|
||
{
|
||
name: "zhipu coding default endpoint ok",
|
||
account: &Account{ID: 4, Platform: domain.PlatformZhipu, Credentials: map[string]any{"account_mode": AccountModeCoding}},
|
||
},
|
||
{
|
||
name: "zhipu payg has no balance endpoint",
|
||
account: &Account{ID: 5, Platform: domain.PlatformZhipu},
|
||
wantErr: ErrChannelMonitorAccountNotSupportable,
|
||
},
|
||
{
|
||
name: "kimi payg ok",
|
||
account: &Account{ID: 6, Platform: domain.PlatformKimi},
|
||
},
|
||
{
|
||
name: "deepseek payg ok",
|
||
account: &Account{ID: 7, Platform: domain.PlatformDeepseek},
|
||
},
|
||
{
|
||
name: "anthropic api key cannot query usage",
|
||
account: &Account{ID: 8, Platform: domain.PlatformAnthropic, Type: AccountTypeAPIKey},
|
||
wantErr: ErrChannelMonitorAccountNotSupportable,
|
||
},
|
||
{
|
||
name: "anthropic oauth ok",
|
||
account: &Account{ID: 9, Platform: domain.PlatformAnthropic, Type: AccountTypeOAuth},
|
||
},
|
||
{
|
||
name: "anthropic setup token ok (local estimation)",
|
||
account: &Account{ID: 10, Platform: domain.PlatformAnthropic, Type: AccountTypeSetupToken},
|
||
},
|
||
{
|
||
name: "openai api key cannot query usage",
|
||
account: &Account{ID: 11, Platform: domain.PlatformOpenAI, Type: AccountTypeAPIKey},
|
||
wantErr: ErrChannelMonitorAccountNotSupportable,
|
||
},
|
||
{
|
||
name: "openai oauth ok",
|
||
account: &Account{ID: 12, Platform: domain.PlatformOpenAI, Type: AccountTypeOAuth},
|
||
},
|
||
{
|
||
// 防过度拦截:gemini/grok/antigravity 走本地统计/值通道降级,不会永久 error。
|
||
name: "gemini api key ok",
|
||
account: &Account{ID: 13, Platform: domain.PlatformGemini, Type: AccountTypeAPIKey},
|
||
},
|
||
{
|
||
name: "grok ok",
|
||
account: &Account{ID: 14, Platform: domain.PlatformGrok},
|
||
},
|
||
{
|
||
name: "antigravity ok",
|
||
account: &Account{ID: 15, Platform: domain.PlatformAntigravity},
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
err := monitorAccountQuotaCapability(tc.account)
|
||
if tc.wantErr == nil {
|
||
require.NoError(t, err)
|
||
} else {
|
||
require.ErrorIs(t, err, tc.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestValidateLinkedAccount_CapabilityRejected(t *testing.T) {
|
||
svc := NewChannelMonitorService(nil, nil)
|
||
svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
|
||
1: {ID: 1, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}},
|
||
}, nil))
|
||
|
||
err := svc.validateLinkedAccount(context.Background(), MonitorProviderDeepseek, int64Ptr(1))
|
||
require.ErrorIs(t, err, ErrChannelMonitorAccountNotSupportable)
|
||
}
|
||
|
||
func TestRevalidateLinkedAccount_Capability(t *testing.T) {
|
||
svc := NewChannelMonitorService(nil, nil)
|
||
svc.SetQuotaFetcher(newQuotaModeFetcher(map[int64]*Account{
|
||
2: {ID: 2, Platform: domain.PlatformDeepseek, Credentials: map[string]any{"account_mode": AccountModeCoding}},
|
||
}, nil))
|
||
|
||
quota := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeQuota, AccountID: int64Ptr(2)}
|
||
require.ErrorIs(t, svc.revalidateLinkedAccount(context.Background(), quota), ErrChannelMonitorAccountNotSupportable)
|
||
require.NotNil(t, quota.AccountID, "quota mode keeps the binding for the admin to fix")
|
||
|
||
probe := &ChannelMonitor{Provider: MonitorProviderDeepseek, CheckMode: MonitorCheckModeProbe, AccountID: int64Ptr(2)}
|
||
require.NoError(t, svc.revalidateLinkedAccount(context.Background(), probe))
|
||
require.Nil(t, probe.AccountID, "probe mode should silently unbind unusable account")
|
||
}
|
||
|
||
// provider-only 更新不得绕过 provider × check_mode 组合校验。
|
||
func TestApplyMonitorUpdate_ProviderOnlyRevalidatesCheckMode(t *testing.T) {
|
||
probeKimi := func() *ChannelMonitor {
|
||
return &ChannelMonitor{
|
||
Provider: MonitorProviderKimi, APIMode: MonitorAPIModeChatCompletions,
|
||
Endpoint: "https://api.kimi.com", PrimaryModel: "kimi-k2",
|
||
CheckMode: MonitorCheckModeProbe,
|
||
}
|
||
}
|
||
|
||
provider := MonitorProviderAntigravity
|
||
err := applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{Provider: &provider})
|
||
require.ErrorIs(t, err, ErrChannelMonitorInvalidCheckMode)
|
||
|
||
// 带上 check_mode/account_id 的完整切换合法。
|
||
accountID := int64(3)
|
||
err = applyMonitorUpdate(probeKimi(), ChannelMonitorUpdateParams{
|
||
Provider: &provider, CheckMode: strPtr(MonitorCheckModeQuota), AccountID: &accountID,
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// 存量非法行(antigravity+probe)仅改名/停用不被砖化。
|
||
legacy := &ChannelMonitor{
|
||
Provider: MonitorProviderAntigravity, APIMode: MonitorAPIModeChatCompletions,
|
||
Endpoint: "https://example.com", PrimaryModel: "gemini-3-pro",
|
||
CheckMode: MonitorCheckModeProbe,
|
||
}
|
||
newName := "renamed"
|
||
require.NoError(t, applyMonitorUpdate(legacy, ChannelMonitorUpdateParams{Name: &newName}))
|
||
}
|
||
|
||
// --- quota → probe 切换的 key 管控(validateProbeAPIKey) ---
|
||
|
||
func TestValidateProbeAPIKey_QuotaToProbeRequiresFreshKey(t *testing.T) {
|
||
svc := NewChannelMonitorService(nil, &duplicateChannelMonitorEncryptor{})
|
||
|
||
quota := &ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeQuota, APIKey: "NEW:"}
|
||
require.NoError(t, svc.validateProbeAPIKey(quota, "")) // quota 模式不管 key
|
||
|
||
quota.CheckMode = MonitorCheckModeProbe
|
||
// 存量密文解出空明文(quota 监控存的加密空串)→ 必须重填 key。
|
||
require.ErrorIs(t, svc.validateProbeAPIKey(quota, ""), ErrChannelMonitorMissingAPIKey)
|
||
// 提供新明文 key → 放行。
|
||
require.NoError(t, svc.validateProbeAPIKey(quota, "sk-fresh"))
|
||
// 密文解出非空明文 → 放行。
|
||
require.NoError(t, svc.validateProbeAPIKey(
|
||
&ChannelMonitor{Provider: MonitorProviderKimi, CheckMode: MonitorCheckModeProbe, APIKey: "OLD:sk-live"}, ""))
|
||
}
|
||
|
||
// --- Duplicate:quota 模式空明文重加密 ---
|
||
|
||
func TestDuplicateChannelMonitorQuotaModeReencryptsEmptyKey(t *testing.T) {
|
||
accountID := int64(9)
|
||
source := &ChannelMonitor{
|
||
ID: 42,
|
||
Name: "kimi-quota",
|
||
Provider: MonitorProviderKimi,
|
||
APIMode: MonitorAPIModeChatCompletions,
|
||
Endpoint: "",
|
||
APIKey: "OLD:", // 解密为空串(quota 监控的加密空 key)
|
||
PrimaryModel: "quota",
|
||
Enabled: true,
|
||
IntervalSeconds: 60,
|
||
CheckMode: MonitorCheckModeQuota,
|
||
AccountID: &accountID,
|
||
}
|
||
repo := &duplicateChannelMonitorRepoStub{source: source}
|
||
service := NewChannelMonitorService(repo, &duplicateChannelMonitorEncryptor{})
|
||
|
||
dup, err := service.Duplicate(context.Background(), 42, 7, "admin:7", "op-1")
|
||
require.NoError(t, err)
|
||
require.Equal(t, MonitorCheckModeQuota, dup.CheckMode)
|
||
require.NotNil(t, dup.AccountID)
|
||
require.Equal(t, accountID, *dup.AccountID)
|
||
require.Empty(t, dup.APIKey, "plaintext stays empty for quota monitors")
|
||
require.Len(t, repo.created, 1)
|
||
require.Equal(t, "NEW:", repo.created[0].APIKey, "empty key must be re-encrypted, not dropped")
|
||
}
|