From 46f08dc096c219088c13ba55afd14c4621bcaab0 Mon Sep 17 00:00:00 2001 From: Sarah Weeks Date: Mon, 28 Sep 2026 16:41:44 +0100 Subject: [PATCH 1/5] feat(BUZZ-231): add command for count --- cmd/audience/audience.go | 22 ++ cmd/audience/count.go | 174 +++++++++++++ cmd/audience/count_test.go | 399 ++++++++++++++++++++++++++++++ cmd/root.go | 2 + docs/examples/audience-count.json | 15 ++ 5 files changed, 612 insertions(+) create mode 100644 cmd/audience/audience.go create mode 100644 cmd/audience/count.go create mode 100644 cmd/audience/count_test.go create mode 100644 docs/examples/audience-count.json 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..389554c6 --- /dev/null +++ b/cmd/audience/count.go @@ -0,0 +1,174 @@ +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 + FilterSetID 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 either an unsaved set of filters with -t/--template-path, or a +saved filter set with --filter-set. + +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. + +A template holds a flat list of filters, which the API combines with AND. +The API also supports nested and/or filter groups, but those cannot yet be +expressed in a CLI template.`, + 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 a saved filter set +$ prolific audience count --filter-set + +Emit machine-readable output for scripting +$ prolific audience count -t /path/to/filters.json -w --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.TemplatePath == "" && opts.FilterSetID == "" { + return fmt.Errorf("error: a filter template or a filter set is required, use -t/--template-path or --filter-set") + } + + if opts.TemplatePath != "" && opts.FilterSetID != "" { + return fmt.Errorf("error: -t/--template-path and --filter-set cannot be used together") + } + + if opts.TemplatePath != "" && 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.FilterSetID, "filter-set", "", "ID of a saved filter set to count, instead of a template.") + flags.StringVarP(&opts.WorkspaceID, "workspace", "w", viper.GetString("workspace"), "The workspace ID to count eligible participants for (required with -t/--template-path).") + // -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) { + if opts.FilterSetID != "" { + filterSet, err := c.GetFilterSet(opts.FilterSetID) + if err != nil { + return 0, err + } + + return filterSet.EligibleParticipantCount, nil + } + + 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 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..92bfd3be --- /dev/null +++ b/cmd/audience/count_test.go @@ -0,0 +1,399 @@ +package audience_test + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "os" + "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 TestCountCommandCountsSavedFilterSet(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + c. + EXPECT(). + GetFilterSet(gomock.Eq("filter-set-id")). + Return(&model.FilterSet{ID: "filter-set-id", EligibleParticipantCount: 42}, nil). + Times(1) + + // A saved filter set is counted from the single record the API already + // returns, so no eligibility count call is made. + c.EXPECT().GetEligibilityCount(gomock.Any()).Times(0) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + + cmd := audience.NewCountCommand(c, writer) + _ = cmd.Flags().Set("filter-set", "filter-set-id") + + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + writer.Flush() + + expected := "Eligible participants: 42\n" + if b.String() != expected { + t.Fatalf("expected %q, got %q", expected, b.String()) + } +} + +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 + filterSetID string + workspaceID string + expectedError string + }{ + { + name: "no template and no filter set", + workspaceID: "ws-id", + expectedError: "error: a filter template or a filter set is required, use -t/--template-path or --filter-set", + }, + { + name: "both template and filter set", + templateJSON: `{"filters": []}`, + filterSetID: "filter-set-id", + workspaceID: "ws-id", + expectedError: "error: -t/--template-path and --filter-set cannot be used together", + }, + { + name: "missing workspace", + templateJSON: `{"filters": []}`, + 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("filter-set", tt.filterSetID) + _ = 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") + }, + }, + { + name: "filter set lookup fails", + setup: func(t *testing.T, c *mock_client.MockAPI, cmd *cobra.Command) { + c.EXPECT().GetFilterSet(gomock.Any()).Return(nil, errors.New("boom")).Times(1) + _ = cmd.Flags().Set("filter-set", "filter-set-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/root.go b/cmd/root.go index d4536837..72811a0e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ 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" @@ -78,6 +79,7 @@ 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), 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"] + } + ] +} From 40e9f23bd782e6b8f607525dc5469dc7e3146f70 Mon Sep 17 00:00:00 2001 From: Sarah Weeks Date: Tue, 29 Sep 2026 09:34:52 +0100 Subject: [PATCH 2/5] fix(BUZZ-231): record unmapped export and conversation operations Co-authored-by: Cursor --- contract_test/contract_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contract_test/contract_test.go b/contract_test/contract_test.go index fe001592..17bfbf81 100644 --- a/contract_test/contract_test.go +++ b/contract_test/contract_test.go @@ -197,6 +197,8 @@ var operations = []operation{ {operationID: "aiTaskBuilder_GetBatchSyncStatus", call: func(c *client.Client) { c.GetAITaskBuilderBatchSyncStatus("batch-id", "sync-id") }}, {operationID: "aiTaskBuilder_RequestBatchExport", call: func(c *client.Client) { c.InitiateBatchExport("batch-id") }}, {operationID: "aiTaskBuilder_GetBatchExportStatus", call: func(c *client.Client) { c.GetBatchExportStatus("batch-id", "export-id") }}, + {operationID: "aiTaskBuilder_ListBatchExportJobs", skip: "OUTOFSCOPE: no CLI command for listing batch export jobs"}, + {operationID: "aiTaskBuilder_DeleteBatchExport", skip: "OUTOFSCOPE: no CLI command for deleting a batch export"}, // AI Task Builder — Datasets {operationID: "aiTaskBuilder_CreateTaskBuilderDataset", call: func(c *client.Client) { @@ -244,6 +246,8 @@ var operations = []operation{ {operationID: "aiTaskBuilder_GetCollectionExportStatus", call: func(c *client.Client) { c.GetCollectionExportStatus("coll-id", "export-id") }}, + {operationID: "aiTaskBuilder_ListCollectionExportJobs", skip: "OUTOFSCOPE: no CLI command for listing collection export jobs"}, + {operationID: "aiTaskBuilder_DeleteCollectionExport", skip: "OUTOFSCOPE: no CLI command for deleting a collection export"}, // Invitations {operationID: "invitations_CreateInvitation", call: func(c *client.Client) { @@ -269,6 +273,7 @@ var operations = []operation{ {operationID: "messages_GetUnreadMessages", call: func(c *client.Client) { c.GetUnreadMessages() }}, {operationID: "messages_GetConversations", skip: "OUTOFSCOPE: no CLI command for listing conversations"}, {operationID: "messages_GetConversationMessages", skip: "OUTOFSCOPE: no CLI command for retrieving conversation messages"}, + {operationID: "messages_CreateConversation", skip: "OUTOFSCOPE: no CLI command for creating a conversation"}, // Studies {operationID: "studies_GetStudies", call: func(c *client.Client) { c.GetStudies("", "") }}, From 5c59ba654c9920d5b3a7593695a6c4026b6b61c8 Mon Sep 17 00:00:00 2001 From: Sarah Weeks Date: Tue, 29 Sep 2026 09:42:31 +0100 Subject: [PATCH 3/5] fix(BUZZ-231): regenrate readme that has unscoped apie changes --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index a58878cc..7413fdf0 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,8 @@ Operations are grouped as they appear in [`contract_test/contract_test.go`](cont | `aiTaskBuilder_GetBatchSyncStatus` | GET | `/api/v1/data-collection/batches/{batch_id}/syncs/{sync_id}` | ✅ `GetAITaskBuilderBatchSyncStatus` | | `aiTaskBuilder_RequestBatchExport` | POST | `/api/v1/data-collection/batches/{batch_id}/export` | ✅ `InitiateBatchExport` | | `aiTaskBuilder_GetBatchExportStatus` | GET | `/api/v1/data-collection/batches/{batch_id}/export/{export_id}` | ✅ `GetBatchExportStatus` | +| `aiTaskBuilder_ListBatchExportJobs` | GET | `/api/v1/data-collection/batches/{batch_id}/export` | ➖ Not exposed in the CLI | +| `aiTaskBuilder_DeleteBatchExport` | DELETE | `/api/v1/data-collection/batches/{batch_id}/export/{export_id}` | ➖ Not exposed in the CLI | @@ -373,6 +375,8 @@ Operations are grouped as they appear in [`contract_test/contract_test.go`](cont | `aiTaskBuilder_GetCollectionResponses` | GET | `/api/v1/data-collection/collections/{collection_id}/responses` | ➖ Not exposed in the CLI | | `aiTaskBuilder_RequestCollectionExport` | POST | `/api/v1/data-collection/collections/{collection_id}/export` | ✅ `InitiateCollectionExport` | | `aiTaskBuilder_GetCollectionExportStatus` | GET | `/api/v1/data-collection/collections/{collection_id}/export/{export_id}` | ✅ `GetCollectionExportStatus` | +| `aiTaskBuilder_ListCollectionExportJobs` | GET | `/api/v1/data-collection/collections/{collection_id}/export` | ➖ Not exposed in the CLI | +| `aiTaskBuilder_DeleteCollectionExport` | DELETE | `/api/v1/data-collection/collections/{collection_id}/export/{export_id}` | ➖ Not exposed in the CLI | @@ -397,6 +401,7 @@ Operations are grouped as they appear in [`contract_test/contract_test.go`](cont | `messages_GetUnreadMessages` | GET | `/api/v1/messages/unread/` | ✅ `GetUnreadMessages` | | `messages_GetConversations` | GET | `/api/v1/conversations/` | ➖ Not exposed in the CLI | | `messages_GetConversationMessages` | GET | `/api/v1/conversations/{conversation_id}/messages/` | ➖ Not exposed in the CLI | +| `messages_CreateConversation` | POST | `/api/v1/conversations/` | ➖ Not exposed in the CLI | From 2669bcd22537463e7cb9de880b871f2fa203098c Mon Sep 17 00:00:00 2001 From: Sarah Weeks Date: Tue, 29 Sep 2026 16:34:28 +0100 Subject: [PATCH 4/5] fix(BUZZ-231): refactor --- CHANGELOG.md | 11 + cmd/audience/count.go | 55 +++-- cmd/audience/count_test.go | 83 +++++++- cmd/eligibilitycount/count.go | 109 ---------- cmd/eligibilitycount/count_test.go | 310 ----------------------------- cmd/root.go | 2 - 6 files changed, 125 insertions(+), 445 deletions(-) delete mode 100644 cmd/eligibilitycount/count.go delete mode 100644 cmd/eligibilitycount/count_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 962bb42e..55c52d1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ +### 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 `--filter-set ` + to count a saved filter set, 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. + ## 1.2.5 - Maintenance and dependency updates diff --git a/cmd/audience/count.go b/cmd/audience/count.go index 389554c6..84c8e995 100644 --- a/cmd/audience/count.go +++ b/cmd/audience/count.go @@ -20,6 +20,7 @@ const PrivacyThreshold = 5 // CountOptions is the options for the count command. type CountOptions struct { TemplatePath string + FiltersJSON string FilterSetID string WorkspaceID string JSON bool @@ -53,36 +54,42 @@ func NewCountCommand(client client.API, w io.Writer) *cobra.Command { 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 either an unsaved set of filters with -t/--template-path, or a -saved filter set with --filter-set. +Count either an unsaved set of filters with -t/--template-path or +--filters, or a saved filter set with --filter-set. 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. -A template holds a flat list of filters, which the API combines with AND. -The API also supports nested and/or filter groups, but those cannot yet be -expressed in a CLI template.`, +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 + Count participants matching a saved filter set $ prolific audience count --filter-set Emit machine-readable output for scripting $ prolific audience count -t /path/to/filters.json -w --json`, RunE: func(cmd *cobra.Command, args []string) error { - if opts.TemplatePath == "" && opts.FilterSetID == "" { - return fmt.Errorf("error: a filter template or a filter set is required, use -t/--template-path or --filter-set") - } - - if opts.TemplatePath != "" && opts.FilterSetID != "" { - return fmt.Errorf("error: -t/--template-path and --filter-set cannot be used together") + usingTemplate := opts.TemplatePath != "" + usingFlags := opts.FiltersJSON != "" + usingFilterSet := opts.FilterSetID != "" + + switch { + case usingTemplate && usingFlags, usingTemplate && usingFilterSet, usingFlags && usingFilterSet: + return fmt.Errorf("error: use only one of -t/--template-path, --filters, or --filter-set") + case !usingTemplate && !usingFlags && !usingFilterSet: + return fmt.Errorf("error: a filter template, --filters, or a filter set is required, use -t/--template-path, --filters, or --filter-set") } - if opts.TemplatePath != "" && opts.WorkspaceID == "" { + if (usingTemplate || usingFlags) && opts.WorkspaceID == "" { return fmt.Errorf("error: workspace ID is required") } @@ -104,6 +111,7 @@ $ prolific audience count -t /path/to/filters.json -w --json`, 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.StringVar(&opts.FilterSetID, "filter-set", "", "ID of a saved filter set to count, instead of a template.") flags.StringVarP(&opts.WorkspaceID, "workspace", "w", viper.GetString("workspace"), "The workspace ID to count eligible participants for (required with -t/--template-path).") // -j is bound by hand rather than through shared.AddOutputFlags, which @@ -123,15 +131,22 @@ func getCount(c client.API, opts CountOptions) (int, error) { return filterSet.EligibleParticipantCount, nil } - 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) + + 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. diff --git a/cmd/audience/count_test.go b/cmd/audience/count_test.go index 92bfd3be..d21e16cd 100644 --- a/cmd/audience/count_test.go +++ b/cmd/audience/count_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "os" + "strings" "testing" "github.com/golang/mock/gomock" @@ -119,6 +120,59 @@ func TestCountCommandRendersCountFromTemplate(t *testing.T) { } } +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 TestCountCommandCountsSavedFilterSet(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -241,27 +295,47 @@ func TestCountCommandValidatesInput(t *testing.T) { tests := []struct { name string templateJSON string // empty means -t/--template-path is left unset + filtersJSON string filterSetID string workspaceID string expectedError string }{ { - name: "no template and no filter set", + name: "no template, filters, or filter set", workspaceID: "ws-id", - expectedError: "error: a filter template or a filter set is required, use -t/--template-path or --filter-set", + expectedError: "error: a filter template, --filters, or a filter set is required, use -t/--template-path, --filters, or --filter-set", }, { name: "both template and filter set", templateJSON: `{"filters": []}`, filterSetID: "filter-set-id", workspaceID: "ws-id", - expectedError: "error: -t/--template-path and --filter-set cannot be used together", + expectedError: "error: use only one of -t/--template-path, --filters, or --filter-set", + }, + { + name: "both template and filters", + templateJSON: `{"filters": []}`, + filtersJSON: `[]`, + workspaceID: "ws-id", + expectedError: "error: use only one of -t/--template-path, --filters, or --filter-set", + }, + { + name: "both filters and filter set", + filtersJSON: `[]`, + filterSetID: "filter-set-id", + workspaceID: "ws-id", + expectedError: "error: use only one of -t/--template-path, --filters, or --filter-set", }, { - name: "missing workspace", + 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 { @@ -277,6 +351,7 @@ func TestCountCommandValidatesInput(t *testing.T) { if tt.templateJSON != "" { _ = cmd.Flags().Set("template-path", mustWriteTempTemplate(t, tt.templateJSON)) } + _ = cmd.Flags().Set("filters", tt.filtersJSON) _ = cmd.Flags().Set("filter-set", tt.filterSetID) _ = cmd.Flags().Set("workspace", tt.workspaceID) 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 72811a0e..7ce83bb0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,7 +16,6 @@ import ( "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" @@ -84,7 +83,6 @@ func NewRootCommand() *cobra.Command { 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), From 5c90d47775b3e1ab329c6e7fdc5ddec6dbb26435 Mon Sep 17 00:00:00 2001 From: Sarah Weeks Date: Wed, 30 Sep 2026 11:44:42 +0100 Subject: [PATCH 5/5] fix(BUZZ-231): remove --filter-set from audience count --- CHANGELOG.md | 5 +-- cmd/audience/count.go | 30 +++++------------- cmd/audience/count_test.go | 62 ++------------------------------------ 3 files changed, 13 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 331c2ae3..8c410993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ - 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 `--filter-set ` - to count a saved filter set, and `-j/--json` for machine-readable output. + -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. diff --git a/cmd/audience/count.go b/cmd/audience/count.go index 84c8e995..40bd5801 100644 --- a/cmd/audience/count.go +++ b/cmd/audience/count.go @@ -21,7 +21,6 @@ const PrivacyThreshold = 5 type CountOptions struct { TemplatePath string FiltersJSON string - FilterSetID string WorkspaceID string JSON bool } @@ -54,8 +53,7 @@ func NewCountCommand(client client.API, w io.Writer) *cobra.Command { 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 either an unsaved set of filters with -t/--template-path or ---filters, or a saved filter set with --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 @@ -72,24 +70,20 @@ $ 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 -Count participants matching a saved filter set -$ prolific audience count --filter-set - 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 != "" - usingFilterSet := opts.FilterSetID != "" switch { - case usingTemplate && usingFlags, usingTemplate && usingFilterSet, usingFlags && usingFilterSet: - return fmt.Errorf("error: use only one of -t/--template-path, --filters, or --filter-set") - case !usingTemplate && !usingFlags && !usingFilterSet: - return fmt.Errorf("error: a filter template, --filters, or a filter set is required, use -t/--template-path, --filters, or --filter-set") + 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 (usingTemplate || usingFlags) && opts.WorkspaceID == "" { + if opts.WorkspaceID == "" { return fmt.Errorf("error: workspace ID is required") } @@ -112,8 +106,7 @@ $ prolific audience count -t /path/to/filters.json -w --json`, 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.StringVar(&opts.FilterSetID, "filter-set", "", "ID of a saved filter set to count, instead of a template.") - flags.StringVarP(&opts.WorkspaceID, "workspace", "w", viper.GetString("workspace"), "The workspace ID to count eligible participants for (required with -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") @@ -122,15 +115,6 @@ $ prolific audience count -t /path/to/filters.json -w --json`, } func getCount(c client.API, opts CountOptions) (int, error) { - if opts.FilterSetID != "" { - filterSet, err := c.GetFilterSet(opts.FilterSetID) - if err != nil { - return 0, err - } - - return filterSet.EligibleParticipantCount, nil - } - var tmpl countTemplate if opts.FiltersJSON != "" { diff --git a/cmd/audience/count_test.go b/cmd/audience/count_test.go index d21e16cd..d0300141 100644 --- a/cmd/audience/count_test.go +++ b/cmd/audience/count_test.go @@ -173,39 +173,6 @@ func TestCountCommandHandlesMalformedFlagJSON(t *testing.T) { } } -func TestCountCommandCountsSavedFilterSet(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - c. - EXPECT(). - GetFilterSet(gomock.Eq("filter-set-id")). - Return(&model.FilterSet{ID: "filter-set-id", EligibleParticipantCount: 42}, nil). - Times(1) - - // A saved filter set is counted from the single record the API already - // returns, so no eligibility count call is made. - c.EXPECT().GetEligibilityCount(gomock.Any()).Times(0) - - var b bytes.Buffer - writer := bufio.NewWriter(&b) - - cmd := audience.NewCountCommand(c, writer) - _ = cmd.Flags().Set("filter-set", "filter-set-id") - - if err := cmd.RunE(cmd, nil); err != nil { - t.Fatalf("unexpected error: %s", err) - } - - writer.Flush() - - expected := "Eligible participants: 42\n" - if b.String() != expected { - t.Fatalf("expected %q, got %q", expected, b.String()) - } -} - func TestCountCommandRendersJSON(t *testing.T) { tests := []struct { name string @@ -296,35 +263,20 @@ func TestCountCommandValidatesInput(t *testing.T) { name string templateJSON string // empty means -t/--template-path is left unset filtersJSON string - filterSetID string workspaceID string expectedError string }{ { - name: "no template, filters, or filter set", - workspaceID: "ws-id", - expectedError: "error: a filter template, --filters, or a filter set is required, use -t/--template-path, --filters, or --filter-set", - }, - { - name: "both template and filter set", - templateJSON: `{"filters": []}`, - filterSetID: "filter-set-id", + name: "no template or filters", workspaceID: "ws-id", - expectedError: "error: use only one of -t/--template-path, --filters, or --filter-set", + 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, --filters, or --filter-set", - }, - { - name: "both filters and filter set", - filtersJSON: `[]`, - filterSetID: "filter-set-id", - workspaceID: "ws-id", - expectedError: "error: use only one of -t/--template-path, --filters, or --filter-set", + expectedError: "error: use only one of -t/--template-path or --filters", }, { name: "missing workspace with template", @@ -352,7 +304,6 @@ func TestCountCommandValidatesInput(t *testing.T) { _ = cmd.Flags().Set("template-path", mustWriteTempTemplate(t, tt.templateJSON)) } _ = cmd.Flags().Set("filters", tt.filtersJSON) - _ = cmd.Flags().Set("filter-set", tt.filterSetID) _ = cmd.Flags().Set("workspace", tt.workspaceID) err := cmd.RunE(cmd, nil) @@ -397,13 +348,6 @@ func TestCountCommandHandlesAPIError(t *testing.T) { _ = cmd.Flags().Set("workspace", "ws-id") }, }, - { - name: "filter set lookup fails", - setup: func(t *testing.T, c *mock_client.MockAPI, cmd *cobra.Command) { - c.EXPECT().GetFilterSet(gomock.Any()).Return(nil, errors.New("boom")).Times(1) - _ = cmd.Flags().Set("filter-set", "filter-set-id") - }, - }, } for _, tt := range tests {