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
104 changes: 96 additions & 8 deletions cmd/audience_segments.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package cmd

import (
"encoding/json"
"fmt"
"os"

"github.com/loops-so/cli/internal/config"
"github.com/loops-so/loops-go"
Expand Down Expand Up @@ -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)
},
}

Expand All @@ -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)
}
187 changes: 187 additions & 0 deletions cmd/audience_segments_create_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
})
}