package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "browser.local/run/protocol" ) func TestNewPlatformClientNormalizesBaseURL(t *testing.T) { client, err := NewPlatformClient("http://platform.test/") if err != nil { t.Fatalf("unexpected error: %v", err) } if client.BaseURL() != "http://platform.test" { t.Fatalf("expected normalized base URL, got %q", client.BaseURL()) } } func TestNewPlatformClientRequiresAbsoluteURL(t *testing.T) { if _, err := NewPlatformClient("platform.local"); err == nil { t.Fatal("expected error for URL without scheme and host") } } func TestPlatformClientHelloPostsJSONAndDecodesResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/control/hello" { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } if contentType := r.Header.Get("Content-Type"); contentType != "application/json" { t.Fatalf("expected JSON content type, got %q", contentType) } var request protocol.RunHelloRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatalf("decode hello request: %v", err) } if request.RunEndpointID != "run-local" || request.CapabilityReport.Fingerprint != "cap-v1" { t.Fatalf("unexpected hello payload: %+v", request) } writeTestJSON(t, w, protocol.RunHelloResponse{ Accepted: true, RunEndpointID: "run-local", SessionToken: "session-token", ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), HeartbeatIntervalSeconds: 15, }) })) defer server.Close() client, err := NewPlatformClient(server.URL) if err != nil { t.Fatalf("new client: %v", err) } response, err := client.Hello(context.Background(), validRunHelloRequest()) if err != nil { t.Fatalf("hello: %v", err) } if !response.Accepted || response.SessionToken != "session-token" || response.HeartbeatIntervalSeconds != 15 { t.Fatalf("unexpected hello response: %+v", response) } } func TestPlatformClientHeartbeatPostsJSONAndDecodesResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/api/v1/run/control/heartbeat" { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } var request protocol.RunHeartbeatRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatalf("decode heartbeat request: %v", err) } if request.SessionToken != "session-token" || request.Capacity.RunningJobs != 1 { t.Fatalf("unexpected heartbeat payload: %+v", request) } writeTestJSON(t, w, protocol.RunHeartbeatResponse{ Accepted: true, RunEndpointID: "run-local", NextHeartbeatSeconds: 15, RefreshCapabilities: true, ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), }) })) defer server.Close() client, err := NewPlatformClient(server.URL) if err != nil { t.Fatalf("new client: %v", err) } response, err := client.Heartbeat(context.Background(), validRunHeartbeatRequest("session-token")) if err != nil { t.Fatalf("heartbeat: %v", err) } if !response.Accepted || !response.RefreshCapabilities || response.NextHeartbeatSeconds != 15 { t.Fatalf("unexpected heartbeat response: %+v", response) } } func TestPlatformClientReturnsErrorForPlatformFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"code":"validation_failed"}`)) })) defer server.Close() client, err := NewPlatformClient(server.URL) if err != nil { t.Fatalf("new client: %v", err) } if _, err := client.Hello(context.Background(), validRunHelloRequest()); err == nil { t.Fatal("expected platform error") } } func TestPlatformClientHelloThenHeartbeatFlow(t *testing.T) { var activeSessionToken string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/run/control/hello": var request protocol.RunHelloRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatalf("decode hello request: %v", err) } if request.RegistrationToken == "" || request.RunEndpointID != "run-local" { t.Fatalf("unexpected hello request: %+v", request) } activeSessionToken = "session-token" writeTestJSON(t, w, protocol.RunHelloResponse{ Accepted: true, RunEndpointID: request.RunEndpointID, SessionToken: activeSessionToken, ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), HeartbeatIntervalSeconds: 15, }) case "/api/v1/run/control/heartbeat": var request protocol.RunHeartbeatRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { t.Fatalf("decode heartbeat request: %v", err) } if request.SessionToken != activeSessionToken { t.Fatalf("heartbeat did not use active session token: %+v", request) } writeTestJSON(t, w, protocol.RunHeartbeatResponse{ Accepted: true, RunEndpointID: request.RunEndpointID, NextHeartbeatSeconds: 15, ServerTime: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), }) default: t.Fatalf("unexpected request path %s", r.URL.Path) } })) defer server.Close() client, err := NewPlatformClient(server.URL) if err != nil { t.Fatalf("new client: %v", err) } hello, err := client.Hello(context.Background(), validRunHelloRequest()) if err != nil { t.Fatalf("hello: %v", err) } heartbeat, err := client.Heartbeat(context.Background(), validRunHeartbeatRequest(hello.SessionToken)) if err != nil { t.Fatalf("heartbeat: %v", err) } if !hello.Accepted || !heartbeat.Accepted { t.Fatalf("expected accepted hello and heartbeat, got %+v %+v", hello, heartbeat) } } func writeTestJSON(t *testing.T, w http.ResponseWriter, value any) { t.Helper() w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(value); err != nil { t.Fatalf("encode response: %v", err) } } func validRunHelloRequest() protocol.RunHelloRequest { return protocol.RunHelloRequest{ RegistrationToken: "registration-token", RunEndpointID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Status: "online", Platform: "darwin/arm64", CapabilityReport: protocol.RunCapabilityReport{ Capabilities: []string{"control.hello", "control.heartbeat"}, Fingerprint: "cap-v1", }, Capacity: protocol.RunCapacityReport{MaxJobs: 4}, } } func validRunHeartbeatRequest(sessionToken string) protocol.RunHeartbeatRequest { return protocol.RunHeartbeatRequest{ RunEndpointID: "run-local", SessionToken: sessionToken, Version: "0.1.0", Status: "online", CapabilityFingerprint: "cap-v1", Capacity: protocol.RunCapacityReport{ MaxJobs: 4, RunningJobs: 1, }, } }