//go:build load package api import ( "bytes" "context" "fmt" "net/http" "net/http/httptest" "os" "sort" "strconv" "sync" "testing" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) // TestQueueCreateHTTPLoad is the bounded, repeatable API portion of ยง8.51. // It deliberately uses the real HTTP handler and in-process queue boundary; // database/replica capacity and matcher throughput remain separate gates. func TestQueueCreateHTTPLoad(t *testing.T) { clients := loadInt(t, "COSMIC_CLASH_LOAD_CLIENTS", 10000) concurrency := loadInt(t, "COSMIC_CLASH_LOAD_CONCURRENCY", 256) p95Limit := time.Duration(loadInt(t, "COSMIC_CLASH_LOAD_P95_MS", 250)) * time.Millisecond if clients < 1 || clients > 100000 || concurrency < 1 || concurrency > clients || p95Limit <= 0 || p95Limit > 10*time.Second { t.Fatalf("invalid load configuration clients=%d concurrency=%d p95=%s", clients, concurrency, p95Limit) } now := time.Unix(1_000_000, 0).UTC() sessions := domain.NewSessionStore() queue := domain.NewQueue() service := &Service{ Sessions: sessions, Queue: queue, Now: func() time.Time { return now }, CandidateV2: func(playerID, ticketID string, spec domain.QueueSpec) (domain.Candidate, error) { return domain.Candidate{ PlayerID: playerID, TicketID: ticketID, Playlist: spec.Playlist, ClientBuild: spec.ClientBuild, ProtocolVersion: spec.ProtocolVersion, EnqueuedAt: now, PredictedRTT: map[string]float64{"EU": 20}, }, nil }, } httpServer := httptest.NewServer(service.Handler()) defer httpServer.Close() tokens := make([]string, clients) for i := range tokens { session, token, err := sessions.Issue(fmt.Sprintf("load-player-%d", i), time.Hour, now) if err != nil { t.Fatalf("issue session %d: %v", i, err) } tokens[i] = session.SessionID + ":" + token } client := &http.Client{Transport: &http.Transport{MaxIdleConns: clients, MaxIdleConnsPerHost: clients}} start := make(chan struct{}) jobs := make(chan int) durations := make([]time.Duration, clients) statuses := make([]int, clients) var wg sync.WaitGroup for worker := 0; worker < concurrency; worker++ { wg.Add(1) go func() { defer wg.Done() <-start for index := range jobs { started := time.Now() body := fmt.Sprintf(`{"ticket_id":"load-ticket-%08d","playlist":"casual","client_build":"build-1","protocol_version":1}`, index) request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, httpServer.URL+"/v1/queue", bytes.NewBufferString(body)) if err != nil { continue } request.Header.Set("Authorization", "Bearer "+tokens[index]) request.Header.Set("Idempotency-Key", fmt.Sprintf("load-create-key-%08d", index)) request.Header.Set("Content-Type", "application/json") response, err := client.Do(request) if err == nil { statuses[index] = response.StatusCode response.Body.Close() } durations[index] = time.Since(started) } }() } close(start) for i := 0; i < clients; i++ { jobs <- i } close(jobs) wg.Wait() ordered := append([]time.Duration(nil), durations...) sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] }) p95 := ordered[(len(ordered)*95+99)/100-1] for i, status := range statuses { if status != http.StatusCreated { t.Fatalf("client %d returned HTTP %d; the load request must create a queue ticket", i, status) } } if p95 > p95Limit { t.Fatalf("queue-create HTTP p95=%s exceeds %s for %d clients at %d in-flight", p95, p95Limit, clients, concurrency) } t.Logf("queue-create load: clients=%d concurrency=%d p95=%s p99=%s", clients, concurrency, p95, ordered[(len(ordered)*99+99)/100-1]) } func loadInt(t *testing.T, name string, fallback int) int { t.Helper() value := fallback if raw := os.Getenv(name); raw != "" { parsed, err := strconv.Atoi(raw) if err != nil { t.Fatalf("%s=%q is not an integer", name, raw) } value = parsed } return value }