//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) }