test(account): 补充临时不可调度作用范围单元测试

- 后端:scope 解析归一化、缺省/显式账号级/显式模型级/模型未知兜底的分流判定,
  以及显式账号级、显式模型级 401、模型未知三种命中的落库位置与状态 JSON
- 前端:未设置 scope 的存量规则按 401 账号级、其余模型级回显并原样保存,
  显式 scope 回显与切换后提交,新增规则默认模型级
This commit is contained in:
feeeei
2026-10-03 12:08:05 +08:00
parent 5a25507dcb
commit a46249aac5
2 changed files with 232 additions and 0 deletions
@@ -0,0 +1,156 @@
//go:build unit
package service
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type tempUnschedScopeRepoStub struct {
modelNotFoundAccountRepoStub
tempReasons []string
}
func (r *tempUnschedScopeRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
r.tempCalls++
r.tempReasons = append(r.tempReasons, reason)
return nil
}
func newTempUnschedScopeAccount(scope string, extra map[string]any) *Account {
rule := map[string]any{
"error_code": float64(http.StatusServiceUnavailable),
"keywords": []any{"unavailable"},
"duration_minutes": float64(10),
}
if scope != "" {
rule["scope"] = scope
}
for k, v := range extra {
rule[k] = v
}
return &Account{
ID: 202,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Status: StatusActive,
Schedulable: true,
Credentials: map[string]any{
"temp_unschedulable_enabled": true,
"temp_unschedulable_rules": []any{rule},
},
}
}
var tempUnschedScopeBody = []byte(`{"error":{"message":"upstream unavailable"}}`)
func TestGetTempUnschedulableRules_ParsesScope(t *testing.T) {
mk := func(keyword string, scope any) map[string]any {
rule := map[string]any{
"error_code": float64(http.StatusServiceUnavailable),
"keywords": []any{keyword},
"duration_minutes": float64(1),
}
if scope != nil {
rule["scope"] = scope
}
return rule
}
account := &Account{Credentials: map[string]any{
"temp_unschedulable_rules": []any{
mk("a", "account"),
mk("b", " Model "),
mk("c", "whatever"),
mk("d", nil),
},
}}
rules := account.GetTempUnschedulableRules()
require.Len(t, rules, 4)
require.Equal(t, TempUnschedScopeAccount, rules[0].Scope)
require.Equal(t, TempUnschedScopeModel, rules[1].Scope, "大小写与首尾空白应被归一化")
require.Empty(t, rules[2].Scope, "未知取值视为未设置")
require.Empty(t, rules[3].Scope)
}
func TestTempUnschedRuleUsesModelScope(t *testing.T) {
cases := []struct {
name string
scope string
model string
status int
want bool
}{
{"缺省 模型已知 非401 走模型级", "", "gpt-5.4", http.StatusServiceUnavailable, true},
{"缺省 401 走账号级", "", "gpt-5.4", http.StatusUnauthorized, false},
{"缺省 模型未知 走账号级", "", "", http.StatusServiceUnavailable, false},
{"显式账号级 模型已知 仍账号级", TempUnschedScopeAccount, "gpt-5.4", http.StatusServiceUnavailable, false},
{"显式模型级 401 仍模型级", TempUnschedScopeModel, "gpt-5.4", http.StatusUnauthorized, true},
{"显式模型级 模型未知 兜底账号级", TempUnschedScopeModel, " ", http.StatusServiceUnavailable, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tempUnschedRuleUsesModelScope(TempUnschedulableRule{Scope: tc.scope}, tc.model, tc.status)
require.Equal(t, tc.want, got)
})
}
}
func TestHandleTempUnschedulable_ExplicitAccountScopePausesWholeAccountWithKnownModel(t *testing.T) {
repo := &tempUnschedScopeRepoStub{}
svc := &RateLimitService{accountRepo: repo}
account := newTempUnschedScopeAccount(TempUnschedScopeAccount, nil)
handled := svc.HandleTempUnschedulable(context.Background(), account, http.StatusServiceUnavailable, tempUnschedScopeBody, "gpt-5.4")
require.True(t, handled)
require.Equal(t, 1, repo.tempCalls)
require.Empty(t, repo.modelRateLimitCalls)
var state TempUnschedState
require.NoError(t, json.Unmarshal([]byte(repo.tempReasons[0]), &state))
require.Equal(t, TempUnschedScopeAccount, state.Scope)
require.Empty(t, state.Model)
}
func TestCheckErrorPolicy_ExplicitModelScopeKeeps401ModelScoped(t *testing.T) {
repo := &tempUnschedScopeRepoStub{}
svc := &RateLimitService{accountRepo: repo}
account := newTempUnschedScopeAccount(TempUnschedScopeModel, map[string]any{
"error_code": float64(http.StatusUnauthorized),
"keywords": []any{"unauthorized"},
})
result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized,
[]byte(`{"error":{"message":"unauthorized"}}`), "gpt-5.4")
require.Equal(t, ErrorPolicyTempUnscheduled, result)
require.Zero(t, repo.tempCalls, "运营人员显式选了模型级,401 也不应连累整个账号")
require.Len(t, repo.modelRateLimitCalls, 1)
require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope)
var state TempUnschedState
require.NoError(t, json.Unmarshal([]byte(repo.modelRateLimitCalls[0].reason), &state))
require.Equal(t, TempUnschedScopeModel, state.Scope)
require.Equal(t, "gpt-5.4", state.Model)
require.Equal(t, http.StatusUnauthorized, state.StatusCode)
}
func TestHandleTempUnschedulable_ExplicitModelScopeFallsBackToAccountWhenModelUnknown(t *testing.T) {
repo := &tempUnschedScopeRepoStub{}
svc := &RateLimitService{accountRepo: repo}
account := newTempUnschedScopeAccount(TempUnschedScopeModel, nil)
handled := svc.HandleTempUnschedulable(context.Background(), account, http.StatusServiceUnavailable, tempUnschedScopeBody)
require.True(t, handled)
require.Equal(t, 1, repo.tempCalls)
require.Empty(t, repo.modelRateLimitCalls)
}
@@ -1731,3 +1731,79 @@ describe('EditAccountModal OpenAI 自动使用重置卡', () => {
wrapper.unmount()
})
})
describe('EditAccountModal temp-unschedulable rule scope', () => {
function buildAccountWithTempUnschedRules(rules: Array<Record<string, unknown>>) {
const account = buildAccount()
return {
...account,
credentials: {
...account.credentials,
temp_unschedulable_enabled: true,
temp_unschedulable_rules: rules
}
} as any
}
const checkedScopes = (wrapper: ReturnType<typeof mountModal>) =>
wrapper
.findAll('[data-testid^="temp-unsched-scope-"][aria-checked="true"]')
.map((radio) => radio.attributes('data-testid'))
const savedRules = () =>
updateAccountMock.mock.calls[0]?.[1]?.credentials?.temp_unschedulable_rules
beforeEach(() => {
authIsSimpleMode.value = true
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
})
it('shows rules without scope the way the backend treats them and keeps that on save', async () => {
const account = buildAccountWithTempUnschedRules([
{ error_code: 401, keywords: ['unauthorized'], duration_minutes: 10 },
{ error_code: 503, keywords: ['unavailable'], duration_minutes: 30 }
])
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-account', 'temp-unsched-scope-model'])
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(savedRules().map((rule: { scope: string }) => rule.scope)).toEqual(['account', 'model'])
wrapper.unmount()
})
it('respects an explicit scope and saves the card the operator picks', async () => {
const account = buildAccountWithTempUnschedRules([
{ error_code: 401, keywords: ['unauthorized'], duration_minutes: 10, scope: 'model' },
{ error_code: 503, keywords: ['unavailable'], duration_minutes: 30, scope: ' Account ' }
])
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-model', 'temp-unsched-scope-account'])
await wrapper.findAll('[data-testid="temp-unsched-scope-model"]')[1].trigger('click')
expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-model', 'temp-unsched-scope-model'])
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(savedRules().map((rule: { scope: string }) => rule.scope)).toEqual(['model', 'model'])
wrapper.unmount()
})
it('defaults a newly added rule to model scope', async () => {
const wrapper = mountModal(buildAccountWithTempUnschedRules([]))
const addRule = wrapper
.findAll('button')
.find((button) => button.text().includes('admin.accounts.tempUnschedulable.addRule'))
await addRule!.trigger('click')
expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-model'])
wrapper.unmount()
})
})