From 5ead92df102023c9d9f07415629317db00314fec Mon Sep 17 00:00:00 2001 From: Stu Burgoyne Date: Tue, 27 Jan 2026 09:14:25 +0000 Subject: [PATCH 1/4] feat(DCP-2190): add collection publish command Add command to publish AI Task Builder Collections as studies. Creates a study with the collection content and transitions it to published state. --- cmd/collection/collection.go | 1 + cmd/collection/publish.go | 132 ++++++++++++++ cmd/collection/publish_test.go | 321 +++++++++++++++++++++++++++++++++ model/study.go | 5 + 4 files changed, 459 insertions(+) create mode 100644 cmd/collection/publish.go create mode 100644 cmd/collection/publish_test.go diff --git a/cmd/collection/collection.go b/cmd/collection/collection.go index 7913406..36c3be8 100644 --- a/cmd/collection/collection.go +++ b/cmd/collection/collection.go @@ -19,6 +19,7 @@ func NewCollectionCommand(client client.API, w io.Writer) *cobra.Command { NewGetCommand(client, w), NewCreateCollectionCommand(client, w), NewUpdateCommand(client, w), + NewPublishCommand(client, w), ) return cmd } diff --git a/cmd/collection/publish.go b/cmd/collection/publish.go new file mode 100644 index 0000000..e5cbd22 --- /dev/null +++ b/cmd/collection/publish.go @@ -0,0 +1,132 @@ +package collection + +import ( + "errors" + "fmt" + "io" + + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/shared" + "github.com/prolific-oss/cli/model" + "github.com/prolific-oss/cli/ui" + studyui "github.com/prolific-oss/cli/ui/study" + "github.com/spf13/cobra" +) + +// PublishOptions is the options for the publish collection command. +type PublishOptions struct { + Args []string + Participants int + Name string + Description string +} + +// NewPublishCommand creates a new `collection publish` command to publish +// a collection as a study. +func NewPublishCommand(c client.API, w io.Writer) *cobra.Command { + var opts PublishOptions + + cmd := &cobra.Command{ + Use: "publish ", + Args: cobra.MinimumNArgs(1), + Short: "Publish a collection as a study", + Long: `Publish a collection as a study + +This command creates and publishes a study from an AI Task Builder Collection. +The study will be created with the collection's content and made available +to participants.`, + Example: ` +Publish a collection with 100 participants: + +$ prolific collection publish 67890abcdef --participants 100 + +Publish with a custom study name: + +$ prolific collection publish 67890abcdef -p 50 --name "My Custom Study" +`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Args = args + + if len(opts.Args) < 1 || opts.Args[0] == "" { + return errors.New("please provide a collection ID") + } + + if opts.Participants <= 0 { + return errors.New("please provide a valid number of participants using --participants or -p") + } + + return publishCollection(c, opts, w) + }, + } + + flags := cmd.Flags() + flags.IntVarP(&opts.Participants, "participants", "p", 0, "Number of participants required (required)") + flags.StringVarP(&opts.Name, "name", "n", "", "Study name (defaults to collection's task name)") + flags.StringVarP(&opts.Description, "description", "d", "", "Study description (defaults to collection's task introduction)") + + return cmd +} + +func publishCollection(c client.API, opts PublishOptions, w io.Writer) error { + collectionID := opts.Args[0] + + // Fetch the collection to get default name/description + coll, err := c.GetCollection(collectionID) + if err != nil { + if shared.IsFeatureNotEnabledError(err) { + ui.RenderFeatureAccessMessage(FeatureNameAITBCollection, FeatureContactURLAITBCollection) + return nil + } + return fmt.Errorf("failed to get collection: %s", err.Error()) + } + + // Use collection details as defaults if not provided + studyName := opts.Name + if studyName == "" && coll.TaskDetails != nil { + studyName = coll.TaskDetails.TaskName + } + if studyName == "" { + studyName = coll.Name + } + + studyDescription := opts.Description + if studyDescription == "" && coll.TaskDetails != nil { + studyDescription = coll.TaskDetails.TaskIntroduction + } + if studyDescription == "" { + studyDescription = fmt.Sprintf("Study for collection: %s", coll.Name) + } + + // Create the study with collection-specific configuration + createStudy := model.CreateStudy{ + Name: studyName, + InternalName: studyName, + Description: studyDescription, + TotalAvailablePlaces: opts.Participants, + DataCollectionMethod: model.DataCollectionMethodAITBCollection, + DataCollectionID: collectionID, + } + + study, err := c.CreateStudy(createStudy) + if err != nil { + return fmt.Errorf("failed to create study: %s", err.Error()) + } + + // Transition the study to publish + _, err = c.TransitionStudy(study.ID, model.TransitionStudyPublish) + if err != nil { + return fmt.Errorf("failed to publish study: %s", err.Error()) + } + + // Fetch the updated study to get the latest status + study, err = c.GetStudy(study.ID) + if err != nil { + return fmt.Errorf("failed to get study details: %s", err.Error()) + } + + // Display the result + fmt.Fprintln(w, studyui.RenderStudy(*study)) + fmt.Fprintf(w, "\nStudy URL: %s\n", studyui.GetStudyURL(study.ID)) + + return nil +} diff --git a/cmd/collection/publish_test.go b/cmd/collection/publish_test.go new file mode 100644 index 0000000..0131ae0 --- /dev/null +++ b/cmd/collection/publish_test.go @@ -0,0 +1,321 @@ +package collection_test + +import ( + "bytes" + "errors" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/collection" + "github.com/prolific-oss/cli/mock_client" + "github.com/prolific-oss/cli/model" +) + +func TestNewPublishCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + + use := "publish " + short := "Publish a collection as a study" + + 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 TestPublishCommandRequiresCollectionID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + + err := cmd.RunE(cmd, []string{}) + if err == nil { + t.Fatalf("expected error for missing collection ID, got nil") + } +} + +func TestPublishCommandRequiresParticipants(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err == nil { + t.Fatalf("expected error for missing participants, got nil") + } + + expectedErr := "please provide a valid number of participants" + if err.Error() != "please provide a valid number of participants using --participants or -p" { + t.Fatalf("expected error message containing %q, got: %s", expectedErr, err.Error()) + } +} + +func TestPublishCommandSuccess(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + TaskDetails: &model.TaskDetails{ + TaskName: "Test Task Name", + TaskIntroduction: "Test task introduction", + }, + } + + testStudy := &model.Study{ + ID: "study-123", + Name: "Test Task Name", + Status: "active", + TotalAvailablePlaces: 100, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + DoAndReturn(func(s model.CreateStudy) (*model.Study, error) { + if s.DataCollectionMethod != model.DataCollectionMethodAITBCollection { + t.Errorf("expected DataCollectionMethod %s, got %s", model.DataCollectionMethodAITBCollection, s.DataCollectionMethod) + } + if s.DataCollectionID != testCollectionID { + t.Errorf("expected DataCollectionID %s, got %s", testCollectionID, s.DataCollectionID) + } + if s.TotalAvailablePlaces != 100 { + t.Errorf("expected TotalAvailablePlaces 100, got %d", s.TotalAvailablePlaces) + } + return testStudy, nil + }). + Times(1) + + mockClient. + EXPECT(). + TransitionStudy(gomock.Eq("study-123"), gomock.Eq(model.TransitionStudyPublish)). + Return(&client.TransitionStudyResponse{}, nil). + Times(1) + + mockClient. + EXPECT(). + GetStudy(gomock.Eq("study-123")). + Return(testStudy, nil). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("participants", "100") + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := buf.String() + if output == "" { + t.Fatal("expected output, got empty string") + } +} + +func TestPublishCommandCollectionNotFound(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(nil, errors.New("collection not found")). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("participants", "100") + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !bytes.Contains([]byte(err.Error()), []byte("failed to get collection")) { + t.Errorf("expected error to contain 'failed to get collection', got: %s", err.Error()) + } +} + +func TestPublishCommandCreateStudyError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + Return(nil, errors.New("validation error")). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("participants", "100") + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !bytes.Contains([]byte(err.Error()), []byte("failed to create study")) { + t.Errorf("expected error to contain 'failed to create study', got: %s", err.Error()) + } +} + +func TestPublishCommandTransitionError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + } + + testStudy := &model.Study{ + ID: "study-123", + Name: "Test Collection", + Status: "unpublished", + TotalAvailablePlaces: 100, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + Return(testStudy, nil). + Times(1) + + mockClient. + EXPECT(). + TransitionStudy(gomock.Eq("study-123"), gomock.Eq(model.TransitionStudyPublish)). + Return(nil, errors.New("insufficient funds")). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("participants", "100") + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !bytes.Contains([]byte(err.Error()), []byte("failed to publish study")) { + t.Errorf("expected error to contain 'failed to publish study', got: %s", err.Error()) + } +} + +func TestPublishCommandUsesCustomNameAndDescription(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + } + + testStudy := &model.Study{ + ID: "study-123", + Name: "Custom Study Name", + Status: "active", + TotalAvailablePlaces: 50, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + DoAndReturn(func(s model.CreateStudy) (*model.Study, error) { + if s.Name != "Custom Study Name" { + t.Errorf("expected Name 'Custom Study Name', got %s", s.Name) + } + if s.Description != "Custom description" { + t.Errorf("expected Description 'Custom description', got %s", s.Description) + } + return testStudy, nil + }). + Times(1) + + mockClient. + EXPECT(). + TransitionStudy(gomock.Eq("study-123"), gomock.Eq(model.TransitionStudyPublish)). + Return(&client.TransitionStudyResponse{}, nil). + Times(1) + + mockClient. + EXPECT(). + GetStudy(gomock.Eq("study-123")). + Return(testStudy, nil). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("participants", "50") + _ = cmd.Flags().Set("name", "Custom Study Name") + _ = cmd.Flags().Set("description", "Custom description") + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} diff --git a/model/study.go b/model/study.go index 95404bf..84e7d9e 100644 --- a/model/study.go +++ b/model/study.go @@ -48,6 +48,11 @@ const ( TransitionStudyStop = "STOP" ) +const ( + // DataCollectionMethodAITBCollection is the data collection method for AI Task Builder Collections + DataCollectionMethodAITBCollection = "AI_TASK_BUILDER_COLLECTION" +) + // TransitionList is the list of transitions we can use on a Study. var TransitionList = []string{ TransitionStudyPublish, From 4af0e47da104a2174a452a72371d2fd891871432 Mon Sep 17 00:00:00 2001 From: Keir Lavelle Date: Thu, 29 Jan 2026 08:49:04 +0000 Subject: [PATCH 2/4] feat(DCP-2190): Add AI Task Builder collection study configuration example --- ...study-with-ai-task-builder-collection.json | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/examples/study-with-ai-task-builder-collection.json diff --git a/docs/examples/study-with-ai-task-builder-collection.json b/docs/examples/study-with-ai-task-builder-collection.json new file mode 100644 index 0000000..1d3f911 --- /dev/null +++ b/docs/examples/study-with-ai-task-builder-collection.json @@ -0,0 +1,28 @@ +{ + "name": "AI Task Builder Collection study", + "description": "This study demonstrates how to attach an AI Task Builder Collection", + "reward": 13, + "data_collection_method": "AI_TASK_BUILDER_COLLECTION", + "data_collection_id": "019be590-c2bd-714d-b808-70cd01a28b3c", + "total_available_places": 500, + "prolific_id_option": "question", + "completion_code": "ABC123", + "completion_codes": [ + { + "code": "ABC123", + "code_type": "COMPLETED", + "actions": [ + { + "action": "MANUALLY_REVIEW" + } + ] + } + ], + "device_compatibility": ["mobile", "desktop", "tablet"], + "peripheral_requirements": [], + "estimated_completion_time": 1, + "filter_set_id": "644b9cace850cb37684f0892", + "submissions_config": { + "max_submissions_per_participant": 1 + } +} From 225a002f9b575b1cc2750a3cafb1c120da1cad83 Mon Sep 17 00:00:00 2001 From: Keir Lavelle Date: Fri, 30 Jan 2026 12:49:44 +0000 Subject: [PATCH 3/4] feat(DCP-2190): Allow passing of a template path on collection publish commands --- cmd/collection/publish.go | 104 ++++++-- cmd/collection/publish_test.go | 433 ++++++++++++++++++++++++++++++++- 2 files changed, 505 insertions(+), 32 deletions(-) diff --git a/cmd/collection/publish.go b/cmd/collection/publish.go index e5cbd22..5ffa4f3 100644 --- a/cmd/collection/publish.go +++ b/cmd/collection/publish.go @@ -11,6 +11,7 @@ import ( "github.com/prolific-oss/cli/ui" studyui "github.com/prolific-oss/cli/ui/study" "github.com/spf13/cobra" + "github.com/spf13/viper" ) // PublishOptions is the options for the publish collection command. @@ -19,6 +20,7 @@ type PublishOptions struct { Participants int Name string Description string + TemplatePath string } // NewPublishCommand creates a new `collection publish` command to publish @@ -34,7 +36,15 @@ func NewPublishCommand(c client.API, w io.Writer) *cobra.Command { This command creates and publishes a study from an AI Task Builder Collection. The study will be created with the collection's content and made available -to participants.`, +to participants. + +You can either specify the number of participants directly, or provide a study +template file. When using a template, the collection ID will be automatically +set as the data_collection_id and data_collection_method will be set to +AI_TASK_BUILDER_COLLECTION. + +If both a template and --participants are provided, the --participants flag +will override the template's total_available_places value.`, Example: ` Publish a collection with 100 participants: @@ -43,6 +53,14 @@ $ prolific collection publish 67890abcdef --participants 100 Publish with a custom study name: $ prolific collection publish 67890abcdef -p 50 --name "My Custom Study" + +Publish using a study template file: + +$ prolific collection publish 67890abcdef -t /path/to/study-template.json + +Publish using a template but override the participant count: + +$ prolific collection publish 67890abcdef -t /path/to/template.json -p 200 `, RunE: func(cmd *cobra.Command, args []string) error { opts.Args = args @@ -51,8 +69,8 @@ $ prolific collection publish 67890abcdef -p 50 --name "My Custom Study" return errors.New("please provide a collection ID") } - if opts.Participants <= 0 { - return errors.New("please provide a valid number of participants using --participants or -p") + if opts.TemplatePath == "" && opts.Participants <= 0 { + return errors.New("please provide a valid number of participants using --participants or -p, or provide a template file using --template or -t") } return publishCollection(c, opts, w) @@ -60,9 +78,10 @@ $ prolific collection publish 67890abcdef -p 50 --name "My Custom Study" } flags := cmd.Flags() - flags.IntVarP(&opts.Participants, "participants", "p", 0, "Number of participants required (required)") + flags.IntVarP(&opts.Participants, "participants", "p", 0, "Number of participants required (required if no template)") flags.StringVarP(&opts.Name, "name", "n", "", "Study name (defaults to collection's task name)") flags.StringVarP(&opts.Description, "description", "d", "", "Study description (defaults to collection's task introduction)") + flags.StringVarP(&opts.TemplatePath, "template", "t", "", "Path to a study template file (JSON/YAML) - collection ID and method will be set automatically") return cmd } @@ -80,31 +99,62 @@ func publishCollection(c client.API, opts PublishOptions, w io.Writer) error { return fmt.Errorf("failed to get collection: %s", err.Error()) } - // Use collection details as defaults if not provided - studyName := opts.Name - if studyName == "" && coll.TaskDetails != nil { - studyName = coll.TaskDetails.TaskName - } - if studyName == "" { - studyName = coll.Name - } + var createStudy model.CreateStudy - studyDescription := opts.Description - if studyDescription == "" && coll.TaskDetails != nil { - studyDescription = coll.TaskDetails.TaskIntroduction - } - if studyDescription == "" { - studyDescription = fmt.Sprintf("Study for collection: %s", coll.Name) - } + if opts.TemplatePath != "" { + // Load study configuration from template file + v := viper.New() + v.SetConfigFile(opts.TemplatePath) + if err := v.ReadInConfig(); err != nil { + return fmt.Errorf("failed to read template file: %s", err.Error()) + } + + if err := v.Unmarshal(&createStudy); err != nil { + return fmt.Errorf("failed to parse template file: %s", err.Error()) + } + + // Override collection-specific fields + createStudy.DataCollectionMethod = model.DataCollectionMethodAITBCollection + createStudy.DataCollectionID = collectionID + // Clear external_study_url as it's incompatible with data collection method + createStudy.ExternalStudyURL = "" + + // Use collection's task introduction as description if not provided in template + if createStudy.Description == "" && coll.TaskDetails != nil { + createStudy.Description = coll.TaskDetails.TaskIntroduction + } + + // Allow -p flag to override template's total_available_places + if opts.Participants > 0 { + createStudy.TotalAvailablePlaces = opts.Participants + } + } else { + // Use collection details as defaults if not provided + studyName := opts.Name + if studyName == "" && coll.TaskDetails != nil { + studyName = coll.TaskDetails.TaskName + } + if studyName == "" { + studyName = coll.Name + } + + studyDescription := opts.Description + if studyDescription == "" && coll.TaskDetails != nil { + studyDescription = coll.TaskDetails.TaskIntroduction + } + if studyDescription == "" { + studyDescription = fmt.Sprintf("Study for collection: %s", coll.Name) + } - // Create the study with collection-specific configuration - createStudy := model.CreateStudy{ - Name: studyName, - InternalName: studyName, - Description: studyDescription, - TotalAvailablePlaces: opts.Participants, - DataCollectionMethod: model.DataCollectionMethodAITBCollection, - DataCollectionID: collectionID, + // Create the study with collection-specific configuration + createStudy = model.CreateStudy{ + Name: studyName, + InternalName: studyName, + Description: studyDescription, + TotalAvailablePlaces: opts.Participants, + DataCollectionMethod: model.DataCollectionMethodAITBCollection, + DataCollectionID: collectionID, + } } study, err := c.CreateStudy(createStudy) diff --git a/cmd/collection/publish_test.go b/cmd/collection/publish_test.go index 0131ae0..927d1f2 100644 --- a/cmd/collection/publish_test.go +++ b/cmd/collection/publish_test.go @@ -2,7 +2,11 @@ package collection_test import ( "bytes" + "encoding/json" "errors" + "os" + "path/filepath" + "strings" "testing" "time" @@ -47,7 +51,7 @@ func TestPublishCommandRequiresCollectionID(t *testing.T) { } } -func TestPublishCommandRequiresParticipants(t *testing.T) { +func TestPublishCommandRequiresParticipantsOrTemplate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockClient := mock_client.NewMockAPI(ctrl) @@ -57,12 +61,12 @@ func TestPublishCommandRequiresParticipants(t *testing.T) { err := cmd.RunE(cmd, []string{testCollectionID}) if err == nil { - t.Fatalf("expected error for missing participants, got nil") + t.Fatalf("expected error for missing participants/template, got nil") } - expectedErr := "please provide a valid number of participants" - if err.Error() != "please provide a valid number of participants using --participants or -p" { - t.Fatalf("expected error message containing %q, got: %s", expectedErr, err.Error()) + expectedErr := "please provide a valid number of participants using --participants or -p, or provide a template file using --template or -t" + if err.Error() != expectedErr { + t.Fatalf("expected error message %q, got: %s", expectedErr, err.Error()) } } @@ -319,3 +323,422 @@ func TestPublishCommandUsesCustomNameAndDescription(t *testing.T) { t.Fatalf("expected no error, got: %v", err) } } + +func TestPublishCommandWithTemplate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + // Create a temporary template file + templateContent := `{ + "name": "Template Study", + "internal_name": "Template Study Internal", + "description": "A study from template", + "reward": 100, + "total_available_places": 200, + "prolific_id_option": "question", + "completion_code": "TEMPLATE01", + "estimated_completion_time": 5, + "device_compatibility": ["desktop", "mobile"] + }` + + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, "template.json") + if err := os.WriteFile(templatePath, []byte(templateContent), 0600); err != nil { + t.Fatalf("failed to create template file: %v", err) + } + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + } + + testStudy := &model.Study{ + ID: "study-456", + Name: "Template Study", + Status: "active", + TotalAvailablePlaces: 200, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + DoAndReturn(func(s model.CreateStudy) (*model.Study, error) { + // Verify the collection fields are set correctly + if s.DataCollectionMethod != model.DataCollectionMethodAITBCollection { + t.Errorf("expected DataCollectionMethod %s, got %s", model.DataCollectionMethodAITBCollection, s.DataCollectionMethod) + } + if s.DataCollectionID != testCollectionID { + t.Errorf("expected DataCollectionID %s, got %s", testCollectionID, s.DataCollectionID) + } + // Verify template values are used + if s.Name != "Template Study" { + t.Errorf("expected Name 'Template Study', got %s", s.Name) + } + if s.TotalAvailablePlaces != 200 { + t.Errorf("expected TotalAvailablePlaces 200, got %d", s.TotalAvailablePlaces) + } + if s.Reward != 100 { + t.Errorf("expected Reward 100, got %f", s.Reward) + } + return testStudy, nil + }). + Times(1) + + mockClient. + EXPECT(). + TransitionStudy(gomock.Eq("study-456"), gomock.Eq(model.TransitionStudyPublish)). + Return(&client.TransitionStudyResponse{}, nil). + Times(1) + + mockClient. + EXPECT(). + GetStudy(gomock.Eq("study-456")). + Return(testStudy, nil). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("template", templatePath) + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := buf.String() + if output == "" { + t.Fatal("expected output, got empty string") + } +} + +func TestPublishCommandWithTemplateOverridesCollectionFields(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + // Create a template that has different data_collection_method, data_collection_id, + // and an external_study_url. These should be overridden/cleared by the command. + templateContent := `{ + "name": "Template Study", + "description": "A study from template", + "reward": 100, + "total_available_places": 50, + "external_study_url": "https://example.com/study", + "data_collection_method": "SOME_OTHER_METHOD", + "data_collection_id": "some-other-id", + "prolific_id_option": "question", + "completion_code": "TEST01", + "estimated_completion_time": 5, + "device_compatibility": ["desktop"] + }` + + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, "template.json") + if err := os.WriteFile(templatePath, []byte(templateContent), 0600); err != nil { + t.Fatalf("failed to create template file: %v", err) + } + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + } + + testStudy := &model.Study{ + ID: "study-789", + Name: "Template Study", + Status: "active", + TotalAvailablePlaces: 50, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + DoAndReturn(func(s model.CreateStudy) (*model.Study, error) { + // Verify the collection fields are OVERRIDDEN correctly + if s.DataCollectionMethod != model.DataCollectionMethodAITBCollection { + t.Errorf("expected DataCollectionMethod to be overridden to %s, got %s", model.DataCollectionMethodAITBCollection, s.DataCollectionMethod) + } + if s.DataCollectionID != testCollectionID { + t.Errorf("expected DataCollectionID to be overridden to %s, got %s", testCollectionID, s.DataCollectionID) + } + // Verify external_study_url is cleared (incompatible with data collection method) + if s.ExternalStudyURL != "" { + t.Errorf("expected ExternalStudyURL to be cleared, got %s", s.ExternalStudyURL) + } + return testStudy, nil + }). + Times(1) + + mockClient. + EXPECT(). + TransitionStudy(gomock.Eq("study-789"), gomock.Eq(model.TransitionStudyPublish)). + Return(&client.TransitionStudyResponse{}, nil). + Times(1) + + mockClient. + EXPECT(). + GetStudy(gomock.Eq("study-789")). + Return(testStudy, nil). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("template", templatePath) + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestPublishCommandWithInvalidTemplatePath(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("template", "/nonexistent/path/template.json") + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err == nil { + t.Fatal("expected error for invalid template path, got nil") + } + + if !bytes.Contains([]byte(err.Error()), []byte("failed to read template file")) { + t.Errorf("expected error to contain 'failed to read template file', got: %s", err.Error()) + } +} + +func TestCreateStudyOmitsEmptyExternalStudyURL(t *testing.T) { + // This test verifies that when ExternalStudyURL is empty, it is omitted + // from the JSON serialization entirely (not sent as "external_study_url": ""). + // This is important because the API rejects requests that include + // external_study_url when using data_collection_method. + + study := model.CreateStudy{ + Name: "Test Study", + Description: "Test description", + TotalAvailablePlaces: 100, + DataCollectionMethod: model.DataCollectionMethodAITBCollection, + DataCollectionID: "collection-123", + ExternalStudyURL: "", // Empty - should be omitted from JSON + } + + jsonBytes, err := json.Marshal(study) + if err != nil { + t.Fatalf("failed to marshal CreateStudy: %v", err) + } + + jsonStr := string(jsonBytes) + + if strings.Contains(jsonStr, "external_study_url") { + t.Errorf("expected external_study_url to be omitted from JSON when empty, but got: %s", jsonStr) + } + + // Also verify that the data collection fields ARE present + if !strings.Contains(jsonStr, "data_collection_method") { + t.Errorf("expected data_collection_method to be present in JSON, but got: %s", jsonStr) + } + if !strings.Contains(jsonStr, "data_collection_id") { + t.Errorf("expected data_collection_id to be present in JSON, but got: %s", jsonStr) + } +} + +func TestPublishCommandWithTemplateUsesCollectionDescriptionFallback(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + // Create a template WITHOUT a description + templateContent := `{ + "name": "Template Study", + "reward": 100, + "total_available_places": 50, + "prolific_id_option": "question", + "completion_code": "TEST01", + "estimated_completion_time": 5, + "device_compatibility": ["desktop"] + }` + + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, "template.json") + if err := os.WriteFile(templatePath, []byte(templateContent), 0600); err != nil { + t.Fatalf("failed to create template file: %v", err) + } + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + TaskDetails: &model.TaskDetails{ + TaskName: "Collection Task Name", + TaskIntroduction: "This is the task introduction from the collection", + }, + } + + testStudy := &model.Study{ + ID: "study-desc-test", + Name: "Template Study", + Status: "active", + TotalAvailablePlaces: 50, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + DoAndReturn(func(s model.CreateStudy) (*model.Study, error) { + // Verify the description falls back to collection's task introduction + expectedDescription := "This is the task introduction from the collection" + if s.Description != expectedDescription { + t.Errorf("expected Description to fall back to %q, got %q", expectedDescription, s.Description) + } + return testStudy, nil + }). + Times(1) + + mockClient. + EXPECT(). + TransitionStudy(gomock.Eq("study-desc-test"), gomock.Eq(model.TransitionStudyPublish)). + Return(&client.TransitionStudyResponse{}, nil). + Times(1) + + mockClient. + EXPECT(). + GetStudy(gomock.Eq("study-desc-test")). + Return(testStudy, nil). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("template", templatePath) + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestPublishCommandWithTemplateAndParticipantsFlag(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + // Create a template with total_available_places set to 50 + templateContent := `{ + "name": "Template Study", + "description": "Template description", + "reward": 100, + "total_available_places": 50, + "prolific_id_option": "question", + "completion_code": "TEST01", + "estimated_completion_time": 5, + "device_compatibility": ["desktop"] + }` + + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, "template.json") + if err := os.WriteFile(templatePath, []byte(templateContent), 0600); err != nil { + t.Fatalf("failed to create template file: %v", err) + } + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + } + + testStudy := &model.Study{ + ID: "study-override-test", + Name: "Template Study", + Status: "active", + TotalAvailablePlaces: 150, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + DoAndReturn(func(s model.CreateStudy) (*model.Study, error) { + // Verify -p flag overrides template's total_available_places + if s.TotalAvailablePlaces != 150 { + t.Errorf("expected TotalAvailablePlaces to be overridden to 150, got %d", s.TotalAvailablePlaces) + } + // Verify other template values are still used + if s.Reward != 100 { + t.Errorf("expected Reward from template (100), got %f", s.Reward) + } + return testStudy, nil + }). + Times(1) + + mockClient. + EXPECT(). + TransitionStudy(gomock.Eq("study-override-test"), gomock.Eq(model.TransitionStudyPublish)). + Return(&client.TransitionStudyResponse{}, nil). + Times(1) + + mockClient. + EXPECT(). + GetStudy(gomock.Eq("study-override-test")). + Return(testStudy, nil). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("template", templatePath) + _ = cmd.Flags().Set("participants", "150") // Override template's 50 + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} From 0fd00f15179eb6b78cdb3a804e55979eb633fba8 Mon Sep 17 00:00:00 2001 From: Keir Lavelle Date: Fri, 30 Jan 2026 13:26:14 +0000 Subject: [PATCH 4/4] fix(DCP-2190): Allow all publish args to override template values on collection publish --- cmd/collection/publish.go | 25 +++-- cmd/collection/publish_test.go | 170 +++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 7 deletions(-) diff --git a/cmd/collection/publish.go b/cmd/collection/publish.go index 5ffa4f3..a5498a1 100644 --- a/cmd/collection/publish.go +++ b/cmd/collection/publish.go @@ -43,8 +43,8 @@ template file. When using a template, the collection ID will be automatically set as the data_collection_id and data_collection_method will be set to AI_TASK_BUILDER_COLLECTION. -If both a template and --participants are provided, the --participants flag -will override the template's total_available_places value.`, +When using a template, CLI flags (--participants, --name, --description) will +override the corresponding template values.`, Example: ` Publish a collection with 100 participants: @@ -119,15 +119,26 @@ func publishCollection(c client.API, opts PublishOptions, w io.Writer) error { // Clear external_study_url as it's incompatible with data collection method createStudy.ExternalStudyURL = "" - // Use collection's task introduction as description if not provided in template - if createStudy.Description == "" && coll.TaskDetails != nil { - createStudy.Description = coll.TaskDetails.TaskIntroduction + // Allow CLI flags to override template values + if opts.Name != "" { + createStudy.Name = opts.Name + createStudy.InternalName = opts.Name + } + if opts.Description != "" { + createStudy.Description = opts.Description } - - // Allow -p flag to override template's total_available_places if opts.Participants > 0 { createStudy.TotalAvailablePlaces = opts.Participants } + + // Use collection's task introduction as description if not provided in template or flags + if createStudy.Description == "" && coll.TaskDetails != nil { + createStudy.Description = coll.TaskDetails.TaskIntroduction + } + // Final fallback if still no description + if createStudy.Description == "" { + createStudy.Description = fmt.Sprintf("Study for collection: %s", coll.Name) + } } else { // Use collection details as defaults if not provided studyName := opts.Name diff --git a/cmd/collection/publish_test.go b/cmd/collection/publish_test.go index 927d1f2..dc665a7 100644 --- a/cmd/collection/publish_test.go +++ b/cmd/collection/publish_test.go @@ -742,3 +742,173 @@ func TestPublishCommandWithTemplateAndParticipantsFlag(t *testing.T) { t.Fatalf("expected no error, got: %v", err) } } + +func TestPublishCommandWithTemplateAndNameDescriptionFlags(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + // Create a template with name and description set + templateContent := `{ + "name": "Template Name", + "description": "Template description", + "reward": 100, + "total_available_places": 50, + "prolific_id_option": "question", + "completion_code": "TEST01", + "estimated_completion_time": 5, + "device_compatibility": ["desktop"] + }` + + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, "template.json") + if err := os.WriteFile(templatePath, []byte(templateContent), 0600); err != nil { + t.Fatalf("failed to create template file: %v", err) + } + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + } + + testStudy := &model.Study{ + ID: "study-name-desc-test", + Name: "Flag Override Name", + Status: "active", + TotalAvailablePlaces: 50, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + DoAndReturn(func(s model.CreateStudy) (*model.Study, error) { + // Verify -n and -d flags override template values + if s.Name != "Flag Override Name" { + t.Errorf("expected Name to be overridden to 'Flag Override Name', got %q", s.Name) + } + if s.InternalName != "Flag Override Name" { + t.Errorf("expected InternalName to be overridden to 'Flag Override Name', got %q", s.InternalName) + } + if s.Description != "Flag override description" { + t.Errorf("expected Description to be overridden to 'Flag override description', got %q", s.Description) + } + // Verify other template values are still used + if s.Reward != 100 { + t.Errorf("expected Reward from template (100), got %f", s.Reward) + } + return testStudy, nil + }). + Times(1) + + mockClient. + EXPECT(). + TransitionStudy(gomock.Eq("study-name-desc-test"), gomock.Eq(model.TransitionStudyPublish)). + Return(&client.TransitionStudyResponse{}, nil). + Times(1) + + mockClient. + EXPECT(). + GetStudy(gomock.Eq("study-name-desc-test")). + Return(testStudy, nil). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("template", templatePath) + _ = cmd.Flags().Set("name", "Flag Override Name") + _ = cmd.Flags().Set("description", "Flag override description") + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestPublishCommandWithTemplateUsesDefaultDescriptionFallback(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + // Create a template WITHOUT a description + templateContent := `{ + "name": "Template Study", + "reward": 100, + "total_available_places": 50, + "prolific_id_option": "question", + "completion_code": "TEST01", + "estimated_completion_time": 5, + "device_compatibility": ["desktop"] + }` + + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, "template.json") + if err := os.WriteFile(templatePath, []byte(templateContent), 0600); err != nil { + t.Fatalf("failed to create template file: %v", err) + } + + // Collection WITHOUT TaskDetails + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "My Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + TaskDetails: nil, // No task details + } + + testStudy := &model.Study{ + ID: "study-fallback-test", + Name: "Template Study", + Status: "active", + TotalAvailablePlaces: 50, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + mockClient. + EXPECT(). + CreateStudy(gomock.Any()). + DoAndReturn(func(s model.CreateStudy) (*model.Study, error) { + // Verify the description falls back to the default format + expectedDescription := "Study for collection: My Test Collection" + if s.Description != expectedDescription { + t.Errorf("expected Description to fall back to %q, got %q", expectedDescription, s.Description) + } + return testStudy, nil + }). + Times(1) + + mockClient. + EXPECT(). + TransitionStudy(gomock.Eq("study-fallback-test"), gomock.Eq(model.TransitionStudyPublish)). + Return(&client.TransitionStudyResponse{}, nil). + Times(1) + + mockClient. + EXPECT(). + GetStudy(gomock.Eq("study-fallback-test")). + Return(testStudy, nil). + Times(1) + + var buf bytes.Buffer + cmd := collection.NewPublishCommand(mockClient, &buf) + _ = cmd.Flags().Set("template", templatePath) + + err := cmd.RunE(cmd, []string{testCollectionID}) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +}