ai-code-battle/cmd/acb-worker/api_test.go
jedarden 6659027bec Implement match worker container (cmd/acb-worker/)
- 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>
2026-03-24 08:06:15 -04:00

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