package api import ( "net/http" "net/http/httptest" "testing" "browser.local/platform/domain" "browser.local/platform/dto" ) func TestRunControlAPIHelloHeartbeatWorkflow(t *testing.T) { router := newTestRouter() helloRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", validRunControlHelloRequest()) assertStatus(t, helloRecorder, http.StatusOK) hello := decodeBody[dto.RunControlHelloResponse](t, helloRecorder) if !hello.Accepted || hello.SessionToken == "" || hello.RunEndpointID != "run-local" { t.Fatalf("expected accepted hello response, got %+v", hello) } endpoint := getJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints/run-local") if endpoint.Status != domain.RunEndpointStatusOnline || len(endpoint.Capabilities) != 2 { t.Fatalf("expected registered endpoint metadata, got %+v", endpoint) } heartbeatRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{ RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.1", Status: domain.RunEndpointStatusOnline, CapabilityFingerprint: "cap-v2", Capacity: dto.RunCapacityResponse{ MaxJobs: 4, RunningJobs: 1, }, }) assertStatus(t, heartbeatRecorder, http.StatusOK) heartbeat := decodeBody[dto.RunControlHeartbeatResponse](t, heartbeatRecorder) if !heartbeat.Accepted || !heartbeat.RefreshCapabilities || heartbeat.NextHeartbeatSeconds <= 0 { t.Fatalf("expected accepted heartbeat with refresh, got %+v", heartbeat) } } func TestRunLifecycleReportAPIProjectsServerState(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) helloRequest := validRunControlHelloRequest() helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "logs.read") helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle-report" hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest)) server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-lifecycle-report-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Lifecycle Report API", State: domain.ServerInstanceStateReady}, adminSession) recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: server.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running", Summary: "private supervised process identity"}}) assertStatus(t, recorder, http.StatusOK) response := decodeBody[dto.RunLifecycleReportResponse](t, recorder) if !response.Accepted || response.ProjectedState != domain.ServerInstanceStateRunning { t.Fatalf("expected lifecycle report projection, got %+v", response) } updated := getJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances/"+server.ID, adminSession) if updated.State != domain.ServerInstanceStateRunning { t.Fatalf("expected server state projected running, got %+v", updated) } } func TestRunControlAPIReRegistrationRotatesToken(t *testing.T) { router := newTestRouter() first := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest())) request := validRunControlHelloRequest() request.Version = "0.2.0" second := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, request)) if second.SessionToken == first.SessionToken { t.Fatalf("expected new session token after re-registration, got %q", second.SessionToken) } } func TestRunControlAPIErrors(t *testing.T) { router := newTestRouter() invalid := validRunControlHelloRequest() invalid.RegistrationToken = "" invalid.Capacity.RunningJobs = 8 invalid.Capacity.MaxJobs = 4 invalidHello := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", invalid) assertErrorResponse(t, invalidHello, http.StatusBadRequest, errorCodeValidation) hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest())) invalidHeartbeat := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{ RunEndpointID: "run-local", SessionToken: "stale-token", Version: "0.1.1", Status: domain.RunEndpointStatusOnline, CapabilityFingerprint: "cap-v1", Capacity: dto.RunCapacityResponse{MaxJobs: 4}, }) assertErrorResponse(t, invalidHeartbeat, http.StatusBadRequest, errorCodeValidation) validHeartbeat := performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{ RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.1", Status: domain.RunEndpointStatusOnline, CapabilityFingerprint: "cap-v1", Capacity: dto.RunCapacityResponse{MaxJobs: 4}, }) assertStatus(t, validHeartbeat, http.StatusOK) methodFailure := performRaw(t, router, http.MethodGet, "/api/v1/run/control/hello", "") assertErrorResponse(t, methodFailure, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed) } func performRunControlHello(t *testing.T, router http.Handler, request dto.RunControlHelloRequest) *httptest.ResponseRecorder { t.Helper() recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/control/hello", request) assertStatus(t, recorder, http.StatusOK) return recorder } func validRunControlHelloRequest() dto.RunControlHelloRequest { return dto.RunControlHelloRequest{ RegistrationToken: "registration-token", RunEndpointID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Status: domain.RunEndpointStatusOnline, Platform: "darwin/arm64", CapabilityReport: dto.RunCapabilityReport{ Capabilities: []string{"control.hello", "control.heartbeat"}, Fingerprint: "cap-v1", }, Capacity: dto.RunCapacityResponse{MaxJobs: 4}, } }