| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639 |
- //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: "minimax coding default endpoint ok",
- account: &Account{ID: 14, Platform: domain.PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModeCoding}},
- },
- {
- name: "custom-domain minimax coding unsupported",
- account: &Account{ID: 16, Platform: domain.PlatformMiniMax, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://relay.example.com/v1"}},
- wantErr: ErrChannelMonitorAccountNotSupportable,
- },
- {
- name: "zhipu payg has no balance endpoint",
- account: &Account{ID: 5, Platform: domain.PlatformZhipu},
- wantErr: ErrChannelMonitorAccountNotSupportable,
- },
- {
- name: "minimax payg has no balance endpoint",
- account: &Account{ID: 15, Platform: domain.PlatformMiniMax},
- 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")
- }
|