diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 468105d..14739ce 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -53,10 +53,11 @@ func addCampaignFieldFlags(cmd *cobra.Command) { cmd.Flags().String("campaign-group-id", "", "Campaign group ID") cmd.Flags().String("mailing-list-id", "", `Mailing list ID to target. Pass "null" to clear.`) cmd.Flags().String("audience-segment-id", "", `Audience segment ID to target. Pass "null" to clear.`) - cmd.Flags().String("audience-filter-file", "", `Path to a JSON file with an ad-hoc audience filter. Pass "null" to clear.`) + cmd.Flags().String("audience-filter", "", `Inline JSON string with an ad-hoc audience filter. Pass "null" to clear.`) + cmd.Flags().String("audience-filter-file", "", "Path to a JSON file with an ad-hoc audience filter") cmd.Flags().Bool("schedule-now", false, "Send immediately when published") cmd.Flags().String("schedule-at", "", "Send at the given ISO 8601 timestamp (e.g. 2026-07-01T12:00:00Z)") - cmd.MarkFlagsMutuallyExclusive("audience-segment-id", "audience-filter-file") + cmd.MarkFlagsMutuallyExclusive("audience-segment-id", "audience-filter", "audience-filter-file") cmd.MarkFlagsMutuallyExclusive("schedule-now", "schedule-at") } @@ -76,6 +77,56 @@ func readNullableFlag(cmd *cobra.Command, flagName string) (*string, bool, error return &v, true, nil } +// resolveAudienceFilter reads the ad-hoc audience filter from either the inline +// --audience-filter JSON string or the --audience-filter-file path and returns +// (filterOrNil, set, err). At most one flag may be provided; the mutual +// exclusion is also checked here (not just via cobra's flag group) so it holds +// when RunE is invoked directly in tests. +// +// Only --audience-filter accepts the "null" sentinel to clear the field, +// encoded as a nil filter with set=true. --audience-filter-file always names a +// file, so a non-file value simply fails the read. +func resolveAudienceFilter(cmd *cobra.Command) (*loops.AudienceFilter, bool, error) { + inlineSet := cmd.Flags().Changed("audience-filter") + fileSet := cmd.Flags().Changed("audience-filter-file") + switch { + case !inlineSet && !fileSet: + return nil, false, nil + case inlineSet && fileSet: + return nil, false, fmt.Errorf("--audience-filter and --audience-filter-file are mutually exclusive") + } + + src := "--audience-filter" + var data []byte + if inlineSet { + v, _ := cmd.Flags().GetString("audience-filter") + if v == "" { + return nil, false, fmt.Errorf(`--audience-filter requires a value; pass "null" to clear`) + } + if v == nullSentinel { + return nil, true, nil + } + data = []byte(v) + } else { + src = "--audience-filter-file" + path, _ := cmd.Flags().GetString("audience-filter-file") + if path == "" { + return nil, false, fmt.Errorf("--audience-filter-file requires a file path") + } + b, err := os.ReadFile(path) + if err != nil { + return nil, false, fmt.Errorf("read --audience-filter-file: %w", err) + } + data = b + } + + var f loops.AudienceFilter + if err := json.Unmarshal(data, &f); err != nil { + return nil, false, fmt.Errorf("parse %s: %w", src, err) + } + return &f, true, nil +} + func campaignFieldParamsFromCmd(cmd *cobra.Command) (campaignFieldParams, error) { p := campaignFieldParams{Set: map[string]bool{}} @@ -99,26 +150,11 @@ func campaignFieldParamsFromCmd(cmd *cobra.Command) (campaignFieldParams, error) p.AudienceSegmentID = v p.Set["audienceSegmentId"] = true } - if cmd.Flags().Changed("audience-filter-file") { - path, _ := cmd.Flags().GetString("audience-filter-file") - if path == "" { - return p, fmt.Errorf(`--audience-filter-file requires a value; pass "null" to clear`) - } - if path == nullSentinel { - p.AudienceFilter = nil - p.Set["audienceFilter"] = true - } else { - data, err := os.ReadFile(path) - if err != nil { - return p, fmt.Errorf("read --audience-filter-file: %w", err) - } - var f loops.AudienceFilter - if err := json.Unmarshal(data, &f); err != nil { - return p, fmt.Errorf("parse --audience-filter-file: %w", err) - } - p.AudienceFilter = &f - p.Set["audienceFilter"] = true - } + if f, set, err := resolveAudienceFilter(cmd); err != nil { + return p, err + } else if set { + p.AudienceFilter = f + p.Set["audienceFilter"] = true } if cmd.Flags().Changed("schedule-now") { p.Scheduling = &loops.CampaignSchedulingRequest{Method: loops.CampaignSchedulingMethodNow} @@ -352,6 +388,7 @@ func init() { "campaign-group-id", "mailing-list-id", "audience-segment-id", + "audience-filter", "audience-filter-file", "schedule-now", "schedule-at", diff --git a/cmd/campaigns_fields_test.go b/cmd/campaigns_fields_test.go index d08abd8..a0d824a 100644 --- a/cmd/campaigns_fields_test.go +++ b/cmd/campaigns_fields_test.go @@ -143,6 +143,98 @@ func TestCampaignFieldParamsFromCmd(t *testing.T) { } }) + t.Run("audience-filter parses inline JSON", func(t *testing.T) { + cmd := newCampaignFieldCmd() + filter := `{"match":"any","conditions":[{"type":"property","key":"plan","operator":"equals","value":"pro"}]}` + if err := cmd.ParseFlags([]string{"--audience-filter", filter}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.AudienceFilter == nil { + t.Fatal("AudienceFilter nil") + } + if p.AudienceFilter.Match != "any" { + t.Errorf("Match = %q, want any", p.AudienceFilter.Match) + } + if len(p.AudienceFilter.Conditions) != 1 { + t.Fatalf("Conditions len = %d, want 1", len(p.AudienceFilter.Conditions)) + } + if p.AudienceFilter.Conditions[0].Type != loops.AudienceConditionTypeProperty { + t.Errorf("Conditions[0].Type = %q", p.AudienceFilter.Conditions[0].Type) + } + if !p.Set["audienceFilter"] { + t.Error(`Set["audienceFilter"] = false, want true`) + } + }) + + t.Run("audience-filter invalid JSON returns error", func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--audience-filter", "{not json"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + _, err := campaignFieldParamsFromCmd(cmd) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "parse --audience-filter:") { + t.Errorf("error = %q, want a %q parse error", err.Error(), "parse --audience-filter:") + } + }) + + t.Run("audience-filter-file invalid JSON returns error", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "filter.json") + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--audience-filter-file", path}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + _, err := campaignFieldParamsFromCmd(cmd) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "parse --audience-filter-file:") { + t.Errorf("error = %q, want a %q parse error", err.Error(), "parse --audience-filter-file:") + } + }) + + t.Run(`audience-filter "null" sentinel clears the filter`, func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--audience-filter", "null"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.AudienceFilter != nil { + t.Errorf("AudienceFilter = %v, want nil", p.AudienceFilter) + } + if !p.Set["audienceFilter"] { + t.Error(`Set["audienceFilter"] = false, want true`) + } + }) + + t.Run("audience-filter and audience-filter-file together return error", func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--audience-filter", "{}", "--audience-filter-file", "f.json"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + _, err := campaignFieldParamsFromCmd(cmd) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("error = %q, want it to mention mutually exclusive", err.Error()) + } + }) + t.Run("audience-filter-file missing returns error", func(t *testing.T) { cmd := newCampaignFieldCmd() if err := cmd.ParseFlags([]string{"--audience-filter-file", "/no/such/file.json"}); err != nil { @@ -187,25 +279,22 @@ func TestCampaignFieldParamsFromCmd(t *testing.T) { } }) - t.Run(`audience-filter-file "null" sentinel clears the filter`, func(t *testing.T) { + t.Run(`audience-filter-file "null" is treated as a path, not a clear`, func(t *testing.T) { cmd := newCampaignFieldCmd() if err := cmd.ParseFlags([]string{"--audience-filter-file", "null"}); err != nil { t.Fatalf("ParseFlags: %v", err) } - p, err := campaignFieldParamsFromCmd(cmd) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if p.AudienceFilter != nil { - t.Errorf("AudienceFilter = %v, want nil", p.AudienceFilter) + _, err := campaignFieldParamsFromCmd(cmd) + if err == nil { + t.Fatal("expected error reading file \"null\", got nil") } - if !p.Set["audienceFilter"] { - t.Error(`Set["audienceFilter"] = false, want true`) + if !strings.Contains(err.Error(), "read --audience-filter-file") { + t.Errorf("error = %q, want a file-read error", err.Error()) } }) - t.Run("empty value on nullable flag is rejected", func(t *testing.T) { - cases := []string{"mailing-list-id", "audience-segment-id", "audience-filter-file"} + t.Run("empty value on nullable string flag is rejected", func(t *testing.T) { + cases := []string{"mailing-list-id", "audience-segment-id", "audience-filter"} for _, flag := range cases { t.Run(flag, func(t *testing.T) { cmd := newCampaignFieldCmd() @@ -222,4 +311,18 @@ func TestCampaignFieldParamsFromCmd(t *testing.T) { }) } }) + + t.Run("empty audience-filter-file is rejected", func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--audience-filter-file", ""}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + _, err := campaignFieldParamsFromCmd(cmd) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "file path") { + t.Errorf("error = %q, want it to mention a file path", err.Error()) + } + }) } diff --git a/cmd/campaigns_update_test.go b/cmd/campaigns_update_test.go index 27a70ff..9a23310 100644 --- a/cmd/campaigns_update_test.go +++ b/cmd/campaigns_update_test.go @@ -67,6 +67,29 @@ func TestRunCampaignsUpdate(t *testing.T) { } }) + t.Run("cleared audienceFilter sends JSON null on the wire", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, body) + _, err := runCampaignsUpdate(cfg(t), "cmp_abc123", loops.UpdateCampaignRequest{ + AudienceFilter: nil, + Set: map[string]bool{"audienceFilter": true}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + v, ok := sent["audienceFilter"] + if !ok { + t.Fatalf("audienceFilter missing from request body: %v", sent) + } + if v != nil { + t.Errorf("audienceFilter = %v, want nil (JSON null)", v) + } + }) + t.Run("Set map controls which fields are sent", func(t *testing.T) { got := serveJSONCapture(t, http.StatusOK, body) ts := "2026-07-01T12:00:00Z"