Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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
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
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const teamLinkedDeactivatedBody = `{"detail":{"code":"deactivated_workspace","message":"This workspace has been deactivated."}}`
|
||||
|
||||
type teamLinkedAccountRepoStub struct {
|
||||
mockAccountRepoForGemini
|
||||
teamAccounts []Account
|
||||
listErr error
|
||||
listCalls int
|
||||
setErrorIDs []int64
|
||||
setErrorMsgs map[int64]string
|
||||
failSetError map[int64]error
|
||||
}
|
||||
|
||||
// ListByPlatform 镜像真实仓库语义:仅返回该平台的 active 账户。
|
||||
func (r *teamLinkedAccountRepoStub) ListByPlatform(ctx context.Context, platform string) ([]Account, error) {
|
||||
r.listCalls++
|
||||
if r.listErr != nil {
|
||||
return nil, r.listErr
|
||||
}
|
||||
out := make([]Account, 0, len(r.teamAccounts))
|
||||
for _, acc := range r.teamAccounts {
|
||||
if acc.Platform == platform && acc.Status == StatusActive {
|
||||
out = append(out, acc)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *teamLinkedAccountRepoStub) SetError(ctx context.Context, id int64, errorMsg string) error {
|
||||
if err, ok := r.failSetError[id]; ok {
|
||||
return err
|
||||
}
|
||||
r.setErrorIDs = append(r.setErrorIDs, id)
|
||||
if r.setErrorMsgs == nil {
|
||||
r.setErrorMsgs = make(map[int64]string)
|
||||
}
|
||||
r.setErrorMsgs[id] = errorMsg
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTeamLinkedAccount(id int64, teamID string) Account {
|
||||
return Account{
|
||||
ID: id,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Credentials: map[string]any{"chatgpt_account_id": teamID},
|
||||
}
|
||||
}
|
||||
|
||||
// newTeamLinkedFixture: #1 触发者(team-A) #2 同队 #3 异队 #4 apikey #5 影子 #6 同队 #7 同队但已 error
|
||||
func newTeamLinkedFixture() []Account {
|
||||
parentID := int64(1)
|
||||
shadow := Account{
|
||||
ID: 5,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
ParentAccountID: &parentID,
|
||||
}
|
||||
apikey := newTeamLinkedAccount(4, "team-A")
|
||||
apikey.Type = AccountTypeAPIKey
|
||||
erroredSibling := newTeamLinkedAccount(7, "team-A")
|
||||
erroredSibling.Status = StatusError
|
||||
return []Account{
|
||||
newTeamLinkedAccount(1, "team-A"),
|
||||
newTeamLinkedAccount(2, "team-A"),
|
||||
newTeamLinkedAccount(3, "team-B"),
|
||||
apikey,
|
||||
shadow,
|
||||
newTeamLinkedAccount(6, "team-A"),
|
||||
erroredSibling,
|
||||
}
|
||||
}
|
||||
|
||||
func newTeamLinkedTestService(repo *teamLinkedAccountRepoStub) (*RateLimitService, *runtimeBlockRecorder) {
|
||||
rl := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
blocker := &runtimeBlockRecorder{}
|
||||
rl.SetAccountRuntimeBlocker(blocker)
|
||||
return rl, blocker
|
||||
}
|
||||
|
||||
func TestTeamLinkedError_FanoutMarksSameTeamAccounts(t *testing.T) {
|
||||
repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()}
|
||||
rl, blocker := newTeamLinkedTestService(repo)
|
||||
trigger := newTeamLinkedAccount(1, "team-A")
|
||||
|
||||
shouldDisable := rl.HandleUpstreamError(context.Background(), &trigger, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody))
|
||||
|
||||
require.True(t, shouldDisable)
|
||||
// fan-out 先标记同队兄弟(#2、#6),触发账户 #1 随后由常规 case 402 标记
|
||||
require.Equal(t, []int64{2, 6, 1}, repo.setErrorIDs)
|
||||
require.Contains(t, repo.setErrorMsgs[2], "team-linked error triggered by account #1")
|
||||
require.Contains(t, repo.setErrorMsgs[6], "team-linked error triggered by account #1")
|
||||
require.Contains(t, repo.setErrorMsgs[1], "Workspace deactivated (402)")
|
||||
require.NotContains(t, repo.setErrorMsgs[1], "team-linked")
|
||||
// 熔断顺序:兄弟账户先于落库全部进程内熔断,触发账户走 auth_error
|
||||
require.Equal(t, []string{openAITeamLinkedErrorBlockReason, openAITeamLinkedErrorBlockReason, "auth_error"}, blocker.reasons)
|
||||
require.Equal(t, int64(2), blocker.accounts[0].ID)
|
||||
require.Equal(t, int64(6), blocker.accounts[1].ID)
|
||||
require.Equal(t, int64(1), blocker.accounts[2].ID)
|
||||
}
|
||||
|
||||
func TestTeamLinkedError_GenericPaymentErrorDoesNotFanout(t *testing.T) {
|
||||
repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()}
|
||||
rl, _ := newTeamLinkedTestService(repo)
|
||||
trigger := newTeamLinkedAccount(1, "team-A")
|
||||
|
||||
rl.HandleUpstreamError(context.Background(), &trigger, http.StatusPaymentRequired, http.Header{}, []byte(`{"error":{"message":"insufficient balance"}}`))
|
||||
|
||||
require.Equal(t, []int64{1}, repo.setErrorIDs)
|
||||
require.Contains(t, repo.setErrorMsgs[1], "Payment required (402)")
|
||||
require.Zero(t, repo.listCalls)
|
||||
}
|
||||
|
||||
func TestTeamLinkedError_DedupWithinTTL(t *testing.T) {
|
||||
repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()}
|
||||
rl, _ := newTeamLinkedTestService(repo)
|
||||
first := newTeamLinkedAccount(1, "team-A")
|
||||
second := newTeamLinkedAccount(2, "team-A")
|
||||
|
||||
rl.HandleUpstreamError(context.Background(), &first, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody))
|
||||
rl.HandleUpstreamError(context.Background(), &second, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody))
|
||||
|
||||
// 第二次触发被去重:只有 #2 自身经 case 402 标记,未再次 fan-out
|
||||
require.Equal(t, []int64{2, 6, 1, 2}, repo.setErrorIDs)
|
||||
require.Equal(t, 1, repo.listCalls)
|
||||
}
|
||||
|
||||
func TestTeamLinkedError_APIKeyTriggerDoesNotFanout(t *testing.T) {
|
||||
repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()}
|
||||
rl, _ := newTeamLinkedTestService(repo)
|
||||
trigger := newTeamLinkedAccount(4, "team-A")
|
||||
trigger.Type = AccountTypeAPIKey
|
||||
|
||||
rl.HandleUpstreamError(context.Background(), &trigger, http.StatusPaymentRequired, http.Header{}, []byte(teamLinkedDeactivatedBody))
|
||||
|
||||
require.Equal(t, []int64{4}, repo.setErrorIDs)
|
||||
require.Zero(t, repo.listCalls)
|
||||
}
|
||||
|
||||
func TestTeamLinkedError_DirectCallSkipsTriggerAccount(t *testing.T) {
|
||||
// 直调对应 fastpath 调用点:账户级临时不可调度规则短路时联动仍然生效
|
||||
repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()}
|
||||
rl, blocker := newTeamLinkedTestService(repo)
|
||||
trigger := newTeamLinkedAccount(1, "team-A")
|
||||
|
||||
rl.maybeHandleOpenAITeamLinkedError(context.Background(), &trigger, http.StatusPaymentRequired, []byte(teamLinkedDeactivatedBody))
|
||||
|
||||
require.Equal(t, []int64{2, 6}, repo.setErrorIDs)
|
||||
require.Equal(t, []string{openAITeamLinkedErrorBlockReason, openAITeamLinkedErrorBlockReason}, blocker.reasons)
|
||||
}
|
||||
|
||||
func TestTeamLinkedError_MissingTeamIDDoesNothing(t *testing.T) {
|
||||
repo := &teamLinkedAccountRepoStub{teamAccounts: newTeamLinkedFixture()}
|
||||
rl, blocker := newTeamLinkedTestService(repo)
|
||||
trigger := Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive}
|
||||
|
||||
rl.maybeHandleOpenAITeamLinkedError(context.Background(), &trigger, http.StatusPaymentRequired, []byte(teamLinkedDeactivatedBody))
|
||||
|
||||
require.Empty(t, repo.setErrorIDs)
|
||||
require.Empty(t, blocker.reasons)
|
||||
require.Zero(t, repo.listCalls)
|
||||
}
|
||||
|
||||
func TestTeamLinkedError_SetErrorFailureDoesNotAbortRemaining(t *testing.T) {
|
||||
repo := &teamLinkedAccountRepoStub{
|
||||
teamAccounts: newTeamLinkedFixture(),
|
||||
failSetError: map[int64]error{2: errors.New("db down")},
|
||||
}
|
||||
rl, blocker := newTeamLinkedTestService(repo)
|
||||
trigger := newTeamLinkedAccount(1, "team-A")
|
||||
|
||||
rl.maybeHandleOpenAITeamLinkedError(context.Background(), &trigger, http.StatusPaymentRequired, []byte(teamLinkedDeactivatedBody))
|
||||
|
||||
require.Equal(t, []int64{6}, repo.setErrorIDs)
|
||||
// 进程内熔断先于落库执行,两个账户都已被熔断
|
||||
require.Len(t, blocker.reasons, 2)
|
||||
}
|
||||
Reference in New Issue
Block a user