//go:build unit package handler import ( "bytes" "context" "net/http" "net/http/httptest" "sync/atomic" "testing" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) type countingGatewaySchedulerCache struct { *fakeSchedulerCache snapshotCalls atomic.Int64 } func (c *countingGatewaySchedulerCache) GetSnapshot(ctx context.Context, bucket service.SchedulerBucket) ([]*service.Account, bool, error) { c.snapshotCalls.Add(1) return c.fakeSchedulerCache.GetSnapshot(ctx, bucket) } func TestGatewayHandlerPreCancelledCompatibleRequestsDoNotSelectAccount(t *testing.T) { gin.SetMode(gin.TestMode) groupID := int64(9100) group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformAnthropic, Status: service.StatusActive} account := &service.Account{ ID: 9101, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Concurrency: 1, AccountGroups: []service.AccountGroup{{AccountID: 9101, GroupID: groupID}}, } schedulerCache := &countingGatewaySchedulerCache{fakeSchedulerCache: &fakeSchedulerCache{accounts: []*service.Account{account}}} schedulerSnapshot := service.NewSchedulerSnapshotService(schedulerCache, nil, nil, nil, nil) gatewayService := service.NewGatewayService( nil, &fakeGroupRepo{group: group}, nil, nil, nil, nil, nil, nil, nil, schedulerSnapshot, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ) cfg := &config.Config{RunMode: config.RunModeSimple} billingCacheService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil) t.Cleanup(billingCacheService.Stop) h := &GatewayHandler{ gatewayService: gatewayService, billingCacheService: billingCacheService, concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&fakeConcurrencyCache{}), SSEPingFormatClaude, 0), maxAccountSwitches: 1, cfg: cfg, } apiKey := &service.APIKey{ ID: 9102, UserID: 9103, GroupID: &groupID, Group: group, Status: service.StatusActive, User: &service.User{ID: 9103, Concurrency: 10, Balance: 100}, } tests := []struct { name string path string body string call func(*gin.Context) }{ { name: "responses", path: "/v1/responses", body: `{"model":"claude-test","input":"hello","stream":false}`, call: h.Responses, }, { name: "chat completions", path: "/v1/chat/completions", body: `{"model":"claude-test","messages":[{"role":"user","content":"hello"}],"stream":false}`, call: h.ChatCompletions, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { schedulerCache.snapshotCalls.Store(0) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) ctx, cancel := context.WithCancel(context.Background()) cancel() ctx = context.WithValue(ctx, ctxkey.Group, group) req := httptest.NewRequest(http.MethodPost, tt.path, bytes.NewBufferString(tt.body)).WithContext(ctx) req.Header.Set("Content-Type", "application/json") c.Request = req c.Set(string(middleware.ContextKeyAPIKey), apiKey) c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10}) tt.call(c) require.Zero(t, schedulerCache.snapshotCalls.Load(), "a cancelled request must stop before the account selector") _, selected := c.Get(opsAccountIDKey) require.False(t, selected) }) } }