//go:build unit package handler import ( "context" "fmt" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/Wei-Shaw/sub2api/internal/service" ) type fakeDiagnoser struct { calls []fakeDiagnoseCall resp service.ModelAvailabilityDiagnosis } type fakeDiagnoseCall struct { GroupID *int64 Model string Platform string } func (f *fakeDiagnoser) DiagnoseModelAvailabilityForPlatform( _ context.Context, groupID *int64, model, platform string, ) service.ModelAvailabilityDiagnosis { f.calls = append(f.calls, fakeDiagnoseCall{ GroupID: groupID, Model: model, Platform: platform, }) return f.resp } func ptrInt64(v int64) *int64 { return &v } // newTestGinContextWithRequest wraps the bare newTestGinContext helper // (defined in openai_gateway_cyber_test.go) by additionally attaching a stub // *http.Request so the classifier can extract c.Request.Context(). func newTestGinContextWithRequest() *gin.Context { c := newTestGinContext() c.Request = httptest.NewRequest(http.MethodPost, "/test", nil) return c } func TestClassifyNoAccountError_NilDiagnoser_Falls503(t *testing.T) { c := newTestGinContextWithRequest() apiKey := &service.APIKey{GroupID: ptrInt64(7)} cls := classifyNoAccountErrorFromGin(c, nil, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI) require.Equal(t, http.StatusServiceUnavailable, cls.Status) require.Equal(t, "api_error", cls.ErrType) require.False(t, cls.ModelNotFound) } func TestClassifyNoAccountError_NilAPIKey_Falls503(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} cls := classifyNoAccountErrorFromGin(c, fd, nil, "gpt-5", "gpt-5", service.PlatformOpenAI) require.Equal(t, http.StatusServiceUnavailable, cls.Status) require.False(t, cls.ModelNotFound) require.Empty(t, fd.calls, "diagnoser must not be consulted when apiKey missing") } func TestClassifyNoAccountError_NilGroupID_Falls503(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} apiKey := &service.APIKey{GroupID: nil} cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI) require.Equal(t, http.StatusServiceUnavailable, cls.Status) require.False(t, cls.ModelNotFound) require.Empty(t, fd.calls, "diagnoser must not be consulted when group not bound") } func TestClassifyNoAccountError_EmptyModel_Falls503(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} apiKey := &service.APIKey{GroupID: ptrInt64(7)} cls := classifyNoAccountErrorFromGin(c, fd, apiKey, " ", "", service.PlatformOpenAI) require.Equal(t, http.StatusServiceUnavailable, cls.Status) require.False(t, cls.ModelNotFound) require.Empty(t, fd.calls) } func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} apiKey := &service.APIKey{GroupID: ptrInt64(42)} cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.1-codex-mini", "gpt-5.1-codex-mini", service.PlatformOpenAI) require.Equal(t, http.StatusNotFound, cls.Status) require.Equal(t, "model_not_found", cls.ErrType) require.True(t, cls.ModelNotFound) require.Contains(t, cls.Message, "gpt-5.1-codex-mini", "message must surface the requested model") require.Len(t, fd.calls, 1) require.Equal(t, "gpt-5.1-codex-mini", fd.calls[0].Model) require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform) require.NotNil(t, fd.calls[0].GroupID) require.Equal(t, int64(42), *fd.calls[0].GroupID) require.True(t, service.HasOpsClientBusinessLimited(c)) require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c)) } func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} groupID := int64(43) apiKey := &service.APIKey{ GroupID: &groupID, Group: &service.Group{ ID: groupID, Platform: service.PlatformGrok, }, } cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, fd, apiKey, "grok-4.5", "grok-4.5") require.Equal(t, http.StatusNotFound, cls.Status) require.Equal(t, "model_not_found", cls.ErrType) require.True(t, cls.ModelNotFound) require.Len(t, fd.calls, 1) require.Equal(t, service.PlatformGrok, fd.calls[0].Platform) require.True(t, service.HasOpsClientBusinessLimited(c)) require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c)) logErr := openAICompatibleSelectionErrorForLog( fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"), service.PlatformGrok, ) require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5") } func TestClassifyNoAccountError_PureClassifierDoesNotMarkGinContext(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} apiKey := &service.APIKey{GroupID: ptrInt64(7)} cls := classifyNoAccountError(c.Request.Context(), fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI) require.True(t, cls.ModelNotFound) require.False(t, service.HasOpsClientBusinessLimited(c)) require.Empty(t, service.OpsClientBusinessLimitedReason(c)) } func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}} apiKey := &service.APIKey{GroupID: ptrInt64(7)} cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI) require.Equal(t, http.StatusServiceUnavailable, cls.Status, "model exists somewhere — caller stays on 503") require.Equal(t, "api_error", cls.ErrType) require.False(t, cls.ModelNotFound) } func TestClassifyNoAccountError_ModelSupportedOnlyByRateLimitedAccount_Returns503(t *testing.T) { c := newTestGinContextWithRequest() // The diagnoser's configured-state lookup still sees the model-supporting // account even though normal scheduling has excluded it during cooldown. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}} apiKey := &service.APIKey{GroupID: ptrInt64(7)} cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "claude-opus-4-8", "claude-opus-4-8", service.PlatformAnthropic) require.Equal(t, http.StatusServiceUnavailable, cls.Status) require.Equal(t, "api_error", cls.ErrType) require.False(t, cls.ModelNotFound, "temporary account cooldown must remain retryable") } func TestClassifyNoAccountError_NoAccountsInPool_Stays503(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: false, HasModelSupport: false}} apiKey := &service.APIKey{GroupID: ptrInt64(7)} cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI) require.Equal(t, http.StatusServiceUnavailable, cls.Status, "empty pool is a service-availability issue, not a model issue") require.False(t, cls.ModelNotFound) } func TestClassifyNoAccountError_DisplayModelOverridesRoutingForMessage(t *testing.T) { c := newTestGinContextWithRequest() fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} apiKey := &service.APIKey{GroupID: ptrInt64(7)} cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "claude-3-fancy", service.PlatformOpenAI) require.True(t, cls.ModelNotFound) require.Contains(t, cls.Message, "claude-3-fancy", "user-facing message must reference the model the user asked for, not the post-mapping routing model") require.Len(t, fd.calls, 1) require.Equal(t, "gpt-5", fd.calls[0].Model, "diagnosis must run against the routing model (post group dispatch mapping)") } func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) { fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}} apiKey := &service.APIKey{GroupID: ptrInt64(7)} cls := classifyNoAccountErrorFromGin(nil, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI) require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response") require.True(t, cls.ModelNotFound) require.False(t, service.HasOpsClientBusinessLimited(nil)) require.Empty(t, service.OpsClientBusinessLimitedReason(nil)) }