diff --git a/cmd/workflow/resume.go b/cmd/workflow/resume.go new file mode 100644 index 0000000..3eb0d68 --- /dev/null +++ b/cmd/workflow/resume.go @@ -0,0 +1,100 @@ +package workflow + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/jedib0t/go-pretty/v6/table" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/internal/output" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewResumeCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "resume ", + Aliases: []string{"activate"}, + Short: "Resume a paused workflow", + Args: cobra.ExactArgs(1), + Example: ` # Resume a workflow (will prompt for confirmation) + kh wf resume abc123 + + # Resume without prompting + kh wf resume abc123 --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + workflowID := args[0] + + yes, err := cmd.Flags().GetBool("yes") + if err != nil { + // --yes not defined locally; check parent persistent flags + yes = false + } + + // Resuming restarts automation that can submit transactions, so it + // is confirmed like pause rather than treated as a safe no-op. + if !yes && f.IOStreams.IsTerminal() { + fmt.Fprintf(f.IOStreams.Out, "Resume workflow %s? (y/N) ", workflowID) + scanner := bufio.NewScanner(f.IOStreams.In) + if scanner.Scan() { + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + return cmdutil.CancelError{Err: fmt.Errorf("resume cancelled")} + } + } + } + // Non-TTY or --yes: proceed without prompting + + client, err := f.HTTPClient() + if err != nil { + return err + } + cfg, err := f.Config() + if err != nil { + return err + } + host := cmdutil.ResolveHost(cmd, cfg) + + bodyBytes, err := json.Marshal(map[string]bool{"enabled": true}) + if err != nil { + return err + } + + url := khhttp.BuildBaseURL(host) + "/api/workflows/" + workflowID + req, err := client.NewRequest(http.MethodPatch, url, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return khhttp.NewAPIError(resp) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decoding resume response: %w", err) + } + + p := output.NewPrinter(f.IOStreams, cmd) + return p.PrintData(result, func(tw table.Writer) { + fmt.Fprintf(f.IOStreams.Out, "Workflow %s resumed\n", workflowID) + tw.Render() + }) + }, + } + + cmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/cmd/workflow/resume_test.go b/cmd/workflow/resume_test.go new file mode 100644 index 0000000..d027382 --- /dev/null +++ b/cmd/workflow/resume_test.go @@ -0,0 +1,124 @@ +package workflow_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/keeperhub/cli/cmd/workflow" + "github.com/keeperhub/cli/internal/config" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/keeperhub/cli/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newResumeFactory(ios *iostreams.IOStreams, svr *httptest.Server) *cmdutil.Factory { + return &cmdutil.Factory{ + AppVersion: "1.0.0", + IOStreams: ios, + Config: func() (config.Config, error) { + return config.Config{DefaultHost: svr.URL}, nil + }, + HTTPClient: func() (*khhttp.Client, error) { + return khhttp.NewClient(khhttp.ClientOptions{ + AppVersion: "1.0.0", + IOStreams: ios, + }), nil + }, + } +} + +func runResumeViaParent(f *cmdutil.Factory, args []string) error { + parent := workflow.NewWorkflowCmd(f) + parent.SetArgs(append([]string{"resume"}, args...)) + return parent.Execute() +} + +func TestResumeSendsPATCHWithEnabledTrue(t *testing.T) { + var receivedMethod, receivedPath string + var receivedBody map[string]interface{} + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + receivedPath = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&receivedBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"wf-abc","enabled":true}`)) + })) + defer svr.Close() + + // Non-TTY: auto-skips confirmation + ios, outBuf, _, _ := iostreams.Test() + f := newResumeFactory(ios, svr) + + err := runResumeViaParent(f, []string{"wf-abc"}) + + require.NoError(t, err) + assert.Equal(t, "PATCH", receivedMethod) + assert.Equal(t, "/api/workflows/wf-abc", receivedPath) + // The inverse of pause: this is the assertion that distinguishes the two. + assert.Equal(t, true, receivedBody["enabled"]) + assert.Contains(t, outBuf.String(), "resumed") +} + +func TestResumeYesFlagSkipsConfirmation(t *testing.T) { + var patchCalled bool + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PATCH" { + patchCalled = true + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"wf-abc","enabled":true}`)) + })) + defer svr.Close() + + ios, _, _, _ := iostreams.Test() + f := newResumeFactory(ios, svr) + + err := runResumeViaParent(f, []string{"wf-abc", "--yes"}) + + require.NoError(t, err) + assert.True(t, patchCalled, "PATCH should have been called") +} + +func TestResumeActivateAlias(t *testing.T) { + var patchCalled bool + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "PATCH" { + patchCalled = true + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"wf-abc","enabled":true}`)) + })) + defer svr.Close() + + ios, _, _, _ := iostreams.Test() + f := newResumeFactory(ios, svr) + + parent := workflow.NewWorkflowCmd(f) + parent.SetArgs([]string{"activate", "wf-abc", "--yes"}) + + require.NoError(t, parent.Execute()) + assert.True(t, patchCalled, "PATCH should have been called via the activate alias") +} + +func TestResumeAPIErrorSurfaces(t *testing.T) { + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"Workflow not found"}`)) + })) + defer svr.Close() + + ios, _, _, _ := iostreams.Test() + f := newResumeFactory(ios, svr) + + err := runResumeViaParent(f, []string{"wf-missing", "--yes"}) + + require.Error(t, err) +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go index 3d2d175..ecf32d7 100644 --- a/cmd/workflow/workflow.go +++ b/cmd/workflow/workflow.go @@ -28,6 +28,7 @@ func NewWorkflowCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewUpdateCmd(f)) cmd.AddCommand(NewGoLiveCmd(f)) cmd.AddCommand(NewPauseCmd(f)) + cmd.AddCommand(NewResumeCmd(f)) return cmd } diff --git a/cmd/workflow/workflow_test.go b/cmd/workflow/workflow_test.go index 7009b11..2fd9a99 100644 --- a/cmd/workflow/workflow_test.go +++ b/cmd/workflow/workflow_test.go @@ -19,11 +19,11 @@ func newTestFactory() *cmdutil.Factory { } } -func TestWorkflowCmdHas8Subcommands(t *testing.T) { +func TestWorkflowCmdHas9Subcommands(t *testing.T) { f := newTestFactory() wfCmd := workflow.NewWorkflowCmd(f) cmds := wfCmd.Commands() - assert.Equal(t, 8, len(cmds), "expected 8 subcommands: list, run, get, go-live, pause, create, delete, update") + assert.Equal(t, 9, len(cmds), "expected 9 subcommands: list, run, get, go-live, pause, resume, create, delete, update") } func TestWorkflowCmdHasAlias(t *testing.T) {