diff --git a/CHANGELOG.md b/CHANGELOG.md index 62cde703..8c410993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ +### Audience + +- **Breaking changes:** + - Removed `eligibility-count`, replaced by `audience count`. The template + format and `-w/--workspace` flag are unchanged, so `prolific + eligibility-count -t filters.json -w ` becomes `prolific audience count + -t filters.json -w `. The new command also accepts `--filters` (a raw + JSON array, for scripting without a temp file), and `-j/--json` for + machine-readable output. + - `audience count` reports the privacy floor as 5 rather than the 25 stated by + the removed command; the API obscures counts below 5. + ### AI Task Builder - Support export slicing: `aitaskbuilder batch export` and `collection export` accept `--study-id`, `--from`, and `--to` to narrow an export to a subset of responses diff --git a/cmd/audience/audience.go b/cmd/audience/audience.go new file mode 100644 index 00000000..137d7b9b --- /dev/null +++ b/cmd/audience/audience.go @@ -0,0 +1,22 @@ +package audience + +import ( + "io" + + "github.com/prolific-oss/cli/client" + "github.com/spf13/cobra" +) + +// NewAudienceCommand creates a new `audience` command +func NewAudienceCommand(client client.API, w io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "audience", + Short: "Understand your audience of eligible participants", + } + + cmd.AddCommand( + NewCountCommand(client, w), + ) + + return cmd +} diff --git a/cmd/audience/count.go b/cmd/audience/count.go new file mode 100644 index 00000000..40bd5801 --- /dev/null +++ b/cmd/audience/count.go @@ -0,0 +1,173 @@ +package audience + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/model" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// PrivacyThreshold is the smallest count the Prolific API reports exactly. +// Anything below it comes back as 0, so that individual participants can't be +// identified from a count. +const PrivacyThreshold = 5 + +// CountOptions is the options for the count command. +type CountOptions struct { + TemplatePath string + FiltersJSON string + WorkspaceID string + JSON bool +} + +// countTemplate is the shape of the -t/--template-path file: a flat list of +// filters, the same format `study create` accepts. Composite (and/or) filter +// groups, which the API also supports, are not represented here. +type countTemplate struct { + Filters []model.Filter `mapstructure:"filters"` +} + +// CountResult is the machine-readable shape emitted by --json. Count is 0 both +// when nobody is eligible and when fewer than PrivacyThreshold are, so +// BelowPrivacyThreshold tells a caller to treat a 0 as "unknown, but small" +// rather than "none". +type CountResult struct { + Count int `json:"count"` + BelowPrivacyThreshold bool `json:"below_privacy_threshold"` +} + +// NewCountCommand creates a new `audience count` command to count how many +// participants match a set of filters, without creating a study or saving a +// filter set. +func NewCountCommand(client client.API, w io.Writer) *cobra.Command { + var opts CountOptions + + cmd := &cobra.Command{ + Use: "count", + Short: "Count participants matching a set of filters", + Long: `Count how many participants would be eligible for a study defined by a +set of filters, without creating the study or saving a filter set. + +Count a set of filters given via -t/--template-path or --filters. + +Counts below 5 are reported as 0 by the Prolific API, to protect +participant privacy. A count of 0 may mean either "zero eligible" or +"somewhere between 1 and 4 eligible" — the CLI cannot tell these apart. + +Filters are a flat list, which the API combines with AND. The API also +supports nested and/or filter groups, but those cannot yet be expressed +via -t/--template-path or --filters.`, + Example: ` +Count participants matching the filters in a JSON/YAML file (see +"prolific study create --help" for the filter format) +$ prolific audience count -t /path/to/filters.json -w + +Count participants matching filters given directly as a flag +$ prolific audience count --filters '[{"filter_id":"age","selected_range":{"lower":18,"upper":65}}]' -w + +Emit machine-readable output for scripting +$ prolific audience count -t /path/to/filters.json -w --json`, + RunE: func(cmd *cobra.Command, args []string) error { + usingTemplate := opts.TemplatePath != "" + usingFlags := opts.FiltersJSON != "" + + switch { + case usingTemplate && usingFlags: + return fmt.Errorf("error: use only one of -t/--template-path or --filters") + case !usingTemplate && !usingFlags: + return fmt.Errorf("error: a filter template or --filters is required, use -t/--template-path or --filters") + } + + if opts.WorkspaceID == "" { + return fmt.Errorf("error: workspace ID is required") + } + + count, err := getCount(client, opts) + if err != nil { + return fmt.Errorf("error: %s", err) + } + + rendered, err := RenderCount(count, opts.JSON) + if err != nil { + return fmt.Errorf("error: %s", err) + } + + fmt.Fprintln(w, rendered) + + return nil + }, + } + + flags := cmd.Flags() + flags.StringVarP(&opts.TemplatePath, "template-path", "t", "", "Path to a YAML/JSON file containing the filters to count against.") + flags.StringVar(&opts.FiltersJSON, "filters", "", `JSON array of filters to count against, e.g. '[{"filter_id":"age","selected_range":{"lower":18,"upper":65}}]'. Alternative to -t/--template-path.`) + flags.StringVarP(&opts.WorkspaceID, "workspace", "w", viper.GetString("workspace"), "The workspace ID to count eligible participants for (required).") + // -j is bound by hand rather than through shared.AddOutputFlags, which + // would claim -t for --table and collide with --template-path. + flags.BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") + + return cmd +} + +func getCount(c client.API, opts CountOptions) (int, error) { + var tmpl countTemplate + + if opts.FiltersJSON != "" { + if err := json.Unmarshal([]byte(opts.FiltersJSON), &tmpl.Filters); err != nil { + return 0, fmt.Errorf("unable to parse --filters as JSON: %s", err) + } + } else { + v := viper.New() + v.SetConfigFile(opts.TemplatePath) + if err := v.ReadInConfig(); err != nil { + return 0, err + } + + if err := v.Unmarshal(&tmpl); err != nil { + return 0, fmt.Errorf("unable to map %s to filters: %s", opts.TemplatePath, err) + } + } + + // The API requires "filters" to be present and non-null, even when empty. + if tmpl.Filters == nil { + tmpl.Filters = []model.Filter{} + } + + response, err := c.GetEligibilityCount(client.EligibilityCountPayload{ + Filters: tmpl.Filters, + WorkspaceID: opts.WorkspaceID, + }) + if err != nil { + return 0, err + } + + return response.Count, nil +} + +// RenderCount produces output for an eligibility count, flagging the +// sub-PrivacyThreshold floor explicitly rather than presenting 0 as an exact +// count. +func RenderCount(count int, asJSON bool) (string, error) { + if asJSON { + payload, err := json.Marshal(CountResult{ + Count: count, + BelowPrivacyThreshold: count == 0, + }) + if err != nil { + return "", err + } + + return string(payload), nil + } + + if count == 0 { + return fmt.Sprintf("Eligible participants: 0 (or fewer than %d — exact counts under %d aren't shown, to protect participant privacy)", PrivacyThreshold, PrivacyThreshold), nil + } + + return fmt.Sprintf("Eligible participants: %d", count), nil +} diff --git a/cmd/audience/count_test.go b/cmd/audience/count_test.go new file mode 100644 index 00000000..d0300141 --- /dev/null +++ b/cmd/audience/count_test.go @@ -0,0 +1,418 @@ +package audience_test + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "os" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/audience" + "github.com/prolific-oss/cli/mock_client" + "github.com/prolific-oss/cli/model" + "github.com/spf13/cobra" +) + +func mustWriteTempTemplate(t *testing.T, content string) string { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "*.json") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(content); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + + return f.Name() +} + +func TestNewCountCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + cmd := audience.NewCountCommand(c, os.Stdout) + + use := "count" + short := "Count participants matching a set of filters" + + if cmd.Use != use { + t.Fatalf("expected use: %s; got %s", use, cmd.Use) + } + + if cmd.Short != short { + t.Fatalf("expected short: %s; got %s", short, cmd.Short) + } +} + +func TestCountIsNestedUnderAudience(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + cmd := audience.NewAudienceCommand(c, os.Stdout) + + sub, _, err := cmd.Find([]string{"count"}) + if err != nil { + t.Fatalf("expected count to be a subcommand of audience: %s", err) + } + + if sub.Use != "count" { + t.Fatalf("expected use: count; got %s", sub.Use) + } +} + +func TestCountCommandRendersCountFromTemplate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + // Asserted as marshalled JSON rather than a struct, so that the pointer + // in SelectedRange and the any-typed bounds compare by wire shape. + var actualPayload client.EligibilityCountPayload + + c. + EXPECT(). + GetEligibilityCount(gomock.Any()). + DoAndReturn(func(payload client.EligibilityCountPayload) (*client.EligibilityCountResponse, error) { + actualPayload = payload + return &client.EligibilityCountResponse{Count: 1234}, nil + }). + Times(1) + + templatePath := mustWriteTempTemplate(t, `{ + "filters": [{"filter_id": "age", "selected_range": {"lower": 30, "upper": 40}}] + }`) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + + cmd := audience.NewCountCommand(c, writer) + _ = cmd.Flags().Set("template-path", templatePath) + _ = cmd.Flags().Set("workspace", "ws-id") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + writer.Flush() + + expectedPayload := `{"filters":[{"filter_id":"age","selected_range":{"lower":30,"upper":40}}],"workspace_id":"ws-id"}` + sentPayload, err := json.Marshal(actualPayload) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if string(sentPayload) != expectedPayload { + t.Fatalf("expected payload %s, got %s", expectedPayload, sentPayload) + } + + expected := "Eligible participants: 1234\n" + if b.String() != expected { + t.Fatalf("expected %q, got %q", expected, b.String()) + } +} + +func TestCountCommandRendersCountFromFlags(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + expectedPayload := client.EligibilityCountPayload{ + Filters: []model.Filter{{FilterID: "age", SelectedRange: &model.FilterRange{Lower: float64(30), Upper: float64(40)}}}, + WorkspaceID: "ws-id", + } + + c. + EXPECT(). + GetEligibilityCount(gomock.Eq(expectedPayload)). + Return(&client.EligibilityCountResponse{Count: 1234}, nil). + Times(1) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + + cmd := audience.NewCountCommand(c, writer) + _ = cmd.Flags().Set("filters", `[{"filter_id":"age","selected_range":{"lower":30,"upper":40}}]`) + _ = cmd.Flags().Set("workspace", "ws-id") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + writer.Flush() + + expected := "Eligible participants: 1234\n" + if b.String() != expected { + t.Fatalf("expected %q, got %q", expected, b.String()) + } +} + +func TestCountCommandHandlesMalformedFlagJSON(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + + cmd := audience.NewCountCommand(c, writer) + _ = cmd.Flags().Set("filters", `not-json`) + _ = cmd.Flags().Set("workspace", "ws-id") + + err := cmd.RunE(cmd, nil) + if err == nil || !strings.HasPrefix(err.Error(), "error: unable to parse --filters as JSON:") { + t.Fatalf("expected a --filters parse error, got %v", err) + } +} + +func TestCountCommandRendersJSON(t *testing.T) { + tests := []struct { + name string + count int + expected string + }{ + { + name: "exact count", + count: 1234, + expected: `{"count":1234,"below_privacy_threshold":false}` + "\n", + }, + { + name: "obscured count", + count: 0, + expected: `{"count":0,"below_privacy_threshold":true}` + "\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + c. + EXPECT(). + GetEligibilityCount(gomock.Any()). + Return(&client.EligibilityCountResponse{Count: tt.count}, nil). + Times(1) + + templatePath := mustWriteTempTemplate(t, `{"filters": []}`) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + + cmd := audience.NewCountCommand(c, writer) + _ = cmd.Flags().Set("template-path", templatePath) + _ = cmd.Flags().Set("workspace", "ws-id") + _ = cmd.Flags().Set("json", "true") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + writer.Flush() + + if b.String() != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, b.String()) + } + }) + } +} + +func TestCountCommandSendsEmptyFiltersNotNil(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + expectedPayload := client.EligibilityCountPayload{ + Filters: []model.Filter{}, + WorkspaceID: "ws-id", + } + + c. + EXPECT(). + GetEligibilityCount(gomock.Eq(expectedPayload)). + Return(&client.EligibilityCountResponse{Count: 0}, nil). + Times(1) + + // No "filters" key at all — must not leave Filters nil, since the API + // rejects a null filters field. + templatePath := mustWriteTempTemplate(t, `{}`) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + + cmd := audience.NewCountCommand(c, writer) + _ = cmd.Flags().Set("template-path", templatePath) + _ = cmd.Flags().Set("workspace", "ws-id") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected error: %s", err) + } +} + +func TestCountCommandValidatesInput(t *testing.T) { + tests := []struct { + name string + templateJSON string // empty means -t/--template-path is left unset + filtersJSON string + workspaceID string + expectedError string + }{ + { + name: "no template or filters", + workspaceID: "ws-id", + expectedError: "error: a filter template or --filters is required, use -t/--template-path or --filters", + }, + { + name: "both template and filters", + templateJSON: `{"filters": []}`, + filtersJSON: `[]`, + workspaceID: "ws-id", + expectedError: "error: use only one of -t/--template-path or --filters", + }, + { + name: "missing workspace with template", + templateJSON: `{"filters": []}`, + expectedError: "error: workspace ID is required", + }, + { + name: "missing workspace with filters", + filtersJSON: `[]`, + expectedError: "error: workspace ID is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + cmd := audience.NewCountCommand(c, writer) + + if tt.templateJSON != "" { + _ = cmd.Flags().Set("template-path", mustWriteTempTemplate(t, tt.templateJSON)) + } + _ = cmd.Flags().Set("filters", tt.filtersJSON) + _ = cmd.Flags().Set("workspace", tt.workspaceID) + + err := cmd.RunE(cmd, nil) + if err == nil || err.Error() != tt.expectedError { + t.Fatalf("expected %q, got %v", tt.expectedError, err) + } + }) + } +} + +func TestCountCommandHandlesFailureToReadConfig(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + + cmd := audience.NewCountCommand(c, writer) + _ = cmd.Flags().Set("template-path", "broken-path.json") + _ = cmd.Flags().Set("workspace", "ws-id") + + err := cmd.RunE(cmd, nil) + writer.Flush() + + expected := "error: open broken-path.json: no such file or directory" + if err == nil || err.Error() != expected { + t.Fatalf("expected %q, got %v", expected, err) + } +} + +func TestCountCommandHandlesAPIError(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, c *mock_client.MockAPI, cmd *cobra.Command) + }{ + { + name: "eligibility count fails", + setup: func(t *testing.T, c *mock_client.MockAPI, cmd *cobra.Command) { + c.EXPECT().GetEligibilityCount(gomock.Any()).Return(nil, errors.New("boom")).Times(1) + _ = cmd.Flags().Set("template-path", mustWriteTempTemplate(t, `{"filters": []}`)) + _ = cmd.Flags().Set("workspace", "ws-id") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + cmd := audience.NewCountCommand(c, writer) + + tt.setup(t, c, cmd) + + err := cmd.RunE(cmd, nil) + + expected := "error: boom" + if err == nil || err.Error() != expected { + t.Fatalf("expected %q, got %v", expected, err) + } + }) + } +} + +func TestRenderCount(t *testing.T) { + tests := []struct { + name string + count int + asJSON bool + expected string + }{ + { + name: "exact count", + count: 1234, + expected: "Eligible participants: 1234", + }, + { + name: "obscured count names the privacy floor", + count: 0, + expected: "Eligible participants: 0 (or fewer than 5 — exact counts under 5 aren't shown, to protect participant privacy)", + }, + { + name: "json exact count", + count: 1234, + asJSON: true, + expected: `{"count":1234,"below_privacy_threshold":false}`, + }, + { + name: "json obscured count", + count: 0, + asJSON: true, + expected: `{"count":0,"below_privacy_threshold":true}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := audience.RenderCount(tt.count, tt.asJSON) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if actual != tt.expected { + t.Fatalf("expected %q, got %q", tt.expected, actual) + } + }) + } +} diff --git a/cmd/eligibilitycount/count.go b/cmd/eligibilitycount/count.go deleted file mode 100644 index 3dfab15d..00000000 --- a/cmd/eligibilitycount/count.go +++ /dev/null @@ -1,109 +0,0 @@ -package eligibilitycount - -import ( - "fmt" - "io" - - "github.com/prolific-oss/cli/client" - "github.com/prolific-oss/cli/model" - - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// CountOptions is the options for the eligibility-count command. -type CountOptions struct { - TemplatePath string - WorkspaceID string -} - -// countTemplate is the shape of the -t/--template-path file: a flat list of -// filters, the same format `study create` accepts. Composite (and/or) filter -// groups, which the API also supports, are not represented here. -type countTemplate struct { - Filters []model.Filter `mapstructure:"filters"` -} - -// NewCountCommand creates a new `eligibility-count` command to count how many -// participants match a set of filters, without creating a study. -func NewCountCommand(client client.API, w io.Writer) *cobra.Command { - var opts CountOptions - - cmd := &cobra.Command{ - Use: "eligibility-count", - Short: "Count participants matching a set of filters", - Long: `Count how many participants would be eligible for a study defined by a -set of filters, without creating the study. - -Counts below 25 are reported as 0 by the Prolific API, to protect -participant privacy. A count of 0 may mean either "zero eligible" or -"somewhere between 1 and 24 eligible" — the CLI cannot tell these apart.`, - Example: ` -Count participants matching the filters in a JSON/YAML file (see -"prolific study create --help" for the filter format) -$ prolific eligibility-count -t /path/to/filters.json -w `, - RunE: func(cmd *cobra.Command, args []string) error { - if opts.TemplatePath == "" { - return fmt.Errorf("error: a filter template is required, use -t/--template-path") - } - - if opts.WorkspaceID == "" { - return fmt.Errorf("error: workspace ID is required") - } - - count, err := getEligibilityCount(client, opts) - if err != nil { - return fmt.Errorf("error: %s", err) - } - - fmt.Fprintln(w, RenderCount(count)) - - return nil - }, - } - - flags := cmd.Flags() - flags.StringVarP(&opts.TemplatePath, "template-path", "t", "", "Path to a YAML/JSON file containing the filters to count against (required).") - flags.StringVarP(&opts.WorkspaceID, "workspace", "w", viper.GetString("workspace"), "The workspace ID to count eligible participants for (required).") - - return cmd -} - -func getEligibilityCount(c client.API, opts CountOptions) (int, error) { - v := viper.New() - v.SetConfigFile(opts.TemplatePath) - if err := v.ReadInConfig(); err != nil { - return 0, err - } - - var tmpl countTemplate - if err := v.Unmarshal(&tmpl); err != nil { - return 0, fmt.Errorf("unable to map %s to filters: %s", opts.TemplatePath, err) - } - - // The API requires "filters" to be present and non-null, even when empty. - if tmpl.Filters == nil { - tmpl.Filters = []model.Filter{} - } - - response, err := c.GetEligibilityCount(client.EligibilityCountPayload{ - Filters: tmpl.Filters, - WorkspaceID: opts.WorkspaceID, - }) - if err != nil { - return 0, err - } - - return response.Count, nil -} - -// RenderCount produces a human-readable line for an eligibility count, -// flagging the sub-25 privacy floor explicitly rather than presenting 0 as -// an exact count. -func RenderCount(count int) string { - if count == 0 { - return "Eligible participants: 0 (or fewer than 25 — exact counts under 25 aren't shown, to protect participant privacy)" - } - - return fmt.Sprintf("Eligible participants: %d", count) -} diff --git a/cmd/eligibilitycount/count_test.go b/cmd/eligibilitycount/count_test.go deleted file mode 100644 index 87c5938a..00000000 --- a/cmd/eligibilitycount/count_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package eligibilitycount_test - -import ( - "bufio" - "bytes" - "errors" - "os" - "strings" - "testing" - - "github.com/golang/mock/gomock" - "github.com/prolific-oss/cli/client" - "github.com/prolific-oss/cli/cmd/eligibilitycount" - "github.com/prolific-oss/cli/mock_client" - "github.com/prolific-oss/cli/model" -) - -func TestNewCountCommand(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - cmd := eligibilitycount.NewCountCommand(c, os.Stdout) - - use := "eligibility-count" - short := "Count participants matching a set of filters" - - if cmd.Use != use { - t.Fatalf("expected use: %s; got %s", use, cmd.Use) - } - - if cmd.Short != short { - t.Fatalf("expected short: %s; got %s", short, cmd.Short) - } -} - -func TestCountCommandRendersCount(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - expectedPayload := client.EligibilityCountPayload{ - Filters: []model.Filter{ - {FilterID: "age", SelectedRange: &model.FilterRange{Lower: float64(18), Upper: float64(65)}}, - }, - WorkspaceID: "ws-id", - } - - c. - EXPECT(). - GetEligibilityCount(gomock.Eq(expectedPayload)). - Return(&client.EligibilityCountResponse{Count: 142}, nil). - Times(1) - - f, err := os.CreateTemp(t.TempDir(), "*.json") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { f.Close() }) - if _, err := f.WriteString(`{"filters": [{"filter_id": "age", "selected_range": {"lower": 18, "upper": 65}}]}`); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - var b bytes.Buffer - writer := bufio.NewWriter(&b) - - cmd := eligibilitycount.NewCountCommand(c, writer) - _ = cmd.Flags().Set("template-path", f.Name()) - _ = cmd.Flags().Set("workspace", "ws-id") - - if err := cmd.RunE(cmd, nil); err != nil { - t.Fatalf("unexpected error: %s", err) - } - - writer.Flush() - - expected := "Eligible participants: 142\n" - if b.String() != expected { - t.Fatalf("expected %q, got %q", expected, b.String()) - } -} - -func TestCountCommandFlagsSubTwentyFiveCounts(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - c. - EXPECT(). - GetEligibilityCount(gomock.Any()). - Return(&client.EligibilityCountResponse{Count: 0}, nil). - Times(1) - - f, err := os.CreateTemp(t.TempDir(), "*.json") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { f.Close() }) - if _, err := f.WriteString(`{"filters": []}`); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - var b bytes.Buffer - writer := bufio.NewWriter(&b) - - cmd := eligibilitycount.NewCountCommand(c, writer) - _ = cmd.Flags().Set("template-path", f.Name()) - _ = cmd.Flags().Set("workspace", "ws-id") - - if err := cmd.RunE(cmd, nil); err != nil { - t.Fatalf("unexpected error: %s", err) - } - - writer.Flush() - - if !strings.Contains(b.String(), "fewer than 25") { - t.Fatalf("expected sub-25 caveat in output, got %q", b.String()) - } -} - -func TestCountCommandSendsEmptyFiltersNotNil(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - expectedPayload := client.EligibilityCountPayload{ - Filters: []model.Filter{}, - WorkspaceID: "ws-id", - } - - c. - EXPECT(). - GetEligibilityCount(gomock.Eq(expectedPayload)). - Return(&client.EligibilityCountResponse{Count: 30}, nil). - Times(1) - - f, err := os.CreateTemp(t.TempDir(), "*.json") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { f.Close() }) - // No "filters" key at all — must not leave Filters nil, since the API - // rejects a null filters field. - if _, err := f.WriteString(`{}`); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - var b bytes.Buffer - writer := bufio.NewWriter(&b) - - cmd := eligibilitycount.NewCountCommand(c, writer) - _ = cmd.Flags().Set("template-path", f.Name()) - _ = cmd.Flags().Set("workspace", "ws-id") - - if err := cmd.RunE(cmd, nil); err != nil { - t.Fatalf("unexpected error: %s", err) - } -} - -func TestCountCommandRequiresTemplatePath(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - var b bytes.Buffer - writer := bufio.NewWriter(&b) - - cmd := eligibilitycount.NewCountCommand(c, writer) - _ = cmd.Flags().Set("workspace", "ws-id") - - err := cmd.RunE(cmd, nil) - - expected := "error: a filter template is required, use -t/--template-path" - if err == nil || err.Error() != expected { - t.Fatalf("expected %q, got %v", expected, err) - } -} - -func TestCountCommandRequiresWorkspace(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - f, err := os.CreateTemp(t.TempDir(), "*.json") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { f.Close() }) - if _, err := f.WriteString(`{"filters": []}`); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - var b bytes.Buffer - writer := bufio.NewWriter(&b) - - cmd := eligibilitycount.NewCountCommand(c, writer) - _ = cmd.Flags().Set("template-path", f.Name()) - _ = cmd.Flags().Set("workspace", "") - - err = cmd.RunE(cmd, nil) - - expected := "error: workspace ID is required" - if err == nil || err.Error() != expected { - t.Fatalf("expected %q, got %v", expected, err) - } -} - -func TestCountCommandHandlesFailureToReadConfig(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - var b bytes.Buffer - writer := bufio.NewWriter(&b) - - cmd := eligibilitycount.NewCountCommand(c, writer) - _ = cmd.Flags().Set("template-path", "broken-path.json") - _ = cmd.Flags().Set("workspace", "ws-id") - - err := cmd.RunE(cmd, nil) - writer.Flush() - - expected := "error: open broken-path.json: no such file or directory" - if err == nil || err.Error() != expected { - t.Fatalf("expected %q, got %v", expected, err) - } -} - -func TestCountCommandHandlesFailureToUnmarshalConfig(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - f, err := os.CreateTemp(t.TempDir(), "*.yaml") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { f.Close() }) - // weightings must be a map; a scalar causes Unmarshal to fail - if _, err = f.WriteString("filters:\n - filter_id: age\n weightings: not-a-map\n"); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - var b bytes.Buffer - writer := bufio.NewWriter(&b) - - cmd := eligibilitycount.NewCountCommand(c, writer) - _ = cmd.Flags().Set("template-path", f.Name()) - _ = cmd.Flags().Set("workspace", "ws-id") - - err = cmd.RunE(cmd, nil) - writer.Flush() - - if err == nil || !strings.Contains(err.Error(), "unable to map") { - t.Fatalf("expected unmarshal error, got %v", err) - } -} - -func TestCountCommandHandlesAPIError(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - c. - EXPECT(). - GetEligibilityCount(gomock.Any()). - Return(nil, errors.New("boom")). - Times(1) - - f, err := os.CreateTemp(t.TempDir(), "*.json") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { f.Close() }) - if _, err := f.WriteString(`{"filters": []}`); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - - var b bytes.Buffer - writer := bufio.NewWriter(&b) - - cmd := eligibilitycount.NewCountCommand(c, writer) - _ = cmd.Flags().Set("template-path", f.Name()) - _ = cmd.Flags().Set("workspace", "ws-id") - - err = cmd.RunE(cmd, nil) - - expected := "error: boom" - if err == nil || err.Error() != expected { - t.Fatalf("expected %q, got %v", expected, err) - } -} diff --git a/cmd/root.go b/cmd/root.go index d4536837..7ce83bb0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,11 +11,11 @@ import ( homedir "github.com/mitchellh/go-homedir" "github.com/prolific-oss/cli/client" "github.com/prolific-oss/cli/cmd/aitaskbuilder" + "github.com/prolific-oss/cli/cmd/audience" "github.com/prolific-oss/cli/cmd/bonus" "github.com/prolific-oss/cli/cmd/campaign" "github.com/prolific-oss/cli/cmd/collection" "github.com/prolific-oss/cli/cmd/credentials" - "github.com/prolific-oss/cli/cmd/eligibilitycount" "github.com/prolific-oss/cli/cmd/feedback" "github.com/prolific-oss/cli/cmd/filters" "github.com/prolific-oss/cli/cmd/filtersets" @@ -78,11 +78,11 @@ func NewRootCommand() *cobra.Command { cmd.AddCommand( aitaskbuilder.NewAITaskBuilderCommand(&client, w), + audience.NewAudienceCommand(&client, w), bonus.NewBonusCommand(&client, w), campaign.NewListCommand("campaign", &client, w), collection.NewCollectionCommand(&client, w), credentials.NewCredentialsCommand(&client, w), - eligibilitycount.NewCountCommand(&client, w), feedback.NewFeedbackCommand(&client, w), filters.NewListCommand(&client, w), filtersets.NewFilterSetCommand(&client, w), diff --git a/docs/examples/audience-count.json b/docs/examples/audience-count.json new file mode 100644 index 00000000..7ee17d68 --- /dev/null +++ b/docs/examples/audience-count.json @@ -0,0 +1,15 @@ +{ + "filters": [ + { + "filter_id": "age", + "selected_range": { + "lower": 18, + "upper": 30 + } + }, + { + "filter_id": "sex", + "selected_values": ["0"] + } + ] +}