将 vansour/sub2api#1555 的 OpenAI compact 能力建模手工移植到当前 main:账号 级 compact 状态/auto-force_on-force_off 模式、compact-only 模型映射、调度器 tier 分层(已支持 > 未知 > 已知不支持)、管理后台 compact 主动探测,以及对应 i18n/状态徽章。普通 /responses 流量行为不变,无数据库迁移。
276 lines
9.1 KiB
Go
276 lines
9.1 KiB
Go
//go:build unit
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
|
)
|
|
|
|
// --- shared test helpers ---
|
|
|
|
type queuedHTTPUpstream struct {
|
|
responses []*http.Response
|
|
requests []*http.Request
|
|
tlsFlags []bool
|
|
}
|
|
|
|
func (u *queuedHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
return nil, fmt.Errorf("unexpected Do call")
|
|
}
|
|
|
|
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
|
u.requests = append(u.requests, req)
|
|
u.tlsFlags = append(u.tlsFlags, profile != nil)
|
|
if len(u.responses) == 0 {
|
|
return nil, fmt.Errorf("no mocked response")
|
|
}
|
|
resp := u.responses[0]
|
|
u.responses = u.responses[1:]
|
|
return resp, nil
|
|
}
|
|
|
|
func newJSONResponse(status int, body string) *http.Response {
|
|
return &http.Response{
|
|
StatusCode: status,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
}
|
|
}
|
|
|
|
// --- test functions ---
|
|
|
|
func newTestContext() (*gin.Context, *httptest.ResponseRecorder) {
|
|
gin.SetMode(gin.TestMode)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
|
|
return c, rec
|
|
}
|
|
|
|
type openAIAccountTestRepo struct {
|
|
mockAccountRepoForGemini
|
|
updatedExtra map[string]any
|
|
rateLimitedID int64
|
|
rateLimitedAt *time.Time
|
|
clearedErrorID int64
|
|
setErrorID int64
|
|
setErrorMsg string
|
|
}
|
|
|
|
func (r *openAIAccountTestRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error {
|
|
r.updatedExtra = updates
|
|
return nil
|
|
}
|
|
|
|
func (r *openAIAccountTestRepo) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error {
|
|
r.rateLimitedID = id
|
|
r.rateLimitedAt = &resetAt
|
|
return nil
|
|
}
|
|
|
|
func (r *openAIAccountTestRepo) ClearError(_ context.Context, id int64) error {
|
|
r.clearedErrorID = id
|
|
return nil
|
|
}
|
|
|
|
func (r *openAIAccountTestRepo) SetError(_ context.Context, id int64, errorMsg string) error {
|
|
r.setErrorID = id
|
|
r.setErrorMsg = errorMsg
|
|
return nil
|
|
}
|
|
|
|
func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
ctx, recorder := newTestContext()
|
|
|
|
resp := newJSONResponse(http.StatusOK, "")
|
|
resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
|
|
|
|
`))
|
|
resp.Header.Set("x-codex-primary-used-percent", "88")
|
|
resp.Header.Set("x-codex-primary-reset-after-seconds", "604800")
|
|
resp.Header.Set("x-codex-primary-window-minutes", "10080")
|
|
resp.Header.Set("x-codex-secondary-used-percent", "42")
|
|
resp.Header.Set("x-codex-secondary-reset-after-seconds", "18000")
|
|
resp.Header.Set("x-codex-secondary-window-minutes", "300")
|
|
|
|
repo := &openAIAccountTestRepo{}
|
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
|
account := &Account{
|
|
ID: 89,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Concurrency: 1,
|
|
Credentials: map[string]any{"access_token": "test-token"},
|
|
}
|
|
|
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, repo.updatedExtra)
|
|
require.Equal(t, 42.0, repo.updatedExtra["codex_5h_used_percent"])
|
|
require.Equal(t, 88.0, repo.updatedExtra["codex_7d_used_percent"])
|
|
require.Contains(t, recorder.Body.String(), "test_complete")
|
|
}
|
|
|
|
func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimitState(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
ctx, _ := newTestContext()
|
|
|
|
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":1777283883}}`)
|
|
resp.Header.Set("x-codex-primary-used-percent", "100")
|
|
resp.Header.Set("x-codex-primary-reset-after-seconds", "604800")
|
|
resp.Header.Set("x-codex-primary-window-minutes", "10080")
|
|
resp.Header.Set("x-codex-secondary-used-percent", "100")
|
|
resp.Header.Set("x-codex-secondary-reset-after-seconds", "18000")
|
|
resp.Header.Set("x-codex-secondary-window-minutes", "300")
|
|
|
|
repo := &openAIAccountTestRepo{}
|
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
|
account := &Account{
|
|
ID: 88,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Status: StatusError,
|
|
Concurrency: 1,
|
|
Credentials: map[string]any{"access_token": "test-token"},
|
|
}
|
|
|
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
|
|
require.Error(t, err)
|
|
require.NotEmpty(t, repo.updatedExtra)
|
|
require.Equal(t, 100.0, repo.updatedExtra["codex_5h_used_percent"])
|
|
require.Equal(t, account.ID, repo.rateLimitedID)
|
|
require.NotNil(t, repo.rateLimitedAt)
|
|
require.Equal(t, account.ID, repo.clearedErrorID)
|
|
require.Equal(t, StatusActive, account.Status)
|
|
require.Empty(t, account.ErrorMessage)
|
|
require.NotNil(t, account.RateLimitResetAt)
|
|
}
|
|
|
|
func TestAccountTestService_OpenAI429BodyOnlyPersistsRateLimitAndClearsStaleError(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
ctx, _ := newTestContext()
|
|
|
|
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":"1777283883"}}`)
|
|
|
|
repo := &openAIAccountTestRepo{}
|
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
|
account := &Account{
|
|
ID: 77,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Status: StatusError,
|
|
ErrorMessage: "Access forbidden (403): account may be suspended or lack permissions",
|
|
Concurrency: 1,
|
|
Credentials: map[string]any{"access_token": "test-token"},
|
|
}
|
|
|
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
|
|
require.Error(t, err)
|
|
require.Equal(t, account.ID, repo.rateLimitedID)
|
|
require.NotNil(t, repo.rateLimitedAt)
|
|
require.Equal(t, account.ID, repo.clearedErrorID)
|
|
require.Equal(t, StatusActive, account.Status)
|
|
require.Empty(t, account.ErrorMessage)
|
|
require.NotNil(t, account.RateLimitResetAt)
|
|
require.Empty(t, repo.updatedExtra)
|
|
}
|
|
|
|
func TestAccountTestService_OpenAI429ActiveAccountDoesNotClearError(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
ctx, _ := newTestContext()
|
|
|
|
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_in_seconds":3600}}`)
|
|
|
|
repo := &openAIAccountTestRepo{}
|
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
|
account := &Account{
|
|
ID: 78,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Status: StatusActive,
|
|
Concurrency: 1,
|
|
Credentials: map[string]any{"access_token": "test-token"},
|
|
}
|
|
|
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
|
|
require.Error(t, err)
|
|
require.Equal(t, account.ID, repo.rateLimitedID)
|
|
require.NotNil(t, repo.rateLimitedAt)
|
|
require.Zero(t, repo.clearedErrorID)
|
|
require.Equal(t, StatusActive, account.Status)
|
|
require.NotNil(t, account.RateLimitResetAt)
|
|
}
|
|
|
|
func TestAccountTestService_OpenAI429WithoutResetSignalDoesNotMutateRuntimeState(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
ctx, _ := newTestContext()
|
|
|
|
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached"}}`)
|
|
|
|
repo := &openAIAccountTestRepo{}
|
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
|
account := &Account{
|
|
ID: 79,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Status: StatusError,
|
|
ErrorMessage: "stale 403",
|
|
Concurrency: 1,
|
|
Credentials: map[string]any{"access_token": "test-token"},
|
|
}
|
|
|
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
|
|
require.Error(t, err)
|
|
require.Zero(t, repo.rateLimitedID)
|
|
require.Nil(t, repo.rateLimitedAt)
|
|
require.Zero(t, repo.clearedErrorID)
|
|
require.Equal(t, StatusError, account.Status)
|
|
require.Equal(t, "stale 403", account.ErrorMessage)
|
|
require.Nil(t, account.RateLimitResetAt)
|
|
}
|
|
|
|
func TestAccountTestService_OpenAI401SetsPermanentErrorOnly(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
ctx, _ := newTestContext()
|
|
|
|
resp := newJSONResponse(http.StatusUnauthorized, `{"error":"bad token"}`)
|
|
|
|
repo := &openAIAccountTestRepo{}
|
|
upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
|
|
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
|
|
account := &Account{
|
|
ID: 80,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Status: StatusActive,
|
|
Concurrency: 1,
|
|
Credentials: map[string]any{"access_token": "test-token"},
|
|
}
|
|
|
|
err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
|
|
require.Error(t, err)
|
|
require.Equal(t, account.ID, repo.setErrorID)
|
|
require.Contains(t, repo.setErrorMsg, "Authentication failed (401)")
|
|
require.Zero(t, repo.rateLimitedID)
|
|
require.Zero(t, repo.clearedErrorID)
|
|
require.Nil(t, account.RateLimitResetAt)
|
|
}
|