From f704c15b6e21d33b55e4e1fe03f34e9641a87d54 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:00:26 -0700 Subject: [PATCH] feat(audience-segments): add create subcommand with --filter/--filter-file --- cmd/audience_segments.go | 104 +++++++++++++-- cmd/audience_segments_create_test.go | 187 +++++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 cmd/audience_segments_create_test.go diff --git a/cmd/audience_segments.go b/cmd/audience_segments.go index db4c086..9d86cb0 100644 --- a/cmd/audience_segments.go +++ b/cmd/audience_segments.go @@ -1,7 +1,9 @@ package cmd import ( + "encoding/json" "fmt" + "os" "github.com/loops-so/cli/internal/config" "github.com/loops-so/loops-go" @@ -115,14 +117,90 @@ var audienceSegmentsGetCmd = &cobra.Command{ return printJSON(cmd.OutOrStdout(), s) } - t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("segmentId", s.ID) - t.Row("name", s.Name) - t.Row("description", deref(s.Description)) - t.Row("filter", formatSegmentFilter(s.Filter)) - t.Row("createdAt", s.CreatedAt) - t.Row("updatedAt", s.UpdatedAt) - return t.Render() + return printAudienceSegment(cmd, s) + }, +} + +func printAudienceSegment(cmd *cobra.Command, s *loops.AudienceSegment) error { + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("segmentId", s.ID) + t.Row("name", s.Name) + t.Row("description", deref(s.Description)) + t.Row("filter", formatSegmentFilter(s.Filter)) + t.Row("createdAt", s.CreatedAt) + t.Row("updatedAt", s.UpdatedAt) + return t.Render() +} + +func runAudienceSegmentsCreate(cfg *config.Config, req loops.CreateAudienceSegmentRequest) (*loops.AudienceSegment, error) { + return newAPIClient(cfg).CreateAudienceSegment(req) +} + +// filterFromCmd resolves the audience filter from either the inline --filter +// JSON string or the --filter-file path (exactly one, enforced as a flag +// group). Selection is value-based so it stays correct when RunE is called +// directly in tests, which bypasses cobra's flag-group validation. +func filterFromCmd(cmd *cobra.Command) (loops.AudienceFilter, error) { + inline, _ := cmd.Flags().GetString("filter") + path, _ := cmd.Flags().GetString("filter-file") + + var data []byte + src := "--filter" + switch { + case inline != "" && path != "": + return loops.AudienceFilter{}, fmt.Errorf("--filter and --filter-file are mutually exclusive") + case inline != "": + data = []byte(inline) + case path != "": + src = "--filter-file" + b, err := os.ReadFile(path) + if err != nil { + return loops.AudienceFilter{}, fmt.Errorf("read --filter-file: %w", err) + } + data = b + default: + return loops.AudienceFilter{}, fmt.Errorf("one of --filter or --filter-file is required") + } + + var filter loops.AudienceFilter + if err := json.Unmarshal(data, &filter); err != nil { + return loops.AudienceFilter{}, fmt.Errorf("parse %s: %w", src, err) + } + return filter, nil +} + +var audienceSegmentsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create an audience segment", + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + + filter, err := filterFromCmd(cmd) + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + s, err := runAudienceSegmentsCreate(cfg, loops.CreateAudienceSegmentRequest{ + Name: name, + Description: description, + Filter: filter, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), s) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s)\n\n", s.ID) + return printAudienceSegment(cmd, s) }, } @@ -131,5 +209,15 @@ func init() { addPickFlag(audienceSegmentsListCmd) audienceSegmentsCmd.AddCommand(audienceSegmentsListCmd) audienceSegmentsCmd.AddCommand(audienceSegmentsGetCmd) + + audienceSegmentsCreateCmd.Flags().StringP("name", "n", "", "Segment name") + audienceSegmentsCreateCmd.Flags().String("description", "", "Segment description") + audienceSegmentsCreateCmd.Flags().String("filter", "", "Audience filter as an inline JSON string") + audienceSegmentsCreateCmd.Flags().String("filter-file", "", "Path to a JSON file with the audience filter") + audienceSegmentsCreateCmd.MarkFlagRequired("name") + audienceSegmentsCreateCmd.MarkFlagsMutuallyExclusive("filter", "filter-file") + audienceSegmentsCreateCmd.MarkFlagsOneRequired("filter", "filter-file") + audienceSegmentsCmd.AddCommand(audienceSegmentsCreateCmd) + rootCmd.AddCommand(audienceSegmentsCmd) } diff --git a/cmd/audience_segments_create_test.go b/cmd/audience_segments_create_test.go new file mode 100644 index 0000000..d2ce4f4 --- /dev/null +++ b/cmd/audience_segments_create_test.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/loops-so/loops-go" +) + +const audienceSegmentBody = `{ + "id": "seg_new", + "name": "Active users", + "description": "Recently active", + "createdAt": "2026-07-24T10:00:00Z", + "updatedAt": "2026-07-24T10:00:00Z", + "filter": {"match": "all", "conditions": []} +}` + +func strPtr(s string) *string { return &s } + +func writeFilterFile(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "filter.json") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write filter file: %v", err) + } + return path +} + +func TestRunAudienceSegmentsCreate(t *testing.T) { + t.Run("returns segment on success", func(t *testing.T) { + serveJSON(t, http.StatusOK, audienceSegmentBody) + s, err := runAudienceSegmentsCreate(cfg(t), loops.CreateAudienceSegmentRequest{ + Name: "Active users", + Filter: loops.AudienceFilter{Match: "all"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.ID != "seg_new" { + t.Errorf("ID = %q, want seg_new", s.ID) + } + if s.Name != "Active users" { + t.Errorf("Name = %q, want Active users", s.Name) + } + }) + + t.Run("returns error on non-2xx response", func(t *testing.T) { + serveJSON(t, http.StatusBadRequest, `{"success":false,"message":"name is required"}`) + _, err := runAudienceSegmentsCreate(cfg(t), loops.CreateAudienceSegmentRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("sends name, description and filter", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, audienceSegmentBody) + _, err := runAudienceSegmentsCreate(cfg(t), loops.CreateAudienceSegmentRequest{ + Name: "Active users", + Description: "Recently active", + Filter: loops.AudienceFilter{ + Match: "all", + Conditions: []loops.AudienceFilterCondition{ + { + Type: "property", + Property: &loops.PropertyCondition{ + Key: "plan", + Operator: "eq", + Value: &loops.PropertyConditionValue{String: strPtr("pro")}, + }, + }, + }, + }, + }) + 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) + } + if sent["name"] != "Active users" { + t.Errorf("name = %v, want Active users", sent["name"]) + } + if sent["description"] != "Recently active" { + t.Errorf("description = %v, want Recently active", sent["description"]) + } + filter, ok := sent["filter"].(map[string]any) + if !ok { + t.Fatalf("filter not an object: %v", sent["filter"]) + } + if filter["match"] != "all" { + t.Errorf("filter.match = %v, want all", filter["match"]) + } + conds, ok := filter["conditions"].([]any) + if !ok || len(conds) != 1 { + t.Fatalf("filter.conditions = %v, want 1 condition", filter["conditions"]) + } + cond, ok := conds[0].(map[string]any) + if !ok { + t.Fatalf("condition not an object: %v", conds[0]) + } + if cond["type"] != "property" || cond["key"] != "plan" || cond["value"] != "pro" { + t.Errorf("condition = %v, want type=property key=plan value=pro", cond) + } + }) +} + +func TestAudienceSegmentsCreateCmd(t *testing.T) { + t.Run("errors when filter file is missing", func(t *testing.T) { + serveJSON(t, http.StatusOK, audienceSegmentBody) + cmd := audienceSegmentsCreateCmd + cmd.SetArgs([]string{}) + cmd.Flags().Set("name", "Active users") + cmd.Flags().Set("filter-file", filepath.Join(t.TempDir(), "does-not-exist.json")) + t.Cleanup(func() { cmd.Flags().Set("filter-file", "") }) + if err := cmd.RunE(cmd, nil); err == nil { + t.Fatal("expected error for missing filter file, got nil") + } + }) + + t.Run("errors on invalid filter JSON", func(t *testing.T) { + serveJSON(t, http.StatusOK, audienceSegmentBody) + path := writeFilterFile(t, `{not json`) + cmd := audienceSegmentsCreateCmd + cmd.SetArgs([]string{}) + cmd.Flags().Set("name", "Active users") + cmd.Flags().Set("filter-file", path) + t.Cleanup(func() { cmd.Flags().Set("filter-file", "") }) + if err := cmd.RunE(cmd, nil); err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + }) + + t.Run("succeeds with a valid filter file", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, audienceSegmentBody) + path := writeFilterFile(t, `{"match":"any","conditions":[]}`) + cmd := audienceSegmentsCreateCmd + cmd.SetArgs([]string{}) + cmd.Flags().Set("name", "Active users") + cmd.Flags().Set("filter-file", path) + t.Cleanup(func() { + cmd.Flags().Set("filter-file", "") + cmd.Flags().Set("name", "") + cmd.Flags().Set("description", "") + }) + if err := cmd.RunE(cmd, nil); 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) + } + filter, ok := sent["filter"].(map[string]any) + if !ok || filter["match"] != "any" { + t.Errorf("filter = %v, want match=any", sent["filter"]) + } + }) + + t.Run("succeeds with an inline --filter", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, audienceSegmentBody) + cmd := audienceSegmentsCreateCmd + cmd.SetArgs([]string{}) + cmd.Flags().Set("name", "Active users") + cmd.Flags().Set("filter", `{"match":"all","conditions":[]}`) + t.Cleanup(func() { + cmd.Flags().Set("filter", "") + cmd.Flags().Set("name", "") + cmd.Flags().Set("description", "") + }) + if err := cmd.RunE(cmd, nil); 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) + } + filter, ok := sent["filter"].(map[string]any) + if !ok || filter["match"] != "all" { + t.Errorf("filter = %v, want match=all", sent["filter"]) + } + }) +}