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..a5498a1 --- /dev/null +++ b/cmd/collection/publish.go @@ -0,0 +1,193 @@ +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" + "github.com/spf13/viper" +) + +// PublishOptions is the options for the publish collection command. +type PublishOptions struct { + Args []string + Participants int + Name string + Description string + TemplatePath 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. + +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. + +When using a template, CLI flags (--participants, --name, --description) will +override the corresponding template values.`, + 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" + +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 + + if len(opts.Args) < 1 || opts.Args[0] == "" { + return errors.New("please provide a collection ID") + } + + 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) + }, + } + + flags := cmd.Flags() + 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 +} + +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()) + } + + var createStudy model.CreateStudy + + 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 = "" + + // 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 + } + 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 + 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..dc665a7 --- /dev/null +++ b/cmd/collection/publish_test.go @@ -0,0 +1,914 @@ +package collection_test + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "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 TestPublishCommandRequiresParticipantsOrTemplate(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/template, got nil") + } + + 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()) + } +} + +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) + } +} + +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) + } +} + +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) + } +} 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 + } +} 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,