diff --git a/cmd/workflows.go b/cmd/workflows.go index d6320e6..1f942d9 100644 --- a/cmd/workflows.go +++ b/cmd/workflows.go @@ -1,7 +1,10 @@ package cmd import ( + "encoding/json" "fmt" + "os" + "slices" "sort" "strconv" "strings" @@ -107,34 +110,40 @@ var workflowsGetCmd = &cobra.Command{ return printJSON(cmd.OutOrStdout(), w) } - t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("workflowId", w.ID) - t.Row("name", w.Name) - t.Row("description", w.Description) - t.Row("emoji", w.Emoji) - t.Row("mailingListId", deref(w.MailingListID)) - t.Row("rootNodeId", deref(w.RootNodeID)) - if err := t.Render(); err != nil { - return err - } + return printSimplifiedWorkflow(cmd, w) + }, +} - if len(w.Nodes) == 0 { - return nil - } +func printSimplifiedWorkflow(cmd *cobra.Command, w *loops.SimplifiedWorkflow) error { + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("workflowId", w.ID) + t.Row("name", w.Name) + t.Row("description", w.Description) + t.Row("emoji", w.Emoji) + t.Row("status", w.Status) + t.Row("workflowRevisionId", deref(w.WorkflowRevisionID)) + t.Row("mailingListId", deref(w.MailingListID)) + t.Row("rootNodeId", deref(w.RootNodeID)) + if err := t.Render(); err != nil { + return err + } - fmt.Fprintln(cmd.OutOrStdout()) - nt := newStyledTable(cmd.OutOrStdout(), "NODE ID", "TYPE", "NEXT IDS") - ids := make([]string, 0, len(w.Nodes)) - for id := range w.Nodes { - ids = append(ids, id) - } - sort.Strings(ids) - for _, id := range ids { - n := w.Nodes[id] - nt.Row(id, n.TypeName, strings.Join(simplifiedNodeNextIDs(n), ", ")) - } - return nt.Render() - }, + if len(w.Nodes) == 0 { + return nil + } + + fmt.Fprintln(cmd.OutOrStdout()) + nt := newStyledTable(cmd.OutOrStdout(), "NODE ID", "TYPE", "NEXT IDS") + ids := make([]string, 0, len(w.Nodes)) + for id := range w.Nodes { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + n := w.Nodes[id] + nt.Row(id, n.TypeName, strings.Join(simplifiedNodeNextIDs(n), ", ")) + } + return nt.Render() } var workflowsNodesCmd = &cobra.Command{ @@ -315,12 +324,540 @@ func workflowNodeRows(n *loops.WorkflowNode) [][2]string { return rows } +// createWorkflowNodeTypes is the set of node types that can be created via +// `workflows nodes create`, per the SDK CreateWorkflowNodeType* constants. +var createWorkflowNodeTypes = []string{ + loops.CreateWorkflowNodeTypeAudienceFilter, + loops.CreateWorkflowNodeTypeBranchNode, + loops.CreateWorkflowNodeTypeExperimentBranchNode, + loops.CreateWorkflowNodeTypeTimerAction, + loops.CreateWorkflowNodeTypeSendEmailAction, + loops.CreateWorkflowNodeTypeVariantNode, +} + +// validateQueuedContactPolicy checks a --queued-contact-policy flag value. An +// empty value is allowed (the API defaults to "fail"). +func validateQueuedContactPolicy(v string) error { + switch v { + case "", loops.WorkflowQueuedContactPolicyFail, loops.WorkflowQueuedContactPolicyDiscard: + return nil + default: + return fmt.Errorf("--queued-contact-policy must be %q or %q", loops.WorkflowQueuedContactPolicyFail, loops.WorkflowQueuedContactPolicyDiscard) + } +} + +// readExpectedRevisionID reads the optional --expected-revision-id flag, +// returning nil when unset (sent as JSON null for optimistic concurrency). +func readExpectedRevisionID(cmd *cobra.Command) *string { + if !cmd.Flags().Changed("expected-revision-id") { + return nil + } + v, _ := cmd.Flags().GetString("expected-revision-id") + return &v +} + +// parseUpdateWorkflowNodePayload reads a node-update payload JSON file and +// builds a loops.UpdateWorkflowNodePayload. The file must contain a JSON object +// with a "typeName" discriminator plus the variant fields (the API wire format, +// e.g. {"typeName":"TimerAction","amount":3,"unit":"d"}). We read typeName, then +// unmarshal the same bytes into the matching *Workflow*Payload variant — the SDK +// type has a MarshalJSON but no UnmarshalJSON, so a direct decode cannot populate +// its untagged variant pointers. This mirrors the SDK's own discriminated +// UnmarshalJSON dispatch and round-trips with its MarshalJSON output. +func parseUpdateWorkflowNodePayload(path string) (loops.UpdateWorkflowNodePayload, error) { + var payload loops.UpdateWorkflowNodePayload + + data, err := os.ReadFile(path) + if err != nil { + return payload, fmt.Errorf("read --payload-file: %w", err) + } + + var head struct { + TypeName string `json:"typeName"` + } + if err := json.Unmarshal(data, &head); err != nil { + return payload, fmt.Errorf("parse --payload-file: %w", err) + } + if head.TypeName == "" { + return payload, fmt.Errorf(`--payload-file must include a "typeName" field`) + } + payload.TypeName = head.TypeName + + unmarshalInto := func(v any) error { + if err := json.Unmarshal(data, v); err != nil { + return fmt.Errorf("parse --payload-file: %w", err) + } + return nil + } + + switch head.TypeName { + case loops.WorkflowNodeTypeSignupTrigger: + var v loops.WorkflowSignupTriggerPayload + if err := unmarshalInto(&v); err != nil { + return payload, err + } + payload.SignupTrigger = &v + case loops.WorkflowNodeTypeEventTrigger: + var v loops.WorkflowEventTriggerPayload + if err := unmarshalInto(&v); err != nil { + return payload, err + } + payload.EventTrigger = &v + case loops.WorkflowNodeTypeContactPropertyTrigger: + var v loops.WorkflowContactPropertyTriggerPayload + if err := unmarshalInto(&v); err != nil { + return payload, err + } + payload.ContactPropertyTrigger = &v + case loops.WorkflowNodeTypeAddToListTrigger: + var v loops.WorkflowAddToListTriggerPayload + if err := unmarshalInto(&v); err != nil { + return payload, err + } + payload.AddToListTrigger = &v + case loops.WorkflowNodeTypeAudienceFilter: + var v loops.WorkflowAudienceFilterPayload + if err := unmarshalInto(&v); err != nil { + return payload, err + } + payload.AudienceFilter = &v + case loops.WorkflowNodeTypeTimerAction: + var v loops.WorkflowTimerActionPayload + if err := unmarshalInto(&v); err != nil { + return payload, err + } + payload.TimerAction = &v + case loops.WorkflowNodeTypeExperimentBranchNode: + var v loops.WorkflowExperimentBranchPayload + if err := unmarshalInto(&v); err != nil { + return payload, err + } + payload.ExperimentBranch = &v + case loops.WorkflowNodeTypeVariantNode: + var v loops.WorkflowVariantPayload + if err := unmarshalInto(&v); err != nil { + return payload, err + } + payload.Variant = &v + default: + return payload, fmt.Errorf("unsupported payload typeName %q", head.TypeName) + } + + return payload, nil +} + +func runWorkflowsCreate(cfg *config.Config, req loops.CreateWorkflowRequest) (*loops.SimplifiedWorkflow, error) { + return newAPIClient(cfg).CreateWorkflow(req) +} + +func runWorkflowsUpdate(cfg *config.Config, id string, req loops.UpdateWorkflowPropertiesRequest) (*loops.SimplifiedWorkflow, error) { + return newAPIClient(cfg).UpdateWorkflow(id, req) +} + +func runWorkflowsChangeMailingList(cfg *config.Config, id string, req loops.ChangeWorkflowMailingListRequest) (*loops.ChangeWorkflowMailingListResponse, error) { + return newAPIClient(cfg).ChangeWorkflowMailingList(id, req) +} + +func runWorkflowsNodeCreate(cfg *config.Config, id string, req loops.CreateWorkflowNodeRequest) (*loops.CreateWorkflowNodeResponse, error) { + return newAPIClient(cfg).CreateWorkflowNode(id, req) +} + +func runWorkflowsNodeUpdate(cfg *config.Config, workflowID, nodeID string, req loops.UpdateWorkflowNodeRequest) (*loops.UpdateWorkflowNodeResponse, error) { + return newAPIClient(cfg).UpdateWorkflowNode(workflowID, nodeID, req) +} + +func runWorkflowsNodeAddBranch(cfg *config.Config, workflowID, nodeID string, req loops.AddWorkflowBranchRequest) (*loops.AddWorkflowBranchResponse, error) { + return newAPIClient(cfg).AddWorkflowBranch(workflowID, nodeID, req) +} + +func runWorkflowsNodeDelete(cfg *config.Config, workflowID, nodeID string, recursive bool, req loops.DeleteWorkflowNodeRequest) (*loops.DeleteWorkflowNodeResponse, error) { + client := newAPIClient(cfg) + if recursive { + return client.DeleteWorkflowNodeRecursive(workflowID, nodeID, req) + } + return client.DeleteWorkflowNode(workflowID, nodeID, req) +} + +func printChangeMailingListResponse(cmd *cobra.Command, r *loops.ChangeWorkflowMailingListResponse) error { + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("status", r.Status) + t.Row("mailingListId", deref(r.MailingListID)) + t.Row("workflowRevisionId", deref(r.WorkflowRevisionID)) + t.Row("queuedContactCount", formatFloat(r.QueuedContactCount)) + return t.Render() +} + +func printDeleteNodeResponse(cmd *cobra.Command, r *loops.DeleteWorkflowNodeResponse) error { + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("status", r.Status) + t.Row("nodeIds", strings.Join(r.NodeIDs, ", ")) + t.Row("workflowRevisionId", deref(r.WorkflowRevisionID)) + t.Row("queuedContactCount", formatFloat(r.QueuedContactCount)) + return t.Render() +} + +var workflowsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a workflow", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + + mailingListID, _, err := readNullableFlag(cmd, "mailing-list-id") + if err != nil { + return err + } + + w, err := runWorkflowsCreate(cfg, loops.CreateWorkflowRequest{ + Name: name, + Description: description, + MailingListID: mailingListID, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), w) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s)\n\n", w.ID) + return printSimplifiedWorkflow(cmd, w) + }, +} + +var workflowsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a workflow's name and/or description", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + + w, err := runWorkflowsUpdate(cfg, args[0], loops.UpdateWorkflowPropertiesRequest{ + ExpectedRevisionID: readExpectedRevisionID(cmd), + Name: name, + Description: description, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), w) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Updated. (id: %s)\n\n", w.ID) + return printSimplifiedWorkflow(cmd, w) + }, +} + +var workflowsChangeMailingListCmd = &cobra.Command{ + Use: "change-mailing-list ", + Short: "Change a workflow's mailing list", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + policy, _ := cmd.Flags().GetString("queued-contact-policy") + if err := validateQueuedContactPolicy(policy); err != nil { + return err + } + + mailingListID, _, err := readNullableFlag(cmd, "mailing-list-id") + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + dryRun, _ := cmd.Flags().GetBool("dry-run") + r, err := runWorkflowsChangeMailingList(cfg, args[0], loops.ChangeWorkflowMailingListRequest{ + ExpectedRevisionID: readExpectedRevisionID(cmd), + MailingListID: mailingListID, + DryRun: dryRun, + QueuedContactPolicy: policy, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), r) + } + + return printChangeMailingListResponse(cmd, r) + }, +} + +var workflowsNodesCreateCmd = &cobra.Command{ + Use: "create ", + 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) + } + + 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, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), resp) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Created node. (id: %s, type: %s, revision: %s)\n\n", mutationNodeID(&resp.Node.WorkflowMutationNode), resp.Node.TypeName, resp.Node.WorkflowRevisionID) + return printSimplifiedWorkflow(cmd, &resp.Workflow) + }, +} + +var workflowsNodesUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a workflow node", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + path, _ := cmd.Flags().GetString("payload-file") + payload, err := parseUpdateWorkflowNodePayload(path) + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + node, err := runWorkflowsNodeUpdate(cfg, args[0], args[1], loops.UpdateWorkflowNodeRequest{ + ExpectedRevisionID: readExpectedRevisionID(cmd), + Payload: payload, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), node) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Updated node. (revision: %s)\n\n", node.WorkflowRevisionID) + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("typeName", node.TypeName) + t.Row("nodeId", mutationNodeID(&node.WorkflowMutationNode)) + t.Row("workflowRevisionId", node.WorkflowRevisionID) + return t.Render() + }, +} + +var workflowsNodesAddBranchCmd = &cobra.Command{ + Use: "add-branch ", + Short: "Add a branch to a branch node", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + resp, err := runWorkflowsNodeAddBranch(cfg, args[0], args[1], loops.AddWorkflowBranchRequest{ + ExpectedRevisionID: readExpectedRevisionID(cmd), + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), resp) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Added branch. (revision: %s)\n\n", resp.Node.WorkflowRevisionID) + return printSimplifiedWorkflow(cmd, &resp.Workflow) + }, +} + +var workflowsNodesDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a workflow node", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + policy, _ := cmd.Flags().GetString("queued-contact-policy") + if err := validateQueuedContactPolicy(policy); err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + recursive, _ := cmd.Flags().GetBool("recursive") + dryRun, _ := cmd.Flags().GetBool("dry-run") + r, err := runWorkflowsNodeDelete(cfg, args[0], args[1], recursive, loops.DeleteWorkflowNodeRequest{ + ExpectedRevisionID: readExpectedRevisionID(cmd), + DryRun: dryRun, + QueuedContactPolicy: policy, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), r) + } + + return printDeleteNodeResponse(cmd, r) + }, +} + +// mutationNodeID returns the ID of the active variant of a WorkflowMutationNode. +func mutationNodeID(n *loops.WorkflowMutationNode) string { + switch n.TypeName { + case loops.WorkflowNodeTypeSignupTrigger: + if n.SignupTrigger != nil { + return n.SignupTrigger.ID + } + case loops.WorkflowNodeTypeEventTrigger: + if n.EventTrigger != nil { + return n.EventTrigger.ID + } + case loops.WorkflowNodeTypeContactPropertyTrigger: + if n.ContactPropertyTrigger != nil { + return n.ContactPropertyTrigger.ID + } + case loops.WorkflowNodeTypeAddToListTrigger: + if n.AddToListTrigger != nil { + return n.AddToListTrigger.ID + } + case loops.WorkflowNodeTypeBlankTrigger: + if n.BlankTrigger != nil { + return n.BlankTrigger.ID + } + case loops.WorkflowNodeTypeAudienceFilter: + if n.AudienceFilter != nil { + return n.AudienceFilter.ID + } + case loops.WorkflowNodeTypeTimerAction: + if n.TimerAction != nil { + return n.TimerAction.ID + } + case loops.WorkflowNodeTypeSendEmailAction: + if n.SendEmailAction != nil { + return n.SendEmailAction.ID + } + case loops.WorkflowNodeTypeExitAction: + if n.ExitAction != nil { + return n.ExitAction.ID + } + case loops.WorkflowNodeTypeBranchNode: + if n.BranchNode != nil { + return n.BranchNode.ID + } + case loops.WorkflowNodeTypeExperimentBranchNode: + if n.ExperimentBranchNode != nil { + return n.ExperimentBranchNode.ID + } + case loops.WorkflowNodeTypeVariantNode: + if n.VariantNode != nil { + return n.VariantNode.ID + } + } + return "" +} + func init() { addPaginationFlags(workflowsListCmd) addPickFlag(workflowsListCmd) workflowsCmd.AddCommand(workflowsListCmd) workflowsCmd.AddCommand(workflowsGetCmd) + + workflowsCreateCmd.Flags().StringP("name", "n", "", "Workflow name") + workflowsCreateCmd.Flags().StringP("description", "d", "", "Workflow description") + workflowsCreateCmd.Flags().String("mailing-list-id", "", `Mailing list ID. Pass "null" to clear.`) + workflowsCreateCmd.MarkFlagRequired("name") + workflowsCmd.AddCommand(workflowsCreateCmd) + + workflowsUpdateCmd.Flags().StringP("name", "n", "", "Workflow name") + workflowsUpdateCmd.Flags().StringP("description", "d", "", "Workflow description") + workflowsUpdateCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") + workflowsUpdateCmd.MarkFlagsOneRequired("name", "description") + workflowsCmd.AddCommand(workflowsUpdateCmd) + + workflowsChangeMailingListCmd.Flags().String("mailing-list-id", "", `Mailing list ID. Pass "null" to clear.`) + workflowsChangeMailingListCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") + workflowsChangeMailingListCmd.Flags().Bool("dry-run", false, "Report queued-contact impact without applying the change") + workflowsChangeMailingListCmd.Flags().String("queued-contact-policy", "", "How to treat queued contacts: fail or discard") + workflowsChangeMailingListCmd.MarkFlagRequired("mailing-list-id") + workflowsCmd.AddCommand(workflowsChangeMailingListCmd) + 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("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)") + workflowsNodesCreateCmd.MarkFlagRequired("node-type") + workflowsNodesCreateCmd.MarkFlagRequired("insert-mode") + workflowsNodesCmd.AddCommand(workflowsNodesCreateCmd) + + workflowsNodesUpdateCmd.Flags().String("payload-file", "", "Path to a JSON file with the node payload (must include a typeName field)") + workflowsNodesUpdateCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") + workflowsNodesUpdateCmd.MarkFlagRequired("payload-file") + workflowsNodesCmd.AddCommand(workflowsNodesUpdateCmd) + + workflowsNodesAddBranchCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)") + workflowsNodesCmd.AddCommand(workflowsNodesAddBranchCmd) + + 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") + workflowsNodesDeleteCmd.Flags().String("queued-contact-policy", "", "How to treat queued contacts: fail or discard") + workflowsNodesCmd.AddCommand(workflowsNodesDeleteCmd) + workflowsCmd.AddCommand(workflowsNodesCmd) rootCmd.AddCommand(workflowsCmd) } diff --git a/cmd/workflows_create_test.go b/cmd/workflows_create_test.go new file mode 100644 index 0000000..4215d9d --- /dev/null +++ b/cmd/workflows_create_test.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunWorkflowsCreate(t *testing.T) { + body := `{ + "id": "wf_new", + "name": "Onboarding", + "description": "New user series", + "status": "Draft", + "workflowRevisionId": "rev_1", + "mailingListId": "ml_1", + "rootNodeId": null, + "nodes": {} + }` + + t.Run("returns workflow on success", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + ml := "ml_1" + w, err := runWorkflowsCreate(cfg(t), loops.CreateWorkflowRequest{ + Name: "Onboarding", + Description: "New user series", + MailingListID: &ml, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if w.ID != "wf_new" { + t.Errorf("ID = %q, want wf_new", w.ID) + } + + if cap.Method != http.MethodPost { + t.Errorf("Method = %q, want POST", cap.Method) + } + if cap.Path != "/workflows" { + t.Errorf("Path = %q, want /workflows", 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["name"] != "Onboarding" { + t.Errorf("name = %v, want Onboarding", sent["name"]) + } + if sent["description"] != "New user series" { + t.Errorf("description = %v", sent["description"]) + } + if sent["mailingListId"] != "ml_1" { + t.Errorf("mailingListId = %v, want ml_1", sent["mailingListId"]) + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusBadRequest, `{"success":false,"message":"name is required"}`) + _, err := runWorkflowsCreate(cfg(t), loops.CreateWorkflowRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestRunWorkflowsUpdate(t *testing.T) { + body := `{ + "id": "wf_1", + "name": "Renamed", + "status": "Draft", + "workflowRevisionId": "rev_2", + "mailingListId": null, + "rootNodeId": null, + "nodes": {} + }` + + t.Run("sends expectedRevisionId and fields", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + rev := "rev_1" + w, err := runWorkflowsUpdate(cfg(t), "wf_1", loops.UpdateWorkflowPropertiesRequest{ + ExpectedRevisionID: &rev, + Name: "Renamed", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if w.Name != "Renamed" { + t.Errorf("Name = %q, want Renamed", w.Name) + } + if cap.Path != "/workflows/wf_1" { + t.Errorf("Path = %q, want /workflows/wf_1", 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["expectedRevisionId"] != "rev_1" { + t.Errorf("expectedRevisionId = %v, want rev_1", sent["expectedRevisionId"]) + } + if sent["name"] != "Renamed" { + t.Errorf("name = %v, want Renamed", sent["name"]) + } + }) + + t.Run("sends null expectedRevisionId when unset", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + _, err := runWorkflowsUpdate(cfg(t), "wf_1", loops.UpdateWorkflowPropertiesRequest{ + Name: "Renamed", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var sent map[string]any + if err := json.Unmarshal(cap.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if _, ok := sent["expectedRevisionId"]; !ok { + t.Error("expectedRevisionId key missing; want present (null)") + } + if sent["expectedRevisionId"] != nil { + t.Errorf("expectedRevisionId = %v, want null", sent["expectedRevisionId"]) + } + }) +} + +func TestRunWorkflowsChangeMailingList(t *testing.T) { + t.Run("sends mailingListId, dryRun, and policy", func(t *testing.T) { + body := `{"status":"updated","mailingListId":"ml_2","workflowRevisionId":"rev_3","queuedContactCount":0}` + cap := serveJSONCapture(t, http.StatusOK, body) + rev := "rev_2" + ml := "ml_2" + r, err := runWorkflowsChangeMailingList(cfg(t), "wf_1", loops.ChangeWorkflowMailingListRequest{ + ExpectedRevisionID: &rev, + MailingListID: &ml, + DryRun: true, + QueuedContactPolicy: loops.WorkflowQueuedContactPolicyDiscard, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.Status != "updated" { + t.Errorf("Status = %q, want updated", r.Status) + } + if cap.Path != "/workflows/wf_1/mailing-list" { + t.Errorf("Path = %q, want /workflows/wf_1/mailing-list", 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["mailingListId"] != "ml_2" { + t.Errorf("mailingListId = %v, want ml_2", sent["mailingListId"]) + } + if sent["dryRun"] != true { + t.Errorf("dryRun = %v, want true", sent["dryRun"]) + } + if sent["queuedContactPolicy"] != "discard" { + t.Errorf("queuedContactPolicy = %v, want discard", sent["queuedContactPolicy"]) + } + }) + + t.Run("sends null mailingListId to clear", func(t *testing.T) { + body := `{"status":"updated","mailingListId":null,"workflowRevisionId":"rev_3","queuedContactCount":0}` + cap := serveJSONCapture(t, http.StatusOK, body) + _, err := runWorkflowsChangeMailingList(cfg(t), "wf_1", loops.ChangeWorkflowMailingListRequest{ + MailingListID: nil, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var sent map[string]any + if err := json.Unmarshal(cap.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if _, ok := sent["mailingListId"]; !ok { + t.Error("mailingListId key missing; want present (null)") + } + if sent["mailingListId"] != nil { + t.Errorf("mailingListId = %v, want null", sent["mailingListId"]) + } + }) +} + +func TestValidateQueuedContactPolicy(t *testing.T) { + for _, v := range []string{"", "fail", "discard"} { + if err := validateQueuedContactPolicy(v); err != nil { + t.Errorf("validateQueuedContactPolicy(%q) = %v, want nil", v, err) + } + } + if err := validateQueuedContactPolicy("bogus"); err == nil { + t.Error("validateQueuedContactPolicy(bogus) = nil, want error") + } +} diff --git a/cmd/workflows_nodes_write_test.go b/cmd/workflows_nodes_write_test.go new file mode 100644 index 0000000..d5c2fc6 --- /dev/null +++ b/cmd/workflows_nodes_write_test.go @@ -0,0 +1,303 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunWorkflowsNodeCreate(t *testing.T) { + body := `{ + "node": { + "typeName": "TimerAction", + "id": "node_new", + "nextNodeIds": ["n2"], + "amount": 0, + "unit": "m", + "workflowRevisionId": "rev_2" + }, + "workflow": { + "id": "wf_1", + "name": "WF", + "status": "Draft", + "workflowRevisionId": "rev_2", + "mailingListId": null, + "rootNodeId": null, + "nodes": {} + } + }` + + t.Run("between mode sends from/to node ids", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + resp, err := runWorkflowsNodeCreate(cfg(t), "wf_1", loops.CreateWorkflowNodeRequest{ + InsertMode: loops.WorkflowInsertModeBetween, + NodeTypeName: loops.CreateWorkflowNodeTypeTimerAction, + FromNodeID: "n1", + ToNodeID: "n2", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Node.TypeName != loops.WorkflowNodeTypeTimerAction { + t.Errorf("Node.TypeName = %q, want TimerAction", resp.Node.TypeName) + } + if resp.Workflow.ID != "wf_1" { + t.Errorf("Workflow.ID = %q, want wf_1", resp.Workflow.ID) + } + if cap.Path != "/workflows/wf_1/nodes" { + t.Errorf("Path = %q, want /workflows/wf_1/nodes", 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["insertMode"] != "between" { + t.Errorf("insertMode = %v, want between", sent["insertMode"]) + } + if sent["nodeTypeName"] != "TimerAction" { + t.Errorf("nodeTypeName = %v, want TimerAction", sent["nodeTypeName"]) + } + if sent["fromNodeId"] != "n1" || sent["toNodeId"] != "n2" { + t.Errorf("from/to = %v/%v, want n1/n2", sent["fromNodeId"], sent["toNodeId"]) + } + if _, ok := sent["beforeNodeId"]; ok { + t.Error("beforeNodeId present; want omitted for between mode") + } + }) + + t.Run("before mode sends beforeNodeId", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + _, err := runWorkflowsNodeCreate(cfg(t), "wf_1", loops.CreateWorkflowNodeRequest{ + InsertMode: loops.WorkflowInsertModeBefore, + NodeTypeName: loops.CreateWorkflowNodeTypeTimerAction, + BeforeNodeID: "n3", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var sent map[string]any + if err := json.Unmarshal(cap.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["beforeNodeId"] != "n3" { + t.Errorf("beforeNodeId = %v, want n3", sent["beforeNodeId"]) + } + if _, ok := sent["fromNodeId"]; ok { + t.Error("fromNodeId present; want omitted for before mode") + } + }) +} + +func TestParseUpdateWorkflowNodePayload(t *testing.T) { + writePayload := func(t *testing.T, contents string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "payload.json") + if err := os.WriteFile(p, []byte(contents), 0o600); err != nil { + t.Fatalf("write payload: %v", err) + } + return p + } + + t.Run("timer action config payload", func(t *testing.T) { + path := writePayload(t, `{"typeName":"TimerAction","amount":3,"unit":"d"}`) + payload, err := parseUpdateWorkflowNodePayload(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if payload.TypeName != loops.WorkflowNodeTypeTimerAction { + t.Errorf("TypeName = %q, want TimerAction", payload.TypeName) + } + if payload.TimerAction == nil { + t.Fatal("TimerAction = nil, want populated") + } + if payload.TimerAction.Amount == nil || *payload.TimerAction.Amount != 3 { + t.Errorf("Amount = %v, want 3", payload.TimerAction.Amount) + } + if payload.TimerAction.Unit != loops.WorkflowTimerUnitDays { + t.Errorf("Unit = %q, want d", payload.TimerAction.Unit) + } + + // Round-trips with the SDK MarshalJSON (config variant, no typeName). + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if got := string(raw); got != `{"amount":3,"unit":"d"}` { + t.Errorf("marshaled = %s", got) + } + }) + + t.Run("event trigger payload carries typeName", func(t *testing.T) { + path := writePayload(t, `{"typeName":"EventTrigger","eventName":"signup","reEligible":true}`) + payload, err := parseUpdateWorkflowNodePayload(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if payload.EventTrigger == nil { + t.Fatal("EventTrigger = nil, want populated") + } + if deref(payload.EventTrigger.EventName) != "signup" { + t.Errorf("EventName = %q, want signup", deref(payload.EventTrigger.EventName)) + } + raw, _ := json.Marshal(payload) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal marshaled: %v", err) + } + if m["typeName"] != "EventTrigger" { + t.Errorf("typeName = %v, want EventTrigger", m["typeName"]) + } + }) + + t.Run("missing typeName is an error", func(t *testing.T) { + path := writePayload(t, `{"amount":3}`) + if _, err := parseUpdateWorkflowNodePayload(path); err == nil { + t.Error("expected error for missing typeName") + } + }) + + t.Run("unknown typeName is an error", func(t *testing.T) { + path := writePayload(t, `{"typeName":"Nonsense"}`) + if _, err := parseUpdateWorkflowNodePayload(path); err == nil { + t.Error("expected error for unknown typeName") + } + }) + + t.Run("missing file is an error", func(t *testing.T) { + if _, err := parseUpdateWorkflowNodePayload(filepath.Join(t.TempDir(), "nope.json")); err == nil { + t.Error("expected error for missing file") + } + }) +} + +func TestRunWorkflowsNodeUpdate(t *testing.T) { + body := `{ + "typeName": "TimerAction", + "id": "node_t", + "nextNodeIds": ["n2"], + "amount": 3, + "unit": "d", + "workflowRevisionId": "rev_5" + }` + + t.Run("sends payload and expectedRevisionId", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + amount := 3.0 + rev := "rev_4" + node, err := runWorkflowsNodeUpdate(cfg(t), "wf_1", "node_t", loops.UpdateWorkflowNodeRequest{ + ExpectedRevisionID: &rev, + Payload: loops.UpdateWorkflowNodePayload{ + TypeName: loops.WorkflowNodeTypeTimerAction, + TimerAction: &loops.WorkflowTimerActionPayload{Amount: &amount, Unit: loops.WorkflowTimerUnitDays}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if node.WorkflowRevisionID != "rev_5" { + t.Errorf("WorkflowRevisionID = %q, want rev_5", node.WorkflowRevisionID) + } + if cap.Path != "/workflows/wf_1/nodes/node_t" { + t.Errorf("Path = %q, want /workflows/wf_1/nodes/node_t", 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["expectedRevisionId"] != "rev_4" { + t.Errorf("expectedRevisionId = %v, want rev_4", sent["expectedRevisionId"]) + } + payload, ok := sent["payload"].(map[string]any) + if !ok { + t.Fatalf("payload not an object: %v", sent["payload"]) + } + if payload["amount"] != 3.0 || payload["unit"] != "d" { + t.Errorf("payload = %v", payload) + } + }) +} + +func TestRunWorkflowsNodeAddBranch(t *testing.T) { + body := `{ + "node": { + "typeName": "BranchNode", + "id": "node_b", + "nextNodeIds": ["a","b"], + "workflowRevisionId": "rev_6" + }, + "workflow": { + "id": "wf_1", + "name": "WF", + "status": "Draft", + "workflowRevisionId": "rev_6", + "mailingListId": null, + "rootNodeId": null, + "nodes": {} + } + }` + + t.Run("posts to add-branch path", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + resp, err := runWorkflowsNodeAddBranch(cfg(t), "wf_1", "node_b", loops.AddWorkflowBranchRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Node.WorkflowRevisionID != "rev_6" { + t.Errorf("revision = %q, want rev_6", resp.Node.WorkflowRevisionID) + } + if cap.Path != "/workflows/wf_1/nodes/node_b/add-branch" { + t.Errorf("Path = %q, want .../add-branch", cap.Path) + } + }) +} + +func TestRunWorkflowsNodeDelete(t *testing.T) { + body := `{"status":"deleted","nodeIds":["node_x"],"workflowRevisionId":"rev_7","queuedContactCount":0}` + + t.Run("non-recursive hits the node path", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + r, err := runWorkflowsNodeDelete(cfg(t), "wf_1", "node_x", false, loops.DeleteWorkflowNodeRequest{ + DryRun: true, + QueuedContactPolicy: loops.WorkflowQueuedContactPolicyFail, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if r.Status != "deleted" { + t.Errorf("Status = %q, want deleted", r.Status) + } + if cap.Method != http.MethodDelete { + t.Errorf("Method = %q, want DELETE", cap.Method) + } + if cap.Path != "/workflows/wf_1/nodes/node_x" { + t.Errorf("Path = %q, want /workflows/wf_1/nodes/node_x", cap.Path) + } + var sent map[string]any + if err := json.Unmarshal(cap.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["dryRun"] != true { + t.Errorf("dryRun = %v, want true", sent["dryRun"]) + } + if sent["queuedContactPolicy"] != "fail" { + t.Errorf("queuedContactPolicy = %v, want fail", sent["queuedContactPolicy"]) + } + }) + + t.Run("recursive hits the recursive path", func(t *testing.T) { + cap := serveJSONCapture(t, http.StatusOK, body) + _, err := runWorkflowsNodeDelete(cfg(t), "wf_1", "node_x", true, loops.DeleteWorkflowNodeRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cap.Path != "/workflows/wf_1/nodes/node_x/recursive" { + t.Errorf("Path = %q, want .../recursive", cap.Path) + } + }) +}