- Worker polls Cloudflare Worker API for pending match jobs - Claims jobs and executes matches using the game engine - Uploads replays to R2 via S3-compatible API - Sends heartbeats during match execution - Submits results back to Worker API - Includes retry logic with exponential backoff - API client tests for job coordination endpoints Also fixes glicko2.ts: export g() and E() functions for testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
337 lines
8.1 KiB
Go
337 lines
8.1 KiB
Go
// API client tests
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGetNextJob(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
response APIResponse
|
|
wantNil bool
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "no pending jobs",
|
|
response: APIResponse{
|
|
Success: true,
|
|
Data: nil,
|
|
},
|
|
wantNil: true,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "pending job found",
|
|
response: APIResponse{
|
|
Success: true,
|
|
Data: json.RawMessage(`{
|
|
"id": "job-123",
|
|
"match_id": "match-456",
|
|
"status": "pending",
|
|
"created_at": "2024-01-01T00:00:00Z"
|
|
}`),
|
|
},
|
|
wantNil: false,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "api error",
|
|
response: APIResponse{
|
|
Success: false,
|
|
Error: "internal server error",
|
|
},
|
|
wantNil: true,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/jobs/next" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.Method != "GET" {
|
|
t.Errorf("unexpected method: %s", r.Method)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(tt.response)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &Config{
|
|
APIEndpoint: server.URL,
|
|
APIKey: "test-key",
|
|
MaxRetries: 0,
|
|
}
|
|
client := NewAPIClient(cfg)
|
|
|
|
job, err := client.GetNextJob(context.Background())
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("GetNextJob() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if (job == nil) != tt.wantNil {
|
|
t.Errorf("GetNextJob() job = %v, wantNil %v", job, tt.wantNil)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClaimJob(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
response APIResponse
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "successful claim",
|
|
response: APIResponse{
|
|
Success: true,
|
|
Data: json.RawMessage(`{
|
|
"job": {"id": "job-123", "match_id": "match-456", "status": "claimed", "created_at": "2024-01-01T00:00:00Z"},
|
|
"match": {"id": "match-456", "status": "running", "map_id": "map-789", "created_at": "2024-01-01T00:00:00Z"},
|
|
"participants": [],
|
|
"map": {"id": "map-789", "width": 60, "height": 60, "walls": "", "spawns": "", "cores": ""},
|
|
"bots": [],
|
|
"bot_secrets": []
|
|
}`),
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "job already claimed",
|
|
response: APIResponse{
|
|
Success: false,
|
|
Error: "job not found or already claimed",
|
|
},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("unexpected method: %s", r.Method)
|
|
}
|
|
|
|
var body map[string]string
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Errorf("failed to decode body: %v", err)
|
|
}
|
|
if body["worker_id"] != "worker-1" {
|
|
t.Errorf("unexpected worker_id: %s", body["worker_id"])
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(tt.response)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &Config{
|
|
APIEndpoint: server.URL,
|
|
APIKey: "test-key",
|
|
MaxRetries: 0,
|
|
}
|
|
client := NewAPIClient(cfg)
|
|
|
|
claim, err := client.ClaimJob(context.Background(), "job-123", "worker-1")
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ClaimJob() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if !tt.wantErr && claim == nil {
|
|
t.Error("ClaimJob() returned nil claim without error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHeartbeat(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("unexpected method: %s", r.Method)
|
|
}
|
|
|
|
var body map[string]string
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
if body["worker_id"] != "worker-1" {
|
|
t.Errorf("unexpected worker_id: %s", body["worker_id"])
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(APIResponse{Success: true})
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &Config{
|
|
APIEndpoint: server.URL,
|
|
APIKey: "test-key",
|
|
MaxRetries: 0,
|
|
}
|
|
client := NewAPIClient(cfg)
|
|
|
|
if err := client.Heartbeat(context.Background(), "job-123", "worker-1"); err != nil {
|
|
t.Errorf("Heartbeat() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSubmitResult(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "POST" {
|
|
t.Errorf("unexpected method: %s", r.Method)
|
|
}
|
|
|
|
var body map[string]interface{}
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
if body["winner_id"] != "bot-1" {
|
|
t.Errorf("unexpected winner_id: %v", body["winner_id"])
|
|
}
|
|
if body["turns"].(float64) != 100 {
|
|
t.Errorf("unexpected turns: %v", body["turns"])
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(APIResponse{Success: true})
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &Config{
|
|
APIEndpoint: server.URL,
|
|
APIKey: "test-key",
|
|
MaxRetries: 0,
|
|
}
|
|
client := NewAPIClient(cfg)
|
|
|
|
result := &MatchResult{
|
|
WinnerID: "bot-1",
|
|
Turns: 100,
|
|
EndReason: "elimination",
|
|
Scores: map[string]int{"bot-1": 5, "bot-2": 2},
|
|
}
|
|
|
|
if err := client.SubmitResult(context.Background(), "job-123", result, "https://r2.example.com/replay.json"); err != nil {
|
|
t.Errorf("SubmitResult() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestHTTPError(t *testing.T) {
|
|
err := &HTTPError{StatusCode: 404, Body: "not found"}
|
|
expected := "HTTP 404: not found"
|
|
if err.Error() != expected {
|
|
t.Errorf("HTTPError.Error() = %q, want %q", err.Error(), expected)
|
|
}
|
|
}
|
|
|
|
func TestAPIAuth(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if auth != "Bearer test-api-key" {
|
|
t.Errorf("unexpected authorization header: %s", auth)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(APIResponse{Success: true})
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &Config{
|
|
APIEndpoint: server.URL,
|
|
APIKey: "test-api-key",
|
|
MaxRetries: 0,
|
|
}
|
|
client := NewAPIClient(cfg)
|
|
|
|
_, err := client.GetNextJob(context.Background())
|
|
if err != nil {
|
|
t.Errorf("GetNextJob() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRetryLogic(t *testing.T) {
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
if attempts < 3 {
|
|
// Simulate server error (retryable)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Success on third attempt
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(APIResponse{Success: true})
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &Config{
|
|
APIEndpoint: server.URL,
|
|
APIKey: "test-key",
|
|
MaxRetries: 3,
|
|
}
|
|
client := NewAPIClient(cfg)
|
|
|
|
_, err := client.GetNextJob(context.Background())
|
|
if err != nil {
|
|
t.Errorf("GetNextJob() error = %v", err)
|
|
}
|
|
if attempts != 3 {
|
|
t.Errorf("expected 3 attempts, got %d", attempts)
|
|
}
|
|
}
|
|
|
|
func TestClientErrorNoRetry(t *testing.T) {
|
|
attempts := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
attempts++
|
|
// Client error (should not retry)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
w.Write([]byte("bad request"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &Config{
|
|
APIEndpoint: server.URL,
|
|
APIKey: "test-key",
|
|
MaxRetries: 3,
|
|
}
|
|
client := NewAPIClient(cfg)
|
|
|
|
_, err := client.GetNextJob(context.Background())
|
|
if err == nil {
|
|
t.Error("expected error for bad request")
|
|
}
|
|
if attempts != 1 {
|
|
t.Errorf("expected 1 attempt (no retry for client errors), got %d", attempts)
|
|
}
|
|
}
|
|
|
|
func TestContextCancellation(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(2 * time.Second) // Long delay
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &Config{
|
|
APIEndpoint: server.URL,
|
|
APIKey: "test-key",
|
|
MaxRetries: 0,
|
|
}
|
|
client := NewAPIClient(cfg)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := client.GetNextJob(ctx)
|
|
if err == nil {
|
|
t.Error("expected context cancellation error")
|
|
}
|
|
}
|