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
81 changes: 59 additions & 22 deletions cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand All @@ -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{}}

Expand All @@ -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}
Expand Down Expand Up @@ -352,6 +388,7 @@ func init() {
"campaign-group-id",
"mailing-list-id",
"audience-segment-id",
"audience-filter",
"audience-filter-file",
"schedule-now",
"schedule-at",
Expand Down
125 changes: 114 additions & 11 deletions cmd/campaigns_fields_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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())
}
})
}
23 changes: 23 additions & 0 deletions cmd/campaigns_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down