From 92047eaa5746474285ff99d8f65186555721fb7c Mon Sep 17 00:00:00 2001 From: Oleksii Date: Thu, 21 May 2026 01:23:44 -0300 Subject: [PATCH] test: add comprehensive tests for all new CLI commands Cover 27 new commands added in 6035f4c with httptest-based integration tests: instance (update-context, batch-create, checkpoints, inject-blocks, bulk-state, bulk-reschedule, stream), sequence (list, delete, migrate-instance), rollback-policy (list, get, create, delete), session (update-data), plugin (update), credential (update), pool (add/update/delete-resource), circuit-breaker (per-tenant list/get/reset), and approval (rich output). --- cmd/commands_new_test.go | 811 +++++++++++++++++++++++++++++++++++++++ cmd/instance_new_test.go | 551 ++++++++++++++++++++++++++ 2 files changed, 1362 insertions(+) create mode 100644 cmd/commands_new_test.go create mode 100644 cmd/instance_new_test.go diff --git a/cmd/commands_new_test.go b/cmd/commands_new_test.go new file mode 100644 index 0000000..34901ea --- /dev/null +++ b/cmd/commands_new_test.go @@ -0,0 +1,811 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/orch8-io/cli/internal/output" + orch8 "github.com/orch8-io/sdk-go" +) + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// setupTest configures flags and output capture for a single test. +// It returns the output buffer and a cleanup function. +func setupTest(srvURL string, jsonMode bool) (*bytes.Buffer, func()) { + flagURL = srvURL + flagTenantID = "t1" + flagJSON = jsonMode + flagAPIKey = "" + + buf := new(bytes.Buffer) + output.Out = buf + + return buf, func() { output.Out = nil } +} + +// run sets up rootCmd and executes with the given args, writing into buf. +func run(t *testing.T, buf *bytes.Buffer, args ...string) { + t.Helper() + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs(args) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// captureStdout redirects os.Stdout to a pipe so that bare fmt.Println +// calls can be captured. Returns a function that restores stdout and +// returns everything written. +func captureStdout(t *testing.T) func() string { + t.Helper() + origStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("creating pipe: %v", err) + } + os.Stdout = w + + return func() string { + w.Close() + captured, _ := io.ReadAll(r) + r.Close() + os.Stdout = origStdout + return string(captured) + } +} + +// writeTempJSON writes data as JSON to a temp file and returns its path. +// The caller must remove the file when done. +func writeTempJSON(t *testing.T, data any) string { + t.Helper() + f, err := os.CreateTemp("", "test-*.json") + if err != nil { + t.Fatalf("creating temp file: %v", err) + } + if err := json.NewEncoder(f).Encode(data); err != nil { + f.Close() + os.Remove(f.Name()) + t.Fatalf("encoding JSON: %v", err) + } + f.Close() + return f.Name() +} + +// decodeBody reads the request body into a generic map. +func decodeBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + var m map[string]any + if err := json.NewDecoder(r.Body).Decode(&m); err != nil { + t.Fatalf("decoding request body: %v", err) + } + return m +} + +// --------------------------------------------------------------------------- +// Sequence commands +// --------------------------------------------------------------------------- + +func TestSequenceList(t *testing.T) { + sequences := []orch8.SequenceDefinition{ + { + ID: "s1", + Name: "my-seq", + Namespace: "default", + Version: 1, + Deprecated: false, + CreatedAt: "2025-01-01T00:00:00Z", + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/sequences" && r.Method == http.MethodGet { + json.NewEncoder(w).Encode(sequences) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "sequence", "list", "--json") + + var out []orch8.SequenceDefinition + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if len(out) != 1 { + t.Fatalf("expected 1 sequence, got %d", len(out)) + } + if out[0].ID != "s1" { + t.Errorf("expected ID=s1, got %s", out[0].ID) + } + if out[0].Name != "my-seq" { + t.Errorf("expected Name=my-seq, got %s", out[0].Name) + } +} + +func TestSequenceDelete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/sequences/s1" && r.Method == http.MethodDelete { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, false) + defer cleanup() + + getStdout := captureStdout(t) + + run(t, buf, "sequence", "delete", "s1") + + got := strings.TrimSpace(getStdout()) + if got != "Deleted: s1" { + t.Errorf("expected %q, got %q", "Deleted: s1", got) + } +} + +func TestSequenceMigrateInstance(t *testing.T) { + result := orch8.TaskInstance{ + ID: "i1", + SequenceID: "s2", + TenantID: "t1", + Namespace: "default", + State: "pending", + CreatedAt: "2025-01-01T00:00:00Z", + UpdatedAt: "2025-01-01T00:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/sequences/migrate-instance" && r.Method == http.MethodPost { + body := decodeBody(t, r) + if body["instance_id"] != "i1" { + t.Errorf("expected instance_id=i1, got %v", body["instance_id"]) + } + if body["target_sequence_id"] != "s2" { + t.Errorf("expected target_sequence_id=s2, got %v", body["target_sequence_id"]) + } + json.NewEncoder(w).Encode(result) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "sequence", "migrate-instance", "--instance", "i1", "--target-sequence", "s2", "--json") + + var out orch8.TaskInstance + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.ID != "i1" { + t.Errorf("expected ID=i1, got %s", out.ID) + } + if out.SequenceID != "s2" { + t.Errorf("expected SequenceID=s2, got %s", out.SequenceID) + } +} + +// --------------------------------------------------------------------------- +// Rollback policy commands +// --------------------------------------------------------------------------- + +func TestRollbackPolicyList(t *testing.T) { + policies := []orch8.RollbackPolicy{ + { + ID: 1, + TenantID: "t1", + SequenceName: "my-seq", + ErrorRateThreshold: 0.5, + TimeWindowSecs: 300, + Enabled: true, + CooldownSecs: 60, + ConfirmationWindowSecs: 30, + CreatedAt: "2025-01-01T00:00:00Z", + UpdatedAt: "2025-01-01T00:00:00Z", + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rollback-policies" && r.Method == http.MethodGet { + if q := r.URL.Query().Get("tenant_id"); q != "t1" { + t.Errorf("expected tenant_id=t1, got %s", q) + } + json.NewEncoder(w).Encode(policies) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "rollback-policy", "list", "--json") + + var out []orch8.RollbackPolicy + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if len(out) != 1 { + t.Fatalf("expected 1 policy, got %d", len(out)) + } + if out[0].SequenceName != "my-seq" { + t.Errorf("expected SequenceName=my-seq, got %s", out[0].SequenceName) + } + if out[0].ErrorRateThreshold != 0.5 { + t.Errorf("expected ErrorRateThreshold=0.5, got %f", out[0].ErrorRateThreshold) + } +} + +func TestRollbackPolicyGet(t *testing.T) { + policy := orch8.RollbackPolicy{ + ID: 1, + TenantID: "t1", + SequenceName: "my-seq", + ErrorRateThreshold: 0.5, + TimeWindowSecs: 300, + Enabled: true, + CooldownSecs: 60, + ConfirmationWindowSecs: 30, + CreatedAt: "2025-01-01T00:00:00Z", + UpdatedAt: "2025-01-01T00:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rollback-policies/my-seq" && r.Method == http.MethodGet { + json.NewEncoder(w).Encode(policy) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "rbp", "get", "my-seq", "--json") + + var out orch8.RollbackPolicy + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.SequenceName != "my-seq" { + t.Errorf("expected SequenceName=my-seq, got %s", out.SequenceName) + } + if out.Enabled != true { + t.Errorf("expected Enabled=true, got %v", out.Enabled) + } +} + +func TestRollbackPolicyCreate(t *testing.T) { + created := orch8.RollbackPolicy{ + ID: 1, + TenantID: "t1", + SequenceName: "my-seq", + ErrorRateThreshold: 0.5, + TimeWindowSecs: 300, + Enabled: true, + CreatedAt: "2025-01-01T00:00:00Z", + UpdatedAt: "2025-01-01T00:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rollback-policies" && r.Method == http.MethodPost { + body := decodeBody(t, r) + if body["tenant_id"] != "t1" { + t.Errorf("expected tenant_id=t1, got %v", body["tenant_id"]) + } + if body["sequence_name"] != "my-seq" { + t.Errorf("expected sequence_name=my-seq, got %v", body["sequence_name"]) + } + if body["error_rate_threshold"] != 0.5 { + t.Errorf("expected error_rate_threshold=0.5, got %v", body["error_rate_threshold"]) + } + tw, ok := body["time_window_secs"].(float64) + if !ok || int(tw) != 300 { + t.Errorf("expected time_window_secs=300, got %v", body["time_window_secs"]) + } + json.NewEncoder(w).Encode(created) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "rbp", "create", "--sequence", "my-seq", "--threshold", "0.5", "--time-window", "300", "--json") + + var out orch8.RollbackPolicy + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.ID != 1 { + t.Errorf("expected ID=1, got %d", out.ID) + } + if out.SequenceName != "my-seq" { + t.Errorf("expected SequenceName=my-seq, got %s", out.SequenceName) + } +} + +func TestRollbackPolicyDelete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rollback-policies/my-seq" && r.Method == http.MethodDelete { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, false) + defer cleanup() + + getStdout := captureStdout(t) + + run(t, buf, "rbp", "delete", "my-seq") + + got := strings.TrimSpace(getStdout()) + if got != "Deleted: my-seq" { + t.Errorf("expected %q, got %q", "Deleted: my-seq", got) + } +} + +// --------------------------------------------------------------------------- +// Session commands +// --------------------------------------------------------------------------- + +func TestSessionUpdateData(t *testing.T) { + session := orch8.Session{ + ID: "sess1", + TenantID: "t1", + SessionKey: "sk1", + State: "active", + CreatedAt: "2025-01-01T00:00:00Z", + UpdatedAt: "2025-01-01T00:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/sessions/sess1/data" && r.Method == http.MethodPatch { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("reading body: %v", err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("decoding body: %v", err) + } + if m["key"] != "val" { + t.Errorf("expected key=val, got %v", m["key"]) + } + json.NewEncoder(w).Encode(session) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "session", "update-data", "sess1", "--data", `{"key":"val"}`, "--json") + + var out orch8.Session + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.ID != "sess1" { + t.Errorf("expected ID=sess1, got %s", out.ID) + } + if out.State != "active" { + t.Errorf("expected State=active, got %s", out.State) + } +} + +// --------------------------------------------------------------------------- +// Plugin commands +// --------------------------------------------------------------------------- + +func TestPluginUpdate(t *testing.T) { + plugin := orch8.PluginDef{ + Name: "my-plugin", + PluginType: "webhook", + Source: "https://example.com/hook", + TenantID: "t1", + Enabled: false, + CreatedAt: "2025-01-01T00:00:00Z", + UpdatedAt: "2025-01-01T00:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/plugins/my-plugin" && r.Method == http.MethodPatch { + body := decodeBody(t, r) + if body["enabled"] != false { + t.Errorf("expected enabled=false, got %v", body["enabled"]) + } + json.NewEncoder(w).Encode(plugin) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + tmpFile := writeTempJSON(t, map[string]any{"enabled": false}) + defer os.Remove(tmpFile) + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "plugin", "update", "my-plugin", "--file", tmpFile, "--json") + + var out orch8.PluginDef + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.Name != "my-plugin" { + t.Errorf("expected Name=my-plugin, got %s", out.Name) + } + if out.Enabled != false { + t.Errorf("expected Enabled=false, got %v", out.Enabled) + } +} + +// --------------------------------------------------------------------------- +// Credential commands +// --------------------------------------------------------------------------- + +func TestCredentialUpdate(t *testing.T) { + cred := orch8.Credential{ + ID: "cred1", + TenantID: "t1", + Name: "new-name", + CredentialType: "api_key", + CreatedAt: "2025-01-01T00:00:00Z", + UpdatedAt: "2025-01-01T00:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/credentials/cred1" && r.Method == http.MethodPatch { + body := decodeBody(t, r) + if body["name"] != "new-name" { + t.Errorf("expected name=new-name, got %v", body["name"]) + } + json.NewEncoder(w).Encode(cred) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + tmpFile := writeTempJSON(t, map[string]any{"name": "new-name"}) + defer os.Remove(tmpFile) + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "credential", "update", "cred1", "--file", tmpFile, "--json") + + var out orch8.Credential + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.ID != "cred1" { + t.Errorf("expected ID=cred1, got %s", out.ID) + } + if out.Name != "new-name" { + t.Errorf("expected Name=new-name, got %s", out.Name) + } +} + +// --------------------------------------------------------------------------- +// Pool resource commands +// --------------------------------------------------------------------------- + +func TestPoolAddResource(t *testing.T) { + resource := orch8.PoolResource{ + ID: "res1", + PoolID: "pool1", + ResourceKey: "worker-1", + State: "available", + CreatedAt: "2025-01-01T00:00:00Z", + UpdatedAt: "2025-01-01T00:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/pools/pool1/resources" && r.Method == http.MethodPost { + body := decodeBody(t, r) + if body["resource_key"] != "worker-1" { + t.Errorf("expected resource_key=worker-1, got %v", body["resource_key"]) + } + json.NewEncoder(w).Encode(resource) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + tmpFile := writeTempJSON(t, map[string]any{ + "resource_key": "worker-1", + "state": "available", + }) + defer os.Remove(tmpFile) + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "pool", "add-resource", "pool1", "--file", tmpFile, "--json") + + var out orch8.PoolResource + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.ID != "res1" { + t.Errorf("expected ID=res1, got %s", out.ID) + } + if out.PoolID != "pool1" { + t.Errorf("expected PoolID=pool1, got %s", out.PoolID) + } +} + +func TestPoolUpdateResource(t *testing.T) { + resource := orch8.PoolResource{ + ID: "res1", + PoolID: "pool1", + ResourceKey: "worker-1", + State: "locked", + CreatedAt: "2025-01-01T00:00:00Z", + UpdatedAt: "2025-01-02T00:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/pools/pool1/resources/res1" && r.Method == http.MethodPut { + body := decodeBody(t, r) + if body["state"] != "locked" { + t.Errorf("expected state=locked, got %v", body["state"]) + } + json.NewEncoder(w).Encode(resource) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + tmpFile := writeTempJSON(t, map[string]any{ + "state": "locked", + }) + defer os.Remove(tmpFile) + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "pool", "update-resource", "pool1", "res1", "--file", tmpFile, "--json") + + var out orch8.PoolResource + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.ID != "res1" { + t.Errorf("expected ID=res1, got %s", out.ID) + } + if out.State != "locked" { + t.Errorf("expected State=locked, got %s", out.State) + } +} + +func TestPoolDeleteResource(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/pools/pool1/resources/res1" && r.Method == http.MethodDelete { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, false) + defer cleanup() + + getStdout := captureStdout(t) + + run(t, buf, "pool", "delete-resource", "pool1", "res1") + + got := strings.TrimSpace(getStdout()) + if got != "Deleted: res1" { + t.Errorf("expected %q, got %q", "Deleted: res1", got) + } +} + +// --------------------------------------------------------------------------- +// Circuit breaker commands (per-tenant) +// --------------------------------------------------------------------------- + +func TestCircuitBreakerListTenant(t *testing.T) { + breakers := []orch8.CircuitBreakerState{ + { + Handler: "my-handler", + State: "closed", + FailureCount: 0, + LastFailure: "", + }, + { + Handler: "other-handler", + State: "open", + FailureCount: 5, + LastFailure: "2025-01-01T00:00:00Z", + }, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/tenants/t1/circuit-breakers" && r.Method == http.MethodGet { + json.NewEncoder(w).Encode(breakers) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "cb", "list", "--json") + + var out []orch8.CircuitBreakerState + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if len(out) != 2 { + t.Fatalf("expected 2 circuit breakers, got %d", len(out)) + } + if out[0].Handler != "my-handler" { + t.Errorf("expected Handler=my-handler, got %s", out[0].Handler) + } + if out[1].State != "open" { + t.Errorf("expected State=open, got %s", out[1].State) + } +} + +func TestCircuitBreakerGetTenant(t *testing.T) { + breaker := orch8.CircuitBreakerState{ + Handler: "my-handler", + State: "open", + FailureCount: 3, + LastFailure: "2025-01-01T00:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/tenants/t1/circuit-breakers/my-handler" && r.Method == http.MethodGet { + json.NewEncoder(w).Encode(breaker) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "cb", "get", "my-handler", "--json") + + var out orch8.CircuitBreakerState + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.Handler != "my-handler" { + t.Errorf("expected Handler=my-handler, got %s", out.Handler) + } + if out.FailureCount != 3 { + t.Errorf("expected FailureCount=3, got %d", out.FailureCount) + } +} + +func TestCircuitBreakerResetTenant(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/tenants/t1/circuit-breakers/my-handler/reset" && r.Method == http.MethodPost { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, false) + defer cleanup() + + getStdout := captureStdout(t) + + run(t, buf, "cb", "reset", "my-handler") + + got := strings.TrimSpace(getStdout()) + if got != "Reset: my-handler" { + t.Errorf("expected %q, got %q", "Reset: my-handler", got) + } +} + +// --------------------------------------------------------------------------- +// Approval commands +// --------------------------------------------------------------------------- + +func TestApprovalListRich(t *testing.T) { + response := orch8.ApprovalsResponse{ + Items: []orch8.ApprovalItem{ + { + InstanceID: "i1", + TenantID: "t1", + Namespace: "default", + SequenceID: "s1", + SequenceName: "my-seq", + BlockID: "step-approve", + Prompt: "Please approve this deployment", + Choices: []orch8.HumanChoice{}, + WaitingSince: "2025-01-01T00:00:00Z", + Deadline: nil, + AllowComment: false, + }, + }, + Total: 1, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/approvals" && r.Method == http.MethodGet { + if q := r.URL.Query().Get("tenant_id"); q != "t1" { + t.Errorf("expected tenant_id=t1, got %s", q) + } + json.NewEncoder(w).Encode(response) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + buf, cleanup := setupTest(srv.URL, true) + defer cleanup() + + run(t, buf, "approval", "list", "--json") + + var out orch8.ApprovalsResponse + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v", err) + } + if out.Total != 1 { + t.Errorf("expected Total=1, got %d", out.Total) + } + if len(out.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(out.Items)) + } + item := out.Items[0] + if item.InstanceID != "i1" { + t.Errorf("expected InstanceID=i1, got %s", item.InstanceID) + } + if item.SequenceName != "my-seq" { + t.Errorf("expected SequenceName=my-seq, got %s", item.SequenceName) + } + if item.BlockID != "step-approve" { + t.Errorf("expected BlockID=step-approve, got %s", item.BlockID) + } + if item.Prompt != "Please approve this deployment" { + t.Errorf("expected Prompt=%q, got %q", "Please approve this deployment", item.Prompt) + } + if item.AllowComment != false { + t.Errorf("expected AllowComment=false, got %v", item.AllowComment) + } +} diff --git a/cmd/instance_new_test.go b/cmd/instance_new_test.go new file mode 100644 index 0000000..e3ee7f1 --- /dev/null +++ b/cmd/instance_new_test.go @@ -0,0 +1,551 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + + "github.com/orch8-io/cli/internal/output" + orch8 "github.com/orch8-io/sdk-go" +) + +func TestInstanceUpdateContext(t *testing.T) { + var gotBody map[string]any + var gotMethod, gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = false + + buf := new(bytes.Buffer) + output.Out = buf + defer func() { output.Out = os.Stdout }() + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"instance", "update-context", "abc123", "--context", `{"foo":"bar"}`}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotMethod != http.MethodPatch { + t.Errorf("expected PATCH, got %s", gotMethod) + } + if gotPath != "/instances/abc123/context" { + t.Errorf("expected /instances/abc123/context, got %s", gotPath) + } + + ctxVal, ok := gotBody["context"].(map[string]any) + if !ok { + t.Fatalf("expected context to be a map, got %T: %v", gotBody["context"], gotBody) + } + if ctxVal["foo"] != "bar" { + t.Errorf("expected context.foo=bar, got %v", ctxVal["foo"]) + } +} + +func TestInstanceBatchCreate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances/batch" && r.Method == http.MethodPost { + json.NewEncoder(w).Encode(orch8.BatchCreateResponse{Created: 3}) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + tmpFile, err := os.CreateTemp("", "batch-*.json") + if err != nil { + t.Fatalf("creating temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + batchData := []map[string]any{ + {"sequence_id": "seq1", "tenant_id": "t1"}, + {"sequence_id": "seq2", "tenant_id": "t1"}, + {"sequence_id": "seq3", "tenant_id": "t1"}, + } + json.NewEncoder(tmpFile).Encode(batchData) + tmpFile.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = true + + buf := new(bytes.Buffer) + output.Out = buf + defer func() { output.Out = os.Stdout }() + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"instance", "batch-create", "--file", tmpFile.Name()}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var out orch8.BatchCreateResponse + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v (raw: %q)", err, buf.String()) + } + if out.Created != 3 { + t.Errorf("expected Created=3, got %d", out.Created) + } +} + +func TestInstanceCheckpoints(t *testing.T) { + checkpoints := []orch8.Checkpoint{ + {ID: "cp1", InstanceID: "i1", CreatedAt: "2025-01-01T00:00:00Z"}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances/i1/checkpoints" && r.Method == http.MethodGet { + json.NewEncoder(w).Encode(checkpoints) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = true + + buf := new(bytes.Buffer) + output.Out = buf + defer func() { output.Out = os.Stdout }() + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"instance", "checkpoints", "i1", "--json"}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var out []orch8.Checkpoint + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v (raw: %q)", err, buf.String()) + } + if len(out) != 1 { + t.Fatalf("expected 1 checkpoint, got %d", len(out)) + } + if out[0].ID != "cp1" { + t.Errorf("expected ID=cp1, got %s", out[0].ID) + } + if out[0].InstanceID != "i1" { + t.Errorf("expected InstanceID=i1, got %s", out[0].InstanceID) + } +} + +func TestInstanceCheckpointSave(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances/i1/checkpoints" && r.Method == http.MethodPost { + json.NewDecoder(r.Body).Decode(&gotBody) + json.NewEncoder(w).Encode(orch8.Checkpoint{ + ID: "cp-new", + InstanceID: "i1", + CreatedAt: "2025-06-01T00:00:00Z", + }) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = true + + buf := new(bytes.Buffer) + output.Out = buf + defer func() { output.Out = os.Stdout }() + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"instance", "checkpoint-save", "i1", "--data", `{"state":"saved"}`, "--json"}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var out orch8.Checkpoint + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v (raw: %q)", err, buf.String()) + } + if out.ID != "cp-new" { + t.Errorf("expected ID=cp-new, got %s", out.ID) + } + if out.InstanceID != "i1" { + t.Errorf("expected InstanceID=i1, got %s", out.InstanceID) + } +} + +func TestInstanceCheckpointLatest(t *testing.T) { + cp := orch8.Checkpoint{ + ID: "cp-latest", + InstanceID: "i1", + CheckpointData: map[string]any{"step": "final"}, + CreatedAt: "2025-06-01T12:00:00Z", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances/i1/checkpoints/latest" && r.Method == http.MethodGet { + json.NewEncoder(w).Encode(cp) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = true + + buf := new(bytes.Buffer) + output.Out = buf + defer func() { output.Out = os.Stdout }() + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + // checkpoint-latest always outputs JSON regardless of --json flag + rootCmd.SetArgs([]string{"instance", "checkpoint-latest", "i1"}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var out orch8.Checkpoint + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v (raw: %q)", err, buf.String()) + } + if out.ID != "cp-latest" { + t.Errorf("expected ID=cp-latest, got %s", out.ID) + } + if out.InstanceID != "i1" { + t.Errorf("expected InstanceID=i1, got %s", out.InstanceID) + } +} + +func TestInstanceCheckpointPrune(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances/i1/checkpoints/prune" && r.Method == http.MethodPost { + json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = false + + buf := new(bytes.Buffer) + output.Out = buf + defer func() { output.Out = os.Stdout }() + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"instance", "checkpoint-prune", "i1", "--keep", "5"}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + keepVal, ok := gotBody["keep"] + if !ok { + t.Fatalf("expected keep in request body, got %v", gotBody) + } + // JSON numbers decode as float64 + if keepVal.(float64) != 5 { + t.Errorf("expected keep=5, got %v", keepVal) + } +} + +func TestInstanceInjectBlocks(t *testing.T) { + var gotBody any + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.URL.Path == "/instances/i1/inject-blocks" && r.Method == http.MethodPost { + json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + tmpFile, err := os.CreateTemp("", "blocks-*.json") + if err != nil { + t.Fatalf("creating temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + blocks := []map[string]any{ + {"type": "step", "handler": "send_email"}, + {"type": "step", "handler": "notify_slack"}, + } + json.NewEncoder(tmpFile).Encode(blocks) + tmpFile.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = false + + buf := new(bytes.Buffer) + output.Out = buf + defer func() { output.Out = os.Stdout }() + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"instance", "inject-blocks", "i1", "--file", tmpFile.Name()}) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotPath != "/instances/i1/inject-blocks" { + t.Errorf("expected /instances/i1/inject-blocks, got %s", gotPath) + } + if gotBody == nil { + t.Fatal("expected request body, got nil") + } +} + +func TestInstanceBulkState(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances/bulk/state" && r.Method == http.MethodPatch { + json.NewDecoder(r.Body).Decode(&gotBody) + json.NewEncoder(w).Encode(orch8.BulkResponse{Updated: 10}) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = true + + buf := new(bytes.Buffer) + output.Out = buf + defer func() { output.Out = os.Stdout }() + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{ + "instance", "bulk-state", + "--state", "Cancelled", + "--filter-tenant", "t1", + "--filter-states", "Failed,Pending", + "--json", + }) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify request body + if gotBody["new_state"] != "Cancelled" { + t.Errorf("expected new_state=Cancelled, got %v", gotBody["new_state"]) + } + filter, ok := gotBody["filter"].(map[string]any) + if !ok { + t.Fatalf("expected filter to be a map, got %T: %v", gotBody["filter"], gotBody) + } + if filter["tenant_id"] != "t1" { + t.Errorf("expected filter.tenant_id=t1, got %v", filter["tenant_id"]) + } + states, ok := filter["states"].([]any) + if !ok { + t.Fatalf("expected filter.states to be an array, got %T: %v", filter["states"], filter["states"]) + } + if len(states) != 2 { + t.Fatalf("expected 2 states, got %d", len(states)) + } + if states[0] != "Failed" || states[1] != "Pending" { + t.Errorf("expected states [Failed, Pending], got %v", states) + } + + // Verify output + var out orch8.BulkResponse + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v (raw: %q)", err, buf.String()) + } + if out.Updated != 10 { + t.Errorf("expected Updated=10, got %d", out.Updated) + } +} + +func TestInstanceBulkReschedule(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances/bulk/reschedule" && r.Method == http.MethodPatch { + json.NewDecoder(r.Body).Decode(&gotBody) + json.NewEncoder(w).Encode(orch8.BulkResponse{Updated: 5}) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = true + + buf := new(bytes.Buffer) + output.Out = buf + defer func() { output.Out = os.Stdout }() + + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{ + "instance", "bulk-reschedule", + "--offset", "3600", + "--filter-namespace", "prod", + "--json", + }) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify request body + if gotBody["offset_secs"].(float64) != 3600 { + t.Errorf("expected offset_secs=3600, got %v", gotBody["offset_secs"]) + } + filter, ok := gotBody["filter"].(map[string]any) + if !ok { + t.Fatalf("expected filter to be a map, got %T: %v", gotBody["filter"], gotBody) + } + if filter["namespace"] != "prod" { + t.Errorf("expected filter.namespace=prod, got %v", filter["namespace"]) + } + + // Verify output + var out orch8.BulkResponse + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("parsing output: %v (raw: %q)", err, buf.String()) + } + if out.Updated != 5 { + t.Errorf("expected Updated=5, got %d", out.Updated) + } +} + +func TestInstanceStream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/instances/i1/stream" && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + if !ok { + t.Error("expected ResponseWriter to be a Flusher") + return + } + + fmt.Fprintf(w, "data: {\"state\":\"running\"}\n\n") + flusher.Flush() + fmt.Fprintf(w, "data: {\"state\":\"completed\"}\n\n") + flusher.Flush() + // Server closes the connection by returning + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + flagURL = srv.URL + flagTenantID = "t1" + flagJSON = false + + // The stream command writes directly to os.Stdout via json.NewEncoder(os.Stdout), + // so we need to capture os.Stdout with a pipe. + origStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("creating pipe: %v", err) + } + os.Stdout = w + + outBuf := new(bytes.Buffer) + output.Out = outBuf + defer func() { + os.Stdout = origStdout + output.Out = os.Stdout + }() + + rootCmd.SetOut(outBuf) + rootCmd.SetErr(outBuf) + rootCmd.SetArgs([]string{"instance", "stream", "i1"}) + + var wg sync.WaitGroup + var captured bytes.Buffer + + wg.Add(1) + go func() { + defer wg.Done() + // Read all output from the pipe until it is closed + captured.ReadFrom(r) + }() + + execErr := rootCmd.Execute() + + // Close the write end so the reader goroutine finishes + w.Close() + wg.Wait() + + // Restore stdout before any further t.Log/t.Error calls + os.Stdout = origStdout + + if execErr != nil { + t.Fatalf("unexpected error: %v", execErr) + } + + lines := strings.Split(strings.TrimSpace(captured.String()), "\n") + if len(lines) < 2 { + t.Fatalf("expected at least 2 JSON lines, got %d: %q", len(lines), captured.String()) + } + + var event1, event2 map[string]any + if err := json.Unmarshal([]byte(lines[0]), &event1); err != nil { + t.Fatalf("parsing first event: %v (line: %q)", err, lines[0]) + } + if err := json.Unmarshal([]byte(lines[1]), &event2); err != nil { + t.Fatalf("parsing second event: %v (line: %q)", err, lines[1]) + } + + if event1["state"] != "running" { + t.Errorf("expected first event state=running, got %v", event1["state"]) + } + if event2["state"] != "completed" { + t.Errorf("expected second event state=completed, got %v", event2["state"]) + } +}