Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 112 additions & 34 deletions cmd/workflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,10 @@ func runWorkflowsNodeAddBranch(cfg *config.Config, workflowID, nodeID string, re
return newAPIClient(cfg).AddWorkflowBranch(workflowID, nodeID, req)
}

func runWorkflowsNodeReroute(cfg *config.Config, workflowID, nodeID string, req loops.RerouteNodeConnectionRequest) (*loops.RerouteNodeConnectionResponse, error) {
return newAPIClient(cfg).RerouteNodeConnection(workflowID, nodeID, req)
}

func runWorkflowsNodeDelete(cfg *config.Config, workflowID, nodeID string, recursive bool, req loops.DeleteWorkflowNodeRequest) (*loops.DeleteWorkflowNodeResponse, error) {
client := newAPIClient(cfg)
if recursive {
Expand All @@ -484,7 +488,14 @@ func printChangeMailingListResponse(cmd *cobra.Command, r *loops.ChangeWorkflowM
t.Row("mailingListId", deref(r.MailingListID))
t.Row("workflowRevisionId", deref(r.WorkflowRevisionID))
t.Row("queuedContactCount", formatFloat(r.QueuedContactCount))
return t.Render()
if err := t.Render(); err != nil {
return err
}
if r.Workflow == nil {
return nil
}
fmt.Fprintln(cmd.OutOrStdout())
return printSimplifiedWorkflow(cmd, r.Workflow)
}

func printDeleteNodeResponse(cmd *cobra.Command, r *loops.DeleteWorkflowNodeResponse) error {
Expand All @@ -493,7 +504,14 @@ func printDeleteNodeResponse(cmd *cobra.Command, r *loops.DeleteWorkflowNodeResp
t.Row("nodeIds", strings.Join(r.NodeIDs, ", "))
t.Row("workflowRevisionId", deref(r.WorkflowRevisionID))
t.Row("queuedContactCount", formatFloat(r.QueuedContactCount))
return t.Render()
if err := t.Render(); err != nil {
return err
}
if r.Workflow == nil {
return nil
}
fmt.Fprintln(cmd.OutOrStdout())
return printSimplifiedWorkflow(cmd, r.Workflow)
}

var workflowsCreateCmd = &cobra.Command{
Expand Down Expand Up @@ -601,47 +619,69 @@ var workflowsChangeMailingListCmd = &cobra.Command{
},
}

// parseCreateWorkflowNodeFlags reads and validates the `workflows nodes create`
// flags and builds a CreateWorkflowNodeRequest. Placement depends on insert
// mode: "between" needs --from-node-id and --to-node-id; "before" inserts
// before --before-node-id; "after" inserts after --from-node-id (valid only
// when that node has exactly one outgoing connection). "before" is sent as
// toNodeId because the API's beforeNodeId field is deprecated.
func parseCreateWorkflowNodeFlags(cmd *cobra.Command) (loops.CreateWorkflowNodeRequest, error) {
nodeType, _ := cmd.Flags().GetString("node-type")
insertMode, _ := cmd.Flags().GetString("insert-mode")
fromNodeID, _ := cmd.Flags().GetString("from-node-id")
toNodeID, _ := cmd.Flags().GetString("to-node-id")
beforeNodeID, _ := cmd.Flags().GetString("before-node-id")

if !slices.Contains(createWorkflowNodeTypes, nodeType) {
return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--node-type must be one of: %s", strings.Join(createWorkflowNodeTypes, ", "))
}

req := loops.CreateWorkflowNodeRequest{
ExpectedRevisionID: readExpectedRevisionID(cmd),
InsertMode: insertMode,
NodeTypeName: nodeType,
}

switch insertMode {
case loops.WorkflowInsertModeBetween:
if fromNodeID == "" || toNodeID == "" {
return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--insert-mode between requires --from-node-id and --to-node-id")
}
req.FromNodeID = fromNodeID
req.ToNodeID = toNodeID
case loops.WorkflowInsertModeBefore:
if beforeNodeID == "" {
return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--insert-mode before requires --before-node-id")
}
req.ToNodeID = beforeNodeID
case loops.WorkflowInsertModeAfter:
if fromNodeID == "" {
return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--insert-mode after requires --from-node-id")
}
req.FromNodeID = fromNodeID
default:
return loops.CreateWorkflowNodeRequest{}, fmt.Errorf("--insert-mode must be %q, %q, or %q", loops.WorkflowInsertModeBetween, loops.WorkflowInsertModeBefore, loops.WorkflowInsertModeAfter)
}

return req, nil
}

var workflowsNodesCreateCmd = &cobra.Command{
Use: "create <workflow-id>",
Short: "Create a workflow node",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
nodeType, _ := cmd.Flags().GetString("node-type")
insertMode, _ := cmd.Flags().GetString("insert-mode")
fromNodeID, _ := cmd.Flags().GetString("from-node-id")
toNodeID, _ := cmd.Flags().GetString("to-node-id")
beforeNodeID, _ := cmd.Flags().GetString("before-node-id")

if !slices.Contains(createWorkflowNodeTypes, nodeType) {
return fmt.Errorf("--node-type must be one of: %s", strings.Join(createWorkflowNodeTypes, ", "))
}

switch insertMode {
case loops.WorkflowInsertModeBetween:
if fromNodeID == "" || toNodeID == "" {
return fmt.Errorf("--insert-mode between requires --from-node-id and --to-node-id")
}
case loops.WorkflowInsertModeBefore:
if beforeNodeID == "" {
return fmt.Errorf("--insert-mode before requires --before-node-id")
}
default:
return fmt.Errorf("--insert-mode must be %q or %q", loops.WorkflowInsertModeBetween, loops.WorkflowInsertModeBefore)
req, err := parseCreateWorkflowNodeFlags(cmd)
if err != nil {
return err
}

cfg, err := loadConfig()
if err != nil {
return err
}

resp, err := runWorkflowsNodeCreate(cfg, args[0], loops.CreateWorkflowNodeRequest{
ExpectedRevisionID: readExpectedRevisionID(cmd),
InsertMode: insertMode,
NodeTypeName: nodeType,
FromNodeID: fromNodeID,
ToNodeID: toNodeID,
BeforeNodeID: beforeNodeID,
})
resp, err := runWorkflowsNodeCreate(cfg, args[0], req)
if err != nil {
return err
}
Expand Down Expand Up @@ -688,7 +728,11 @@ var workflowsNodesUpdateCmd = &cobra.Command{
t.Row("typeName", node.TypeName)
t.Row("nodeId", mutationNodeID(&node.WorkflowMutationNode))
t.Row("workflowRevisionId", node.WorkflowRevisionID)
return t.Render()
if err := t.Render(); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout())
return printSimplifiedWorkflow(cmd, &node.Workflow)
},
}

Expand Down Expand Up @@ -718,6 +762,35 @@ var workflowsNodesAddBranchCmd = &cobra.Command{
},
}

var workflowsNodesRerouteCmd = &cobra.Command{
Use: "reroute <workflow-id> <node-id>",
Short: "Reroute a node's outgoing connection to a new target",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
newTargetNodeID, _ := cmd.Flags().GetString("new-target-node-id")

cfg, err := loadConfig()
if err != nil {
return err
}

resp, err := runWorkflowsNodeReroute(cfg, args[0], args[1], loops.RerouteNodeConnectionRequest{
ExpectedRevisionID: readExpectedRevisionID(cmd),
NewTargetNodeID: newTargetNodeID,
})
if err != nil {
return err
}

if isJSONOutput() {
return printJSON(cmd.OutOrStdout(), resp)
}

fmt.Fprintf(cmd.OutOrStdout(), "Rerouted node. (revision: %s)\n\n", resp.WorkflowRevisionID)
return printSimplifiedWorkflow(cmd, &resp.Workflow)
},
}

var workflowsNodesDeleteCmd = &cobra.Command{
Use: "delete <workflow-id> <node-id>",
Short: "Delete a workflow node",
Expand Down Expand Up @@ -835,8 +908,8 @@ func init() {
workflowsNodesCmd.AddCommand(workflowsNodesGetCmd)

workflowsNodesCreateCmd.Flags().String("node-type", "", fmt.Sprintf("Node type: %s", strings.Join(createWorkflowNodeTypes, ", ")))
workflowsNodesCreateCmd.Flags().String("insert-mode", "", "Insert mode: between or before")
workflowsNodesCreateCmd.Flags().String("from-node-id", "", "Source node ID (insert-mode between)")
workflowsNodesCreateCmd.Flags().String("insert-mode", "", "Insert mode: between, before, or after")
workflowsNodesCreateCmd.Flags().String("from-node-id", "", "Source node ID (insert-mode between or after)")
workflowsNodesCreateCmd.Flags().String("to-node-id", "", "Target node ID (insert-mode between)")
workflowsNodesCreateCmd.Flags().String("before-node-id", "", "Node ID to insert before (insert-mode before)")
workflowsNodesCreateCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)")
Expand All @@ -852,6 +925,11 @@ func init() {
workflowsNodesAddBranchCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)")
workflowsNodesCmd.AddCommand(workflowsNodesAddBranchCmd)

workflowsNodesRerouteCmd.Flags().String("new-target-node-id", "", "Node ID that should receive the source node's outgoing connection")
workflowsNodesRerouteCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)")
workflowsNodesRerouteCmd.MarkFlagRequired("new-target-node-id")
workflowsNodesCmd.AddCommand(workflowsNodesRerouteCmd)

workflowsNodesDeleteCmd.Flags().Bool("recursive", false, "Also delete downstream nodes")
workflowsNodesDeleteCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)")
workflowsNodesDeleteCmd.Flags().Bool("dry-run", false, "Report queued-contact impact without applying the deletion")
Expand Down
166 changes: 166 additions & 0 deletions cmd/workflows_nodes_write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,119 @@ import (
"testing"

"github.com/loops-so/loops-go"
"github.com/spf13/cobra"
)

