package api import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "browser.local/run/protocol" ) func TestPlatformClientJobMethodsPostJSONAndDecodeResponses(t *testing.T) { seen := map[string]bool{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen[r.URL.Path] = true switch r.URL.Path { case "/api/v1/run/jobs/claim": var request protocol.RunJobClaimRequest decodeTestRequest(t, r, &request) if request.RunEndpointID != "run-local" || request.SessionToken != "session-token" { t.Fatalf("unexpected claim request: %+v", request) } writeTestJSON(t, w, validRunJobClaimResponse()) case "/api/v1/run/jobs/ack": var request protocol.RunJobAckRequest decodeTestRequest(t, r, &request) if request.JobID != "job-1" || request.LeaseToken != "lease-1" { t.Fatalf("unexpected ack request: %+v", request) } writeTestJSON(t, w, protocol.RunJobAckResponse{Accepted: true, Job: validRunJobAssignment(), ServerTime: fixedClientTestTime()}) case "/api/v1/run/jobs/progress": var request protocol.RunJobProgressRequest decodeTestRequest(t, r, &request) if request.Progress.Percent != 40 { t.Fatalf("unexpected progress request: %+v", request) } assignment := validRunJobAssignment() assignment.Progress.Percent = 40 writeTestJSON(t, w, protocol.RunJobProgressResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()}) case "/api/v1/run/jobs/result": var request protocol.RunJobResultRequest decodeTestRequest(t, r, &request) if request.State != "succeeded" || request.ResultRef == "" { t.Fatalf("unexpected result request: %+v", request) } assignment := validRunJobAssignment() assignment.State = "succeeded" assignment.ResultRef = request.ResultRef writeTestJSON(t, w, protocol.RunJobResultResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()}) case "/api/v1/run/jobs/cancel": var request protocol.RunJobCancelPollRequest decodeTestRequest(t, r, &request) if request.JobID != "job-1" { t.Fatalf("unexpected cancel request: %+v", request) } writeTestJSON(t, w, protocol.RunJobCancelPollResponse{Accepted: true, RunEndpointID: "run-local", HasCancel: true, JobID: "job-1", Reason: "stop", ServerTime: fixedClientTestTime()}) case "/api/v1/run/jobs/reconcile": var request protocol.RunJobReconcileRequest decodeTestRequest(t, r, &request) if len(request.ActiveJobIDs) != 1 || request.ActiveJobIDs[0] != "job-1" { t.Fatalf("unexpected reconcile request: %+v", request) } writeTestJSON(t, w, protocol.RunJobReconcileResponse{Accepted: true, RunEndpointID: "run-local", ActiveJobs: []protocol.RunJobAssignment{validRunJobAssignment()}, ServerTime: fixedClientTestTime()}) 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) } ctx := context.Background() claim, err := client.ClaimJob(ctx, validRunJobClaimRequest()) if err != nil || !claim.HasJob { t.Fatalf("claim job response=%+v err=%v", claim, err) } if _, err := client.AckJob(ctx, validRunJobAckRequest()); err != nil { t.Fatalf("ack job: %v", err) } if _, err := client.UpdateJobProgress(ctx, validRunJobProgressRequest()); err != nil { t.Fatalf("progress job: %v", err) } if _, err := client.CompleteJob(ctx, validRunJobResultRequest()); err != nil { t.Fatalf("complete job: %v", err) } if _, err := client.PollJobCancel(ctx, validRunJobCancelPollRequest()); err != nil { t.Fatalf("poll cancel: %v", err) } if _, err := client.ReconcileJobs(ctx, validRunJobReconcileRequest()); err != nil { t.Fatalf("reconcile jobs: %v", err) } for _, path := range []string{"/api/v1/run/jobs/claim", "/api/v1/run/jobs/ack", "/api/v1/run/jobs/progress", "/api/v1/run/jobs/result", "/api/v1/run/jobs/cancel", "/api/v1/run/jobs/reconcile"} { if !seen[path] { t.Fatalf("expected request to %s", path) } } } func TestPlatformClientJobMethodReturnsErrorForPlatformFailure(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.ClaimJob(context.Background(), validRunJobClaimRequest()); err == nil { t.Fatal("expected platform error") } } func TestPlatformClientJobLifecycleFlow(t *testing.T) { assignment := validRunJobAssignment() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/v1/run/jobs/claim": assignment.State = "accepted" writeTestJSON(t, w, protocol.RunJobClaimResponse{Accepted: true, RunEndpointID: "run-local", HasJob: true, Job: &assignment, NextPollSeconds: 2, ServerTime: fixedClientTestTime()}) case "/api/v1/run/jobs/ack": assignment.State = "running" writeTestJSON(t, w, protocol.RunJobAckResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()}) case "/api/v1/run/jobs/progress": assignment.Progress.Percent = 70 writeTestJSON(t, w, protocol.RunJobProgressResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()}) case "/api/v1/run/jobs/result": assignment.State = "succeeded" assignment.Progress.Percent = 100 assignment.ResultRef = "artifact://jobs/job-1/result" writeTestJSON(t, w, protocol.RunJobResultResponse{Accepted: true, Job: assignment, ServerTime: fixedClientTestTime()}) case "/api/v1/run/jobs/reconcile": writeTestJSON(t, w, protocol.RunJobReconcileResponse{Accepted: true, RunEndpointID: "run-local", UnknownJobIDs: []string{"local-only"}, ServerTime: fixedClientTestTime()}) 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) } ctx := context.Background() claim, err := client.ClaimJob(ctx, validRunJobClaimRequest()) if err != nil { t.Fatalf("claim job: %v", err) } ack, err := client.AckJob(ctx, protocol.RunJobAckRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt}) if err != nil { t.Fatalf("ack job: %v", err) } progress, err := client.UpdateJobProgress(ctx, protocol.RunJobProgressRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: ack.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, Progress: protocol.RunJobProgressReport{Percent: 70}}) if err != nil { t.Fatalf("progress job: %v", err) } result, err := client.CompleteJob(ctx, protocol.RunJobResultRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: progress.Job.JobID, LeaseToken: progress.Job.LeaseToken, Attempt: progress.Job.Attempt, State: "succeeded", Progress: protocol.RunJobProgressReport{Percent: 100}, ResultRef: "artifact://jobs/job-1/result"}) if err != nil { t.Fatalf("complete job: %v", err) } reconcile, err := client.ReconcileJobs(ctx, protocol.RunJobReconcileRequest{RunEndpointID: "run-local", SessionToken: "session-token", ActiveJobIDs: []string{"local-only"}}) if err != nil { t.Fatalf("reconcile jobs: %v", err) } if claim.Job.State != "accepted" || ack.Job.State != "running" || progress.Job.Progress.Percent != 70 || result.Job.State != "succeeded" || len(reconcile.UnknownJobIDs) != 1 { t.Fatalf("unexpected lifecycle responses: claim=%+v ack=%+v progress=%+v result=%+v reconcile=%+v", claim, ack, progress, result, reconcile) } } func decodeTestRequest(t *testing.T, r *http.Request, target any) { t.Helper() if r.Method != http.MethodPost { t.Fatalf("expected POST, got %s", r.Method) } if contentType := r.Header.Get("Content-Type"); contentType != "application/json" { t.Fatalf("expected JSON content type, got %q", contentType) } if err := json.NewDecoder(r.Body).Decode(target); err != nil { t.Fatalf("decode request: %v", err) } } func fixedClientTestTime() time.Time { return time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) } func validRunJobClaimRequest() protocol.RunJobClaimRequest { return protocol.RunJobClaimRequest{RunEndpointID: "run-local", SessionToken: "session-token", Capabilities: []string{"process.start"}, Capacity: protocol.RunCapacityReport{MaxJobs: 4}} } func validRunJobAckRequest() protocol.RunJobAckRequest { return protocol.RunJobAckRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-1", LeaseToken: "lease-1", Attempt: 1, Message: "started"} } func validRunJobProgressRequest() protocol.RunJobProgressRequest { return protocol.RunJobProgressRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-1", LeaseToken: "lease-1", Attempt: 1, Progress: protocol.RunJobProgressReport{Percent: 40, Message: "working"}} } func validRunJobResultRequest() protocol.RunJobResultRequest { return protocol.RunJobResultRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-1", LeaseToken: "lease-1", Attempt: 1, State: "succeeded", Progress: protocol.RunJobProgressReport{Percent: 100}, ResultRef: "artifact://jobs/job-1/result", Message: "done"} } func validRunJobCancelPollRequest() protocol.RunJobCancelPollRequest { return protocol.RunJobCancelPollRequest{RunEndpointID: "run-local", SessionToken: "session-token", JobID: "job-1", LeaseToken: "lease-1"} } func validRunJobReconcileRequest() protocol.RunJobReconcileRequest { return protocol.RunJobReconcileRequest{RunEndpointID: "run-local", SessionToken: "session-token", ActiveJobIDs: []string{"job-1"}} } func validRunJobClaimResponse() protocol.RunJobClaimResponse { job := validRunJobAssignment() return protocol.RunJobClaimResponse{Accepted: true, RunEndpointID: "run-local", HasJob: true, Job: &job, NextPollSeconds: 2, ServerTime: fixedClientTestTime()} } func validRunJobAssignment() protocol.RunJobAssignment { return protocol.RunJobAssignment{ JobID: "job-1", RunEndpointID: "run-local", Capability: "process.start", IdempotencyKey: "idem-1", State: "accepted", LeaseToken: "lease-1", Attempt: 1, CreatedAt: fixedClientTestTime(), UpdatedAt: fixedClientTestTime(), } }