213 lines
7.0 KiB
Go
213 lines
7.0 KiB
Go
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,
|
|
},
|
|
}
|
|
}
|