// newCreateNodeFlagsCmd builds a command with the `workflows nodes create`
// flags registered and set from the given map, for testing
// parseCreateWorkflowNodeFlags in isolation.
func newCreateNodeFlagsCmd(t *testing.T, flags map[string]string) *cobra.Command {
t.Helper()
c := &cobra.Command{}
for _, name := range []string{"node-type", "insert-mode", "from-node-id", "to-node-id", "before-node-id", "expected-revision-id"} {
c.Flags().String(name, "", "")
}
for k, v := range flags {
if err := c.Flags().Set(k, v); err != nil {
t.Fatalf("set --%s: %v", k, err)
}
}
return c
}

func TestParseCreateWorkflowNodeFlags(t *testing.T) {
t.Run("between sets from/to node ids", func(t *testing.T) {
req, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, map[string]string{
"node-type": loops.CreateWorkflowNodeTypeTimerAction,
"insert-mode": loops.WorkflowInsertModeBetween,
"from-node-id": "n1",
"to-node-id": "n2",
}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.FromNodeID != "n1" || req.ToNodeID != "n2" {
t.Errorf("from/to = %q/%q, want n1/n2", req.FromNodeID, req.ToNodeID)
}
if req.BeforeNodeID != "" {
t.Errorf("BeforeNodeID = %q, want empty", req.BeforeNodeID)
}
})

t.Run("before maps before-node-id to ToNodeID (not BeforeNodeID)", func(t *testing.T) {
req, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, map[string]string{
"node-type": loops.CreateWorkflowNodeTypeTimerAction,
"insert-mode": loops.WorkflowInsertModeBefore,
"before-node-id": "n3",
}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.ToNodeID != "n3" {
t.Errorf("ToNodeID = %q, want n3", req.ToNodeID)
}
if req.BeforeNodeID != "" {
t.Errorf("BeforeNodeID = %q, want empty (deprecated field must not be sent)", req.BeforeNodeID)
}
if req.FromNodeID != "" {
t.Errorf("FromNodeID = %q, want empty", req.FromNodeID)
}
})

t.Run("after sets FromNodeID", func(t *testing.T) {
req, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, map[string]string{
"node-type": loops.CreateWorkflowNodeTypeTimerAction,
"insert-mode": loops.WorkflowInsertModeAfter,
"from-node-id": "n1",
}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.InsertMode != loops.WorkflowInsertModeAfter {
t.Errorf("InsertMode = %q, want after", req.InsertMode)
}
if req.FromNodeID != "n1" {
t.Errorf("FromNodeID = %q, want n1", req.FromNodeID)
}
if req.ToNodeID != "" || req.BeforeNodeID != "" {
t.Errorf("to/before = %q/%q, want empty", req.ToNodeID, req.BeforeNodeID)
}
})

t.Run("expected-revision-id is passed through when set", func(t *testing.T) {
req, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, map[string]string{
"node-type": loops.CreateWorkflowNodeTypeTimerAction,
"insert-mode": loops.WorkflowInsertModeAfter,
"from-node-id": "n1",
"expected-revision-id": "rev_1",
}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if req.ExpectedRevisionID == nil || *req.ExpectedRevisionID != "rev_1" {
t.Errorf("ExpectedRevisionID = %v, want rev_1", req.ExpectedRevisionID)
}
})

errCases := []struct {
name string
flags map[string]string
}{
{"unknown node-type", map[string]string{"node-type": "Nonsense", "insert-mode": loops.WorkflowInsertModeAfter, "from-node-id": "n1"}},
{"unknown insert-mode", map[string]string{"node-type": loops.CreateWorkflowNodeTypeTimerAction, "insert-mode": "sideways", "from-node-id": "n1"}},
{"between missing to", map[string]string{"node-type": loops.CreateWorkflowNodeTypeTimerAction, "insert-mode": loops.WorkflowInsertModeBetween, "from-node-id": "n1"}},
{"before missing before-node-id", map[string]string{"node-type": loops.CreateWorkflowNodeTypeTimerAction, "insert-mode": loops.WorkflowInsertModeBefore}},
{"after missing from-node-id", map[string]string{"node-type": loops.CreateWorkflowNodeTypeTimerAction, "insert-mode": loops.WorkflowInsertModeAfter}},
}
for _, tc := range errCases {
t.Run(tc.name+" is an error", func(t *testing.T) {
if _, err := parseCreateWorkflowNodeFlags(newCreateNodeFlagsCmd(t, tc.flags)); err == nil {
t.Error("expected error, got nil")
}
})
}
}

