diff --git a/cmd/email_messages.go b/cmd/email_messages.go index 6a4694a..aef60c9 100644 --- a/cmd/email_messages.go +++ b/cmd/email_messages.go @@ -442,6 +442,84 @@ var emailMessagesPreviewCmd = &cobra.Command{ }, } +func runEmailMessageGuardian(cfg *config.Config, id string) (*loops.EmailMessageGuardianResponse, error) { + return newAPIClient(cfg).GetEmailMessageGuardian(id) +} + +var emailMessagesGuardianCmd = &cobra.Command{ + Use: "guardian ", + Short: "Run Guardian content validation on an email message", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + resp, err := runEmailMessageGuardian(cfg, args[0]) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), resp) + } + + return printGuardianResponse(cmd, resp) + }, +} + +func printGuardianResponse(cmd *cobra.Command, resp *loops.EmailMessageGuardianResponse) error { + out := cmd.OutOrStdout() + if len(resp.Errors) == 0 && len(resp.Warnings) == 0 { + fmt.Fprintln(out, "No guardian issues found.") + return nil + } + + if err := printGuardianSection(cmd, "Errors", resp.Errors); err != nil { + return err + } + if len(resp.Errors) > 0 && len(resp.Warnings) > 0 { + fmt.Fprintln(out) + } + return printGuardianSection(cmd, "Warnings", resp.Warnings) +} + +func printGuardianSection(cmd *cobra.Command, heading string, rules []loops.GuardianRule) error { + if len(rules) == 0 { + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "%s:\n", heading) + t := newStyledTable(cmd.OutOrStdout(), "RULE", "TITLE", "DESCRIPTION", "ITEMS") + for _, r := range rules { + t.Row(r.Rule, r.Title, r.Description, formatGuardianItems(r.Items)) + } + return t.Render() +} + +// formatGuardianItems summarizes a rule's items as a count plus a short, +// comma-separated list of item labels (falling back to codeName when a label +// is empty). Returns "0" when there are no items. +func formatGuardianItems(items []loops.GuardianRuleItem) string { + if len(items) == 0 { + return "0" + } + labels := make([]string, 0, len(items)) + for _, item := range items { + label := item.Label + if label == "" { + label = item.CodeName + } + if label != "" { + labels = append(labels, label) + } + } + if len(labels) == 0 { + return fmt.Sprintf("%d", len(items)) + } + return fmt.Sprintf("%d (%s)", len(items), strings.Join(labels, ", ")) +} + func printLmxWarnings(cmd *cobra.Command, warnings []loops.LmxWarning) { if len(warnings) == 0 { return @@ -460,6 +538,8 @@ func printLmxWarnings(cmd *cobra.Command, warnings []loops.LmxWarning) { func init() { emailMessagesCmd.AddCommand(emailMessagesGetCmd) + emailMessagesCmd.AddCommand(emailMessagesGuardianCmd) + addEmailMessageFieldFlags(emailMessagesUpdateCmd) emailMessagesUpdateCmd.Flags().StringP("expected-revision-id", "r", "", "Last-seen contentRevisionId. Get this from a prior 'email-messages get'. Mutually exclusive with --force.") emailMessagesUpdateCmd.Flags().BoolP("force", "f", false, "Fetch the current revision and use it (overwrites any concurrent edits). Mutually exclusive with --expected-revision-id.") diff --git a/cmd/email_messages_guardian_test.go b/cmd/email_messages_guardian_test.go new file mode 100644 index 0000000..9fc0344 --- /dev/null +++ b/cmd/email_messages_guardian_test.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "net/http" + "testing" + + loops "github.com/loops-so/loops-go" +) + +func TestRunEmailMessageGuardian(t *testing.T) { + body := `{ + "errors": [ + { + "rule": "missing-unsubscribe", + "title": "Missing unsubscribe link", + "description": "Marketing emails must include an unsubscribe link.", + "items": [ + {"label": "Footer", "codeName": "footer"} + ] + } + ], + "warnings": [ + { + "rule": "broken-link", + "title": "Broken link", + "description": "One or more links may be broken.", + "items": [ + {"label": "https://example.com/a", "codeName": "link-a"}, + {"label": "https://example.com/b", "codeName": "link-b"} + ] + } + ] + }` + + t.Run("returns errors and warnings", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + resp, err := runEmailMessageGuardian(cfg(t), "em_abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Errors) != 1 { + t.Fatalf("Errors len = %d, want 1", len(resp.Errors)) + } + if resp.Errors[0].Rule != "missing-unsubscribe" { + t.Errorf("Errors[0].Rule = %q, want missing-unsubscribe", resp.Errors[0].Rule) + } + if len(resp.Errors[0].Items) != 1 || resp.Errors[0].Items[0].Label != "Footer" { + t.Errorf("Errors[0].Items = %+v, want one item labeled Footer", resp.Errors[0].Items) + } + if len(resp.Warnings) != 1 { + t.Fatalf("Warnings len = %d, want 1", len(resp.Warnings)) + } + if len(resp.Warnings[0].Items) != 2 { + t.Errorf("Warnings[0].Items len = %d, want 2", len(resp.Warnings[0].Items)) + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Email message not found"}`) + _, err := runEmailMessageGuardian(cfg(t), "em_missing") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestFormatGuardianItems(t *testing.T) { + tests := []struct { + name string + items []loops.GuardianRuleItem + want string + }{ + {"no items", nil, "0"}, + {"label", []loops.GuardianRuleItem{{Label: "Footer"}}, "1 (Footer)"}, + {"codeName fallback", []loops.GuardianRuleItem{{CodeName: "footer"}}, "1 (footer)"}, + {"empty label and codeName", []loops.GuardianRuleItem{{}}, "1"}, + {"multiple", []loops.GuardianRuleItem{{Label: "A"}, {Label: "B"}}, "2 (A, B)"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := formatGuardianItems(tc.items) + if got != tc.want { + t.Errorf("formatGuardianItems() = %q, want %q", got, tc.want) + } + }) + } +}