| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706 |
- //go:build unit
- package service
- import (
- "context"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
- "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- // --- 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
- bulkUpdatedIDs []int64
- bulkUpdatedPayload AccountBulkUpdate
- 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) BulkUpdate(_ context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) {
- r.bulkUpdatedIDs = append([]int64(nil), ids...)
- r.bulkUpdatedPayload = updates
- return int64(len(ids)), 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.Len(t, upstream.requests, 1)
- require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[0].Context()))
- 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_OpenAIOAuthTestNormalizesGPT56Alias(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, _ := newTestContext()
- resp := newJSONResponse(http.StatusOK, "")
- resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
- `))
- upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
- svc := &AccountTestService{httpUpstream: upstream}
- account := &Account{
- ID: 90,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{"access_token": "test-token"},
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.6", "", "")
- require.NoError(t, err)
- require.Len(t, upstream.requests, 1)
- body, err := io.ReadAll(upstream.requests[0].Body)
- require.NoError(t, err)
- require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(body, "model").String())
- }
- func TestAccountTestService_OpenAIShadowUsesParentCredentialsAndShadowModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, recorder := newTestContext()
- resp := newJSONResponse(http.StatusOK, "")
- resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
- `))
- parentID := int64(100)
- parent := &Account{
- ID: parentID,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Credentials: map[string]any{
- "access_token": "parent-token",
- "chatgpt_account_id": "org-parent",
- },
- }
- shadow := &Account{
- ID: 200,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- ParentAccountID: &parentID,
- QuotaDimension: QuotaDimensionSpark,
- Concurrency: 2,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
- },
- },
- }
- repo := &openAIAccountTestRepo{
- mockAccountRepoForGemini: mockAccountRepoForGemini{
- accountsByID: map[int64]*Account{
- parentID: parent,
- 200: shadow,
- },
- },
- }
- upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
- err := svc.TestAccountConnection(ctx, shadow.ID, "gpt-5.3-codex-spark", "", "")
- require.NoError(t, err)
- require.Len(t, upstream.requests, 1)
- req := upstream.requests[0]
- require.Equal(t, "Bearer parent-token", req.Header.Get("Authorization"))
- require.Equal(t, "org-parent", req.Header.Get("chatgpt-account-id"))
- body, err := io.ReadAll(req.Body)
- require.NoError(t, err)
- require.Equal(t, "gpt-5.3-codex-spark", gjson.GetBytes(body, "model").String())
- require.Contains(t, recorder.Body.String(), `"success":true`)
- }
- func TestAccountTestService_OpenAIStreamEOFBeforeCompletedFails(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, recorder := newTestContext()
- resp := newJSONResponse(http.StatusOK, "")
- resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"hi"}
- `))
- upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
- svc := &AccountTestService{httpUpstream: upstream}
- account := &Account{
- ID: 90,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{"access_token": "test-token"},
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
- require.Error(t, err)
- require.Contains(t, recorder.Body.String(), "response.completed")
- require.NotContains(t, recorder.Body.String(), `"success":true`)
- }
- func TestAccountTestService_DeepSeekCustomBaseURLUsesV1ResponsesPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, _ := newTestContext()
- resp := newJSONResponse(http.StatusOK, "")
- resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
- `))
- upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- }
- account := &Account{
- ID: 91,
- Platform: PlatformDeepseek,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://relay.example.com/v1",
- "api_protocol": APIProtocolResponses,
- },
- Extra: map[string]any{
- openai_compat.ExtraKeyResponsesSupported: true,
- },
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
- require.NoError(t, err)
- require.Len(t, upstream.requests, 1)
- require.Equal(t, "https://relay.example.com/v1/responses", upstream.requests[0].URL.String())
- }
- func TestAccountTestService_DeepSeekResponsesRoutesToOpenAIProbe(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, _ := newTestContext()
- resp := newJSONResponse(http.StatusOK, "")
- resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
- `))
- upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- }
- account := &Account{
- ID: 93,
- Platform: PlatformDeepseek,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://relay.example.com/v1",
- "api_protocol": APIProtocolResponses,
- },
- Extra: map[string]any{
- openai_compat.ExtraKeyResponsesSupported: true,
- },
- }
- repo := &openAIAccountTestRepo{
- mockAccountRepoForGemini: mockAccountRepoForGemini{
- accountsByID: map[int64]*Account{93: account},
- },
- }
- svc.accountRepo = repo
- err := svc.TestAccountConnection(ctx, account.ID, "gpt-5.4", "", "")
- require.NoError(t, err)
- require.Len(t, upstream.requests, 1)
- require.Equal(t, "https://relay.example.com/v1/responses", upstream.requests[0].URL.String())
- }
- func TestAccountTestService_DeepSeekDefaultBaseURLUsesNativeResponsesPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, _ := newTestContext()
- resp := newJSONResponse(http.StatusOK, "")
- resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"}
- `))
- upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- }
- account := &Account{
- ID: 92,
- Platform: PlatformDeepseek,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "api_protocol": APIProtocolResponses,
- },
- Extra: map[string]any{
- openai_compat.ExtraKeyResponsesSupported: true,
- },
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
- require.NoError(t, err)
- require.Len(t, upstream.requests, 1)
- require.Equal(t, "https://api.deepseek.com/responses", upstream.requests[0].URL.String())
- }
- 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_OpenAI429SyncsObservedPlanType(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, _ := newTestContext()
- resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","plan_type":"free","resets_at":1777283883}}`)
- repo := &openAIAccountTestRepo{}
- upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
- account := &Account{
- ID: 81,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Status: StatusActive,
- Concurrency: 1,
- Credentials: map[string]any{"access_token": "test-token", "plan_type": "plus"},
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
- require.Error(t, err)
- require.Equal(t, []int64{account.ID}, repo.bulkUpdatedIDs)
- require.Equal(t, "free", repo.bulkUpdatedPayload.Credentials["plan_type"])
- require.Equal(t, "free", account.Credentials["plan_type"])
- require.Equal(t, account.ID, repo.rateLimitedID)
- require.NotNil(t, account.RateLimitResetAt)
- }
- 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)
- }
- func TestAccountTestService_OpenAIAPIKeyResponsesUsesCodexProbeHeaders(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, _ := newTestContext()
- resp := newJSONResponse(http.StatusOK, "")
- resp.Body = io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\"}\n\n"))
- upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- }
- account := &Account{
- ID: 95,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://compat-upstream.example/v1",
- },
- Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: true},
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
- require.NoError(t, err)
- require.Len(t, upstream.requests, 1)
- req := upstream.requests[0]
- require.Equal(t, "https://compat-upstream.example/v1/responses", req.URL.String())
- requireOpenAICodexProbeHeaders(t, req.Header)
- }
- func TestAccountTestService_OpenAIAPIKeyResponsesUnsupportedUsesChatCompletionsPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, recorder := newTestContext()
- upstreamBody := strings.Join([]string{
- `data: {"id":"chatcmpl_test","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"pong"},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_test","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- }
- account := &Account{
- ID: 91,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://compat-upstream.example/v1",
- },
- Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false},
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "hello", "")
- require.NoError(t, err)
- require.NotNil(t, upstream.lastReq)
- require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
- require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
- require.Equal(t, "Bearer sk-test", upstream.lastReq.Header.Get("Authorization"))
- require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
- require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
- require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
- require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
- require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
- body := recorder.Body.String()
- require.Contains(t, body, "pong")
- require.Contains(t, body, "已通过 /v1/chat/completions 验证")
- require.Contains(t, body, `"success":true`)
- require.NotContains(t, body, "当前测试接口仅支持 Responses API 路径")
- }
- func TestAccountTestService_OpenAIChatCompletionsPathReturns4xx(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, recorder := newTestContext()
- upstream := &httpUpstreamRecorder{resp: newJSONResponse(http.StatusBadRequest, `{"error":{"message":"bad request"}}`)}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- }
- account := &Account{
- ID: 92,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://compat-upstream.example",
- },
- Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false},
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
- require.Error(t, err)
- require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
- require.Contains(t, err.Error(), "Chat Completions API (/v1/chat/completions) returned 400")
- require.Contains(t, recorder.Body.String(), "/v1/chat/completions")
- require.NotContains(t, recorder.Body.String(), `"success":true`)
- }
- func TestAccountTestService_OpenAIChatCompletionsPathTimeout(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, recorder := newTestContext()
- upstream := &httpUpstreamRecorder{err: context.DeadlineExceeded}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- }
- account := &Account{
- ID: 93,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://compat-upstream.example",
- },
- Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false},
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
- require.Error(t, err)
- require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
- require.Contains(t, err.Error(), "Chat Completions API (/v1/chat/completions) request failed")
- require.Contains(t, err.Error(), context.DeadlineExceeded.Error())
- require.Contains(t, recorder.Body.String(), "/v1/chat/completions")
- require.NotContains(t, recorder.Body.String(), `"success":true`)
- }
- func TestAccountTestService_OpenAIChatCompletionsPathRejectsNonJSONStream(t *testing.T) {
- gin.SetMode(gin.TestMode)
- ctx, recorder := newTestContext()
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader("data: not-json\n\n")),
- }}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
- }
- account := &Account{
- ID: 94,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://compat-upstream.example",
- },
- Extra: map[string]any{openai_compat.ExtraKeyResponsesSupported: false},
- }
- err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4", "", "")
- require.Error(t, err)
- require.Equal(t, "https://compat-upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
- require.Contains(t, err.Error(), "Invalid Chat Completions response from /v1/chat/completions")
- require.Contains(t, recorder.Body.String(), "/v1/chat/completions")
- require.NotContains(t, recorder.Body.String(), `"success":true`)
- }
|