func TestRunWorkflowsNodeCreate(t *testing.T) {
body := `{
"node": {
Expand Down Expand Up @@ -257,6 +368,61 @@ func TestRunWorkflowsNodeAddBranch(t *testing.T) {
})
}

func TestRunWorkflowsNodeReroute(t *testing.T) {
body := `{
"typeName": "TimerAction",
"id": "node_r",
"nextNodeIds": ["n2"],
"amount": 0,
"unit": "m",
"workflowRevisionId": "rev_8",
"workflow": {
"id": "wf_1",
"name": "WF",
"status": "Draft",
"workflowRevisionId": "rev_8",
"mailingListId": null,
"rootNodeId": null,
"nodes": {}
}
}`

t.Run("posts to reroute path with new target and revision", func(t *testing.T) {
cap := serveJSONCapture(t, http.StatusOK, body)
rev := "rev_7"
resp, err := runWorkflowsNodeReroute(cfg(t), "wf_1", "node_r", loops.RerouteNodeConnectionRequest{
ExpectedRevisionID: &rev,
NewTargetNodeID: "node_new",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.WorkflowRevisionID != "rev_8" {
t.Errorf("WorkflowRevisionID = %q, want rev_8", resp.WorkflowRevisionID)
}
if resp.Workflow.ID != "wf_1" {
t.Errorf("Workflow.ID = %q, want wf_1", resp.Workflow.ID)
}
if cap.Method != http.MethodPost {
t.Errorf("Method = %q, want POST", cap.Method)
}
if cap.Path != "/workflows/wf_1/nodes/node_r/reroute" {
t.Errorf("Path = %q, want /workflows/wf_1/nodes/node_r/reroute", cap.Path)
}

var sent map[string]any
if err := json.Unmarshal(cap.Body, &sent); err != nil {
t.Fatalf("decode body: %v\nraw: %s", err, cap.Body)
}
if sent["newTargetNodeId"] != "node_new" {
t.Errorf("newTargetNodeId = %v, want node_new", sent["newTargetNodeId"])
}
if sent["expectedRevisionId"] != "rev_7" {
t.Errorf("expectedRevisionId = %v, want rev_7", sent["expectedRevisionId"])
}
})
}

func TestRunWorkflowsNodeDelete(t *testing.T) {
body := `{"status":"deleted","nodeIds":["node_x"],"workflowRevisionId":"rev_7","queuedContactCount":0}`

Expand Down
Loading