diff --git a/CHANGELOG.md b/CHANGELOG.md index 962bb42e..62cde703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ +### AI Task Builder + +- Support export slicing: `aitaskbuilder batch export` and `collection export` accept `--study-id`, `--from`, and `--to` to narrow an export to a subset of responses +- Add `aitaskbuilder batch export list`/`delete`/`download` and `collection export list`/`delete`/`download` subcommands to list, remove, and (re-)download export jobs without starting a new export + ## 1.2.5 - Maintenance and dependency updates diff --git a/README.md b/README.md index a58878cc..a409f0c5 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` | ✅ `ListBatchExportJobs` | +| `aiTaskBuilder_DeleteBatchExport` | DELETE | `/api/v1/data-collection/batches/{batch_id}/export/{export_id}` | ✅ `DeleteBatchExport` | @@ -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` | ✅ `ListCollectionExportJobs` | +| `aiTaskBuilder_DeleteCollectionExport` | DELETE | `/api/v1/data-collection/collections/{collection_id}/export/{export_id}` | ✅ `DeleteCollectionExport` | @@ -396,6 +400,7 @@ Operations are grouped as they appear in [`contract_test/contract_test.go`](cont | `messages_SendMessageToParticipantGroup` | POST | `/api/v1/messages/participant-group/` | ✅ `SendGroupMessage` | | `messages_GetUnreadMessages` | GET | `/api/v1/messages/unread/` | ✅ `GetUnreadMessages` | | `messages_GetConversations` | GET | `/api/v1/conversations/` | ➖ Not exposed in the CLI | +| `messages_CreateConversation` | POST | `/api/v1/conversations/` | ➖ Not exposed in the CLI | | `messages_GetConversationMessages` | GET | `/api/v1/conversations/{conversation_id}/messages/` | ➖ Not exposed in the CLI | diff --git a/client/client.go b/client/client.go index cc26ea6f..02965aae 100644 --- a/client/client.go +++ b/client/client.go @@ -60,8 +60,10 @@ type API interface { GetCollections(workspaceID string, limit, offset int) (*ListCollectionsResponse, error) GetCollection(ID string) (*model.Collection, error) - InitiateCollectionExport(collectionID string) (*CollectionExportResponse, error) + InitiateCollectionExport(collectionID string, filter ExportFilter) (*CollectionExportResponse, error) GetCollectionExportStatus(collectionID, exportID string) (*CollectionExportResponse, error) + ListCollectionExportJobs(collectionID string) ([]ExportJobListItem, error) + DeleteCollectionExport(collectionID, exportID string) error UpdateCollection(ID string, collection model.UpdateCollection) (*model.Collection, error) GetHooks(workspaceID string, enabled bool, limit, offset int) (*ListHooksResponse, error) @@ -134,8 +136,10 @@ type API interface { GetAITaskBuilderResponses(batchID string) (*GetAITaskBuilderResponsesResponse, error) GetAITaskBuilderTasks(batchID string) (*GetAITaskBuilderTasksResponse, error) GetAITaskBuilderTaskGroups(batchID string) (*GetAITaskBuilderTaskGroupsResponse, error) - InitiateBatchExport(batchID string) (*BatchExportResponse, error) + InitiateBatchExport(batchID string, filter ExportFilter) (*BatchExportResponse, error) GetBatchExportStatus(batchID, exportID string) (*BatchExportResponse, error) + ListBatchExportJobs(batchID string) ([]ExportJobListItem, error) + DeleteBatchExport(batchID, exportID string) error SyncAITaskBuilderBatch(batchID string) (*AITaskBuilderBatchSyncResponse, error) GetAITaskBuilderBatchSyncStatus(batchID, syncID string) (*AITaskBuilderBatchSyncResponse, error) GetAITaskBuilderDatasetStatus(datasetID string) (*GetAITaskBuilderDatasetStatusResponse, error) @@ -571,10 +575,13 @@ func (c *Client) GetCollection(ID string) (*model.Collection, error) { // InitiateCollectionExport starts a collection export job via POST. // Returns "generating" + ExportID (202) if a new job was enqueued, // or "complete" + URL immediately (200) if a valid export already exists. -func (c *Client) InitiateCollectionExport(collectionID string) (*CollectionExportResponse, error) { +// filter optionally slices the export down to responses for a specific +// study_id and/or a from/to created_at date range; a zero-value ExportFilter +// requests a full, unfiltered export. +func (c *Client) InitiateCollectionExport(collectionID string, filter ExportFilter) (*CollectionExportResponse, error) { var response CollectionExportResponse - url := fmt.Sprintf("/api/v1/data-collection/collections/%s/export", collectionID) + url := fmt.Sprintf("/api/v1/data-collection/collections/%s/export%s", collectionID, filter.query()) _, err := c.ExecuteBuilder().PostRequest(url).Decode(&response).Execute() if err != nil { return nil, err @@ -597,6 +604,38 @@ func (c *Client) GetCollectionExportStatus(collectionID, exportID string) (*Coll return &response, nil } +// ListCollectionExportJobs returns every export job requested for a +// collection, most recent first. Manually deleted jobs are excluded. +func (c *Client) ListCollectionExportJobs(collectionID string) ([]ExportJobListItem, error) { + var response []ExportJobListItem + + url := fmt.Sprintf("/api/v1/data-collection/collections/%s/export", collectionID) + _, err := c.Execute(http.MethodGet, url, nil, &response) + if err != nil { + return nil, fmt.Errorf("unable to fulfil request %s: %s", url, err) + } + + return response, nil +} + +// DeleteCollectionExport permanently deletes an export job and, if it +// completed, its ZIP archive. An export that is still generating cannot be +// deleted (the API returns 409 Conflict). +func (c *Client) DeleteCollectionExport(collectionID, exportID string) error { + url := fmt.Sprintf("/api/v1/data-collection/collections/%s/export/%s", collectionID, exportID) + httpResponse, err := c.Execute(http.MethodDelete, url, nil, nil) + if err != nil { + return fmt.Errorf("unable to fulfil request %s: %s", url, err) + } + + if httpResponse.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(httpResponse.Body) + return fmt.Errorf("unexpected status code %d: %s", httpResponse.StatusCode, string(body)) + } + + return nil +} + // UpdateStudy is responsible for updating the Study with a PATCH request. func (c *Client) UpdateStudy(ID string, study any) (*model.Study, error) { var response model.Study @@ -1549,10 +1588,13 @@ func (c *Client) GetAITaskBuilderTaskGroups(batchID string) (*GetAITaskBuilderTa // InitiateBatchExport starts a batch export job via POST. // Returns "generating" + ExportID (202) if a new job was enqueued, // or "complete" + URL immediately (200) if a valid export already exists. -func (c *Client) InitiateBatchExport(batchID string) (*BatchExportResponse, error) { +// filter optionally slices the export down to responses for a specific +// study_id and/or a from/to created_at date range; a zero-value ExportFilter +// requests a full, unfiltered export. +func (c *Client) InitiateBatchExport(batchID string, filter ExportFilter) (*BatchExportResponse, error) { var response BatchExportResponse - url := fmt.Sprintf("/api/v1/data-collection/batches/%s/export", batchID) + url := fmt.Sprintf("/api/v1/data-collection/batches/%s/export%s", batchID, filter.query()) _, err := c.ExecuteBuilder(). PostRequest(url). Status(http.StatusOK, http.StatusAccepted). @@ -1579,6 +1621,43 @@ func (c *Client) GetBatchExportStatus(batchID, exportID string) (*BatchExportRes return &response, nil } +// ListBatchExportJobs returns every export job requested for a batch, most +// recent first. Manually deleted jobs are excluded. +func (c *Client) ListBatchExportJobs(batchID string) ([]ExportJobListItem, error) { + var response []ExportJobListItem + + url := fmt.Sprintf("/api/v1/data-collection/batches/%s/export", batchID) + httpResponse, err := c.Execute(http.MethodGet, url, nil, &response) + if err != nil { + return nil, fmt.Errorf("unable to fulfil request %s: %s", url, err) + } + + if httpResponse.StatusCode != http.StatusOK { + body, _ := io.ReadAll(httpResponse.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", httpResponse.StatusCode, string(body)) + } + + return response, nil +} + +// DeleteBatchExport permanently deletes an export job and, if it completed, +// its ZIP archive. An export that is still generating cannot be deleted (the +// API returns 409 Conflict). +func (c *Client) DeleteBatchExport(batchID, exportID string) error { + url := fmt.Sprintf("/api/v1/data-collection/batches/%s/export/%s", batchID, exportID) + httpResponse, err := c.Execute(http.MethodDelete, url, nil, nil) + if err != nil { + return fmt.Errorf("unable to fulfil request %s: %s", url, err) + } + + if httpResponse.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(httpResponse.Body) + return fmt.Errorf("unexpected status code %d: %s", httpResponse.StatusCode, string(body)) + } + + return nil +} + // SyncAITaskBuilderBatch starts an async sync job that extends a batch with tasks // created from datapoints appended to its dataset since setup or the last sync. // Returns the created job (status "queued") including its sync_id. diff --git a/client/responses.go b/client/responses.go index ea254613..c0d70a08 100644 --- a/client/responses.go +++ b/client/responses.go @@ -1,6 +1,8 @@ package client import ( + "net/url" + "github.com/prolific-oss/cli/model" ) @@ -465,6 +467,65 @@ type BatchExportResponse struct { ExpiresAt string `json:"expires_at,omitempty"` } +// ExportFilter narrows a batch/collection export to a subset of responses. +// All fields are optional; when every field is empty the export is +// unfiltered (a "full" export of every response). +type ExportFilter struct { + // StudyID restricts the export to responses submitted under this + // Prolific Study ID. + StudyID string + // From restricts the export to responses created on or after this ISO + // 8601 datetime (inclusive). + From string + // To restricts the export to responses created before this ISO 8601 + // datetime (exclusive). + To string +} + +// IsEmpty reports whether the filter has no fields set, i.e. it requests an +// unfiltered ("full") export. +func (f ExportFilter) IsEmpty() bool { + return f.StudyID == "" && f.From == "" && f.To == "" +} + +// query encodes the filter as a URL query string. It returns an empty +// string when the filter is empty, so callers can append it unconditionally. +func (f ExportFilter) query() string { + if f.IsEmpty() { + return "" + } + + values := url.Values{} + if f.StudyID != "" { + values.Set("study_id", f.StudyID) + } + if f.From != "" { + values.Set("from", f.From) + } + if f.To != "" { + values.Set("to", f.To) + } + + return "?" + values.Encode() +} + +// ExportJobFilter describes the filter (if any) an export job was requested +// with, as returned by the list-export-jobs endpoints. +type ExportJobFilter struct { + StudyID string `json:"study_id,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` +} + +// ExportJobListItem summarizes one export job, as returned by the +// list-export-jobs endpoints for batches and collections. +type ExportJobListItem struct { + ExportID string `json:"export_id"` + Filter *ExportJobFilter `json:"filter"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + // AITaskBuilderBatchSyncResponse is the response for both starting a batch sync // and polling its status. Status is one of "queued", "processing", "complete", // or "failed". The outcome counts are populated on completion; Reason is set on diff --git a/cmd/aitaskbuilder/batch_export.go b/cmd/aitaskbuilder/batch_export.go index 537a4086..bb8182df 100644 --- a/cmd/aitaskbuilder/batch_export.go +++ b/cmd/aitaskbuilder/batch_export.go @@ -34,8 +34,11 @@ var batchExportDownloadClient = http.DefaultClient // BatchExportOptions holds the options for the batch export command. type BatchExportOptions struct { - Args []string - Output string + Args []string + Output string + StudyID string + From string + To string } // NewBatchExportCommand creates a new `aitaskbuilder batch export` command to @@ -59,7 +62,14 @@ the resulting ZIP file. The archive contains: - files/ — participant-uploaded files (if any) The export is generated asynchronously. This command will poll until the -archive is ready and then download it automatically.`, +archive is ready and then download it automatically. + +Use --study-id, --from, and/or --to to narrow the export to a subset of +responses. Combining them applies all filters together (AND): --study-id +restricts the export to a specific Study, while --from/--to restrict it to +responses whose created_at falls within an ISO 8601 datetime range (--from +is inclusive, --to is exclusive). Omit all three for a full, unfiltered +export.`, Example: ` Export a batch to the default filename (-export-.zip): @@ -68,6 +78,14 @@ $ prolific aitaskbuilder batch export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a Export to a custom output path: $ prolific aitaskbuilder batch export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --output /tmp/my-export.zip + +Export only responses for a specific study: + +$ prolific aitaskbuilder batch export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --study-id 60d3b2f1a2b3c4d5e6f7a8b9 + +Export only responses created in a date range: + +$ prolific aitaskbuilder batch export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --from 2024-01-01T00:00:00Z --to 2024-02-01T00:00:00Z `, RunE: func(cmd *cobra.Command, args []string) error { opts.Args = args @@ -85,6 +103,15 @@ $ prolific aitaskbuilder batch export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --out } cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path (default: -export-.zip)") + cmd.Flags().StringVar(&opts.StudyID, "study-id", "", "Restrict the export to responses submitted under this Study ID") + cmd.Flags().StringVar(&opts.From, "from", "", "Restrict the export to responses created on or after this ISO 8601 datetime (inclusive)") + cmd.Flags().StringVar(&opts.To, "to", "", "Restrict the export to responses created before this ISO 8601 datetime (exclusive)") + + cmd.AddCommand( + NewBatchExportListCommand(c, w), + NewBatchExportDeleteCommand(c, w), + NewBatchExportDownloadCommand(c, w), + ) return cmd } @@ -95,7 +122,8 @@ func exportBatch(c client.API, opts BatchExportOptions, w io.Writer) error { fmt.Fprintf(w, "Requesting export for batch %s...\n", batchID) // Step 1: POST to initiate the export job. - initResult, err := c.InitiateBatchExport(batchID) + filter := client.ExportFilter{StudyID: opts.StudyID, From: opts.From, To: opts.To} + initResult, err := c.InitiateBatchExport(batchID, filter) if err != nil { return fmt.Errorf("error requesting export: %s", err.Error()) } @@ -109,14 +137,27 @@ func exportBatch(c client.API, opts BatchExportOptions, w io.Writer) error { return fmt.Errorf("unexpected export status %q for batch %s", initResult.Status, batchID) } - exportID := initResult.ExportID - // Step 2: Poll GET until complete or failed. + url, err := pollBatchExportUntilDone(c, batchID, initResult.ExportID, w) + if err != nil { + return err + } + + return batchDownloadExport(url, opts.Output, w) +} + +// pollBatchExportUntilDone polls GetBatchExportStatus for batchID/exportID +// until the job reaches "complete" or "failed" (or the poll deadline is +// exceeded), printing a "." to w for each poll. It returns the presigned +// download URL on success. Shared by exportBatch (which polls a job it just +// requested) and downloadBatchExport (which polls a pre-existing job that +// was still generating). +func pollBatchExportUntilDone(c client.API, batchID, exportID string, w io.Writer) (string, error) { deadline := time.Now().Add(batchExportTimeout) for { if time.Now().After(deadline) { - return fmt.Errorf("export timed out after 10 minutes for batch %s", batchID) + return "", fmt.Errorf("export timed out after 10 minutes for batch %s", batchID) } fmt.Fprint(w, ".") @@ -124,18 +165,18 @@ func exportBatch(c client.API, opts BatchExportOptions, w io.Writer) error { pollResult, err := c.GetBatchExportStatus(batchID, exportID) if err != nil { - return fmt.Errorf("error polling export status: %s", err.Error()) + return "", fmt.Errorf("error polling export status: %s", err.Error()) } switch pollResult.Status { case batchExportStatusComplete: - return batchDownloadExport(pollResult.URL, opts.Output, w) + return pollResult.URL, nil case batchExportStatusFailed: - return fmt.Errorf("export generation failed for batch %s", batchID) + return "", fmt.Errorf("export generation failed for batch %s", batchID) case batchExportStatusGenerating: // continue polling default: - return fmt.Errorf("unexpected export status %q for batch %s", pollResult.Status, batchID) + return "", fmt.Errorf("unexpected export status %q for batch %s", pollResult.Status, batchID) } } } diff --git a/cmd/aitaskbuilder/batch_export_delete.go b/cmd/aitaskbuilder/batch_export_delete.go new file mode 100644 index 00000000..1586032b --- /dev/null +++ b/cmd/aitaskbuilder/batch_export_delete.go @@ -0,0 +1,60 @@ +package aitaskbuilder + +import ( + "errors" + "fmt" + "io" + + "github.com/prolific-oss/cli/client" + "github.com/spf13/cobra" +) + +// BatchExportDeleteOptions holds the options for the batch export delete command. +type BatchExportDeleteOptions struct { + Args []string +} + +// NewBatchExportDeleteCommand creates a new `aitaskbuilder batch export delete` +// command to permanently delete an export job for a batch. +func NewBatchExportDeleteCommand(c client.API, w io.Writer) *cobra.Command { + var opts BatchExportDeleteOptions + + cmd := &cobra.Command{ + Use: "delete ", + Args: cobra.ExactArgs(2), + Short: "Delete a batch's export job", + Long: `Delete a batch's export job + +This command permanently deletes an export job and, if it completed, its +ZIP archive. An export that is still generating cannot be deleted.`, + Example: ` +Delete an export job for a batch: + +$ prolific aitaskbuilder batch export delete 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a export-job-uuid-456 +`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Args = args + + if len(opts.Args) < 2 || opts.Args[0] == "" || opts.Args[1] == "" { + return errors.New("please provide a batch ID and an export ID") + } + + return deleteBatchExport(c, opts, w) + }, + } + + return cmd +} + +func deleteBatchExport(c client.API, opts BatchExportDeleteOptions, w io.Writer) error { + batchID := opts.Args[0] + exportID := opts.Args[1] + + if err := c.DeleteBatchExport(batchID, exportID); err != nil { + return fmt.Errorf("error deleting export: %s", err.Error()) + } + + fmt.Fprintf(w, "Export %s for batch %s deleted.\n", exportID, batchID) + + return nil +} diff --git a/cmd/aitaskbuilder/batch_export_delete_test.go b/cmd/aitaskbuilder/batch_export_delete_test.go new file mode 100644 index 00000000..b1102d5b --- /dev/null +++ b/cmd/aitaskbuilder/batch_export_delete_test.go @@ -0,0 +1,91 @@ +package aitaskbuilder_test + +import ( + "bufio" + "bytes" + "errors" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/prolific-oss/cli/cmd/aitaskbuilder" + "github.com/prolific-oss/cli/mock_client" +) + +func TestNewBatchExportDeleteCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := aitaskbuilder.NewBatchExportDeleteCommand(mockClient, &buf) + + if cmd.Use != "delete " { + t.Fatalf("expected use: delete ; got %s", cmd.Use) + } +} + +func TestBatchExportDeleteCommandRequiresArgs(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := aitaskbuilder.NewBatchExportDeleteCommand(mockClient, &buf) + + if err := cmd.RunE(cmd, []string{}); err == nil { + t.Fatal("expected error for missing args, got nil") + } + + if err := cmd.RunE(cmd, []string{testBatchID}); err == nil { + t.Fatal("expected error for missing export ID, got nil") + } +} + +func TestBatchExportDeleteCommandHappyPath(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + DeleteBatchExport(gomock.Eq(testBatchID), gomock.Eq(testBatchExportID)). + Return(nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportDeleteCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testBatchID, testBatchExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := b.String() + want := "Export " + testBatchExportID + " for batch " + testBatchID + " deleted." + if !strings.Contains(output, want) { + t.Errorf("expected confirmation message %q, got: %s", want, output) + } +} + +func TestBatchExportDeleteCommandError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + DeleteBatchExport(gomock.Eq(testBatchID), gomock.Eq(testBatchExportID)). + Return(errors.New("conflict: export still generating")). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportDeleteCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testBatchID, testBatchExportID}) + w.Flush() + if err == nil { + t.Fatal("expected error on client failure, got nil") + } +} diff --git a/cmd/aitaskbuilder/batch_export_download.go b/cmd/aitaskbuilder/batch_export_download.go new file mode 100644 index 00000000..3e659042 --- /dev/null +++ b/cmd/aitaskbuilder/batch_export_download.go @@ -0,0 +1,93 @@ +package aitaskbuilder + +import ( + "errors" + "fmt" + "io" + + "github.com/prolific-oss/cli/client" + "github.com/spf13/cobra" +) + +// BatchExportDownloadOptions holds the options for the batch export +// download command. +type BatchExportDownloadOptions struct { + Args []string + Output string +} + +// NewBatchExportDownloadCommand creates a new `aitaskbuilder batch export +// download` command to download an existing export job's ZIP archive +// without requesting a new export. +func NewBatchExportDownloadCommand(c client.API, w io.Writer) *cobra.Command { + var opts BatchExportDownloadOptions + + cmd := &cobra.Command{ + Use: "download ", + Args: cobra.ExactArgs(2), + Short: "Download an existing batch export job", + Long: `Download an existing batch export job + +This command downloads the ZIP archive for an export job that was already +requested — for example via 'batch export' or one shown by +'batch export list' — without starting a new export. + +If the job is still generating, this command polls until it completes and +then downloads it automatically, just like 'batch export' does for a +newly-requested job. If the job failed, request a fresh export with +'batch export ' instead.`, + Example: ` +Download an existing, completed export job: + +$ prolific aitaskbuilder batch export download 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a export-job-uuid-456 + +Download to a custom output path: + +$ prolific aitaskbuilder batch export download 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a export-job-uuid-456 --output /tmp/my-export.zip +`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Args = args + + if len(opts.Args) < 2 || opts.Args[0] == "" || opts.Args[1] == "" { + return errors.New("please provide a batch ID and an export ID") + } + + if opts.Output == "" { + opts.Output = fmt.Sprintf("%s-export-%s.zip", opts.Args[0], opts.Args[1]) + } + + return downloadBatchExport(c, opts, w) + }, + } + + cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path (default: -export-.zip)") + + return cmd +} + +func downloadBatchExport(c client.API, opts BatchExportDownloadOptions, w io.Writer) error { + batchID := opts.Args[0] + exportID := opts.Args[1] + + fmt.Fprintf(w, "Checking export %s for batch %s...\n", exportID, batchID) + + status, err := c.GetBatchExportStatus(batchID, exportID) + if err != nil { + return fmt.Errorf("error checking export status: %s", err.Error()) + } + + switch status.Status { + case batchExportStatusComplete: + return batchDownloadExport(status.URL, opts.Output, w) + case batchExportStatusFailed: + return fmt.Errorf("export %s for batch %s failed to generate; request a new export with 'aitaskbuilder batch export %s'", exportID, batchID, batchID) + case batchExportStatusGenerating: + url, err := pollBatchExportUntilDone(c, batchID, exportID, w) + if err != nil { + return err + } + return batchDownloadExport(url, opts.Output, w) + default: + return fmt.Errorf("unexpected export status %q for batch %s", status.Status, batchID) + } +} diff --git a/cmd/aitaskbuilder/batch_export_download_test.go b/cmd/aitaskbuilder/batch_export_download_test.go new file mode 100644 index 00000000..518c231a --- /dev/null +++ b/cmd/aitaskbuilder/batch_export_download_test.go @@ -0,0 +1,238 @@ +package aitaskbuilder_test + +import ( + "bufio" + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/aitaskbuilder" + "github.com/prolific-oss/cli/mock_client" +) + +func TestNewBatchExportDownloadCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := aitaskbuilder.NewBatchExportDownloadCommand(mockClient, &buf) + + if cmd.Use != "download " { + t.Fatalf("expected use: download ; got %s", cmd.Use) + } +} + +func TestBatchExportDownloadCommandRequiresArgs(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := aitaskbuilder.NewBatchExportDownloadCommand(mockClient, &buf) + + for _, args := range [][]string{{}, {testBatchID}} { + if err := cmd.RunE(cmd, args); err == nil { + t.Fatalf("expected error for args %v, got nil", args) + } + } +} + +// TestBatchExportDownloadCommandAlreadyComplete covers downloading a job +// that is already complete — no polling required. +func TestBatchExportDownloadCommandAlreadyComplete(t *testing.T) { + zipContent := []byte("PK\x03\x04fake zip content") + srv := newBatchZIPServer(t, zipContent) + defer aitaskbuilder.SetBatchExportDownloadClientForTesting(srv.Client())() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetBatchExportStatus(gomock.Eq(testBatchID), gomock.Eq(testBatchExportID)). + Return(&client.BatchExportResponse{ + Status: "complete", + URL: srv.URL + "/export.zip", + ExpiresAt: "2099-01-01T00:00:00Z", + }, nil). + Times(1) + + outputPath := filepath.Join(t.TempDir(), "download-complete.zip") + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportDownloadCommand(mockClient, w) + if err := cmd.Flags().Set("output", outputPath); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testBatchID, testBatchExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("expected output file to exist: %v", err) + } + if !bytes.Equal(data, zipContent) { + t.Fatalf("expected file contents to match download, got: %v", data) + } +} + +// TestBatchExportDownloadCommandPollsWhileGenerating covers downloading a +// job that is still generating — the command should poll until complete. +func TestBatchExportDownloadCommandPollsWhileGenerating(t *testing.T) { + defer aitaskbuilder.SetBatchExportPollSleepForTesting(func(time.Duration) {})() + + zipContent := []byte("PK\x03\x04fake zip content") + srv := newBatchZIPServer(t, zipContent) + defer aitaskbuilder.SetBatchExportDownloadClientForTesting(srv.Client())() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetBatchExportStatus(gomock.Eq(testBatchID), gomock.Eq(testBatchExportID)). + Return(&client.BatchExportResponse{Status: "generating"}, nil). + Times(1) + + gomock.InOrder( + mockClient.EXPECT(). + GetBatchExportStatus(gomock.Eq(testBatchID), gomock.Eq(testBatchExportID)). + Return(&client.BatchExportResponse{Status: "generating"}, nil). + Times(1), + mockClient.EXPECT(). + GetBatchExportStatus(gomock.Eq(testBatchID), gomock.Eq(testBatchExportID)). + Return(&client.BatchExportResponse{ + Status: "complete", + URL: srv.URL + "/export.zip", + ExpiresAt: "2099-01-01T00:00:00Z", + }, nil). + Times(1), + ) + + outputPath := filepath.Join(t.TempDir(), "download-polled.zip") + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportDownloadCommand(mockClient, w) + if err := cmd.Flags().Set("output", outputPath); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testBatchID, testBatchExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if _, err := os.Stat(outputPath); os.IsNotExist(err) { + t.Fatal("expected output file to exist after polling") + } +} + +func TestBatchExportDownloadCommandFailedStatus(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetBatchExportStatus(gomock.Eq(testBatchID), gomock.Eq(testBatchExportID)). + Return(&client.BatchExportResponse{Status: "failed"}, nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportDownloadCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testBatchID, testBatchExportID}) + w.Flush() + if err == nil { + t.Fatal("expected error for failed status, got nil") + } + if !strings.Contains(err.Error(), "failed to generate") { + t.Errorf("expected error to explain the job failed, got: %v", err) + } + if !strings.Contains(err.Error(), "aitaskbuilder batch export "+testBatchID) { + t.Errorf("expected error to suggest requesting a new export, got: %v", err) + } +} + +func TestBatchExportDownloadCommandStatusError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetBatchExportStatus(gomock.Eq(testBatchID), gomock.Eq(testBatchExportID)). + Return(nil, errors.New("network error")). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportDownloadCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testBatchID, testBatchExportID}) + w.Flush() + if err == nil { + t.Fatal("expected error on client failure, got nil") + } +} + +func TestBatchExportDownloadCommandDefaultOutputPath(t *testing.T) { + zipContent := []byte("PK\x03\x04fake zip content") + srv := newBatchZIPServer(t, zipContent) + defer aitaskbuilder.SetBatchExportDownloadClientForTesting(srv.Client())() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetBatchExportStatus(gomock.Eq(testBatchID), gomock.Eq(testBatchExportID)). + Return(&client.BatchExportResponse{ + Status: "complete", + URL: srv.URL + "/export.zip", + ExpiresAt: "2099-01-01T00:00:00Z", + }, nil). + Times(1) + + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir to temp dir: %v", err) + } + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }() + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportDownloadCommand(mockClient, w) + + err = cmd.RunE(cmd, []string{testBatchID, testBatchExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + wantPath := testBatchID + "-export-" + testBatchExportID + ".zip" + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("expected default output file %s to exist: %v", wantPath, err) + } +} diff --git a/cmd/aitaskbuilder/batch_export_list.go b/cmd/aitaskbuilder/batch_export_list.go new file mode 100644 index 00000000..f7bccb8f --- /dev/null +++ b/cmd/aitaskbuilder/batch_export_list.go @@ -0,0 +1,122 @@ +package aitaskbuilder + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/shared" + "github.com/prolific-oss/cli/ui" + "github.com/spf13/cobra" +) + +// BatchExportListOptions holds the options for the batch export list command. +type BatchExportListOptions struct { + Args []string + Output shared.OutputOptions +} + +// NewBatchExportListCommand creates a new `aitaskbuilder batch export list` +// command to list every export job requested for a batch. +func NewBatchExportListCommand(c client.API, w io.Writer) *cobra.Command { + var opts BatchExportListOptions + + cmd := &cobra.Command{ + Use: "list ", + Args: cobra.MinimumNArgs(1), + Short: "List a batch's export jobs", + Long: `List a batch's export jobs + +This command lists every export job that has been requested for a batch, +most recent first, including its status and the filter (if any) it was +requested with. A batch can have at most 10 export jobs at once.`, + Example: ` +List all export jobs for a batch: + +$ prolific aitaskbuilder batch export list 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a + +You can output as a table +$ prolific aitaskbuilder batch export list 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --table + +You can output as CSV +$ prolific aitaskbuilder batch export list 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --csv + +You can output as JSON +$ prolific aitaskbuilder batch export list 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --json +`, + 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 batch ID") + } + + return listBatchExports(c, opts, w) + }, + } + + shared.AddOutputFlags(cmd, &opts.Output) + + return cmd +} + +func listBatchExports(c client.API, opts BatchExportListOptions, w io.Writer) error { + batchID := opts.Args[0] + + jobs, err := c.ListBatchExportJobs(batchID) + if err != nil { + return fmt.Errorf("error listing exports: %s", err.Error()) + } + + switch shared.ResolveFormat(opts.Output) { + case "json": + r := ui.JSONRenderer[client.ExportJobListItem]{} + if err := r.Render(jobs, w); err != nil { + return fmt.Errorf("error: %s", err) + } + case "csv": + r := ui.CsvRenderer[BatchExportListItem]{} + if err := r.Render(NewBatchExportListItems(jobs), BatchExportListFields, w); err != nil { + return fmt.Errorf("error: %s", err) + } + default: + if len(jobs) == 0 { + fmt.Fprintf(w, "No export jobs found for batch %s\n", batchID) + return nil + } + r := ui.TableRenderer[BatchExportListItem]{} + if err := r.Render(NewBatchExportListItems(jobs), BatchExportListFields, w); err != nil { + return fmt.Errorf("error: %s", err) + } + } + + return nil +} + +// formatExportJobFilter renders an export job's filter as a short, +// human-readable summary, e.g. "study_id=X, from=Y, to=Z". A nil filter +// (unfiltered/full export) renders as "-". +func formatExportJobFilter(filter *client.ExportJobFilter) string { + if filter == nil { + return "-" + } + + var parts []string + if filter.StudyID != "" { + parts = append(parts, fmt.Sprintf("study_id=%s", filter.StudyID)) + } + if filter.From != "" { + parts = append(parts, fmt.Sprintf("from=%s", filter.From)) + } + if filter.To != "" { + parts = append(parts, fmt.Sprintf("to=%s", filter.To)) + } + + if len(parts) == 0 { + return "none" + } + + return strings.Join(parts, ", ") +} diff --git a/cmd/aitaskbuilder/batch_export_list_items.go b/cmd/aitaskbuilder/batch_export_list_items.go new file mode 100644 index 00000000..22a23fac --- /dev/null +++ b/cmd/aitaskbuilder/batch_export_list_items.go @@ -0,0 +1,31 @@ +package aitaskbuilder + +import "github.com/prolific-oss/cli/client" + +// BatchExportListFields are the fields shown in batch export list table and +// CSV output. +const BatchExportListFields = "ExportID,Filter,Status,CreatedAt" + +// BatchExportListItem is the flat presentation model used by table and CSV +// renderers. The API model remains nested (with a pointer Filter field) so +// JSON output preserves the API response shape. +type BatchExportListItem struct { + ExportID string + Filter string + Status string + CreatedAt string +} + +// NewBatchExportListItems converts export job API models into display-ready rows. +func NewBatchExportListItems(jobs []client.ExportJobListItem) []BatchExportListItem { + items := make([]BatchExportListItem, 0, len(jobs)) + for _, job := range jobs { + items = append(items, BatchExportListItem{ + ExportID: job.ExportID, + Filter: formatExportJobFilter(job.Filter), + Status: job.Status, + CreatedAt: job.CreatedAt, + }) + } + return items +} diff --git a/cmd/aitaskbuilder/batch_export_list_test.go b/cmd/aitaskbuilder/batch_export_list_test.go new file mode 100644 index 00000000..8ef565dd --- /dev/null +++ b/cmd/aitaskbuilder/batch_export_list_test.go @@ -0,0 +1,270 @@ +package aitaskbuilder_test + +import ( + "bufio" + "bytes" + "encoding/csv" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/aitaskbuilder" + "github.com/prolific-oss/cli/mock_client" +) + +func batchExportListSampleJobs() []client.ExportJobListItem { + return []client.ExportJobListItem{ + { + ExportID: "export-unfiltered", + Filter: nil, + Status: "complete", + CreatedAt: "2024-01-01T00:00:00Z", + }, + { + ExportID: "export-filtered", + Filter: &client.ExportJobFilter{ + StudyID: "study-id-789", + From: "2024-01-01T00:00:00Z", + To: "2024-02-01T00:00:00Z", + }, + Status: "generating", + CreatedAt: "2024-02-01T00:00:00Z", + }, + } +} + +func TestNewBatchExportListCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := aitaskbuilder.NewBatchExportListCommand(mockClient, &buf) + + if cmd.Use != "list " { + t.Fatalf("expected use: list ; got %s", cmd.Use) + } +} + +func TestBatchExportListCommandRegistersOutputFlags(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := aitaskbuilder.NewBatchExportListCommand(mockClient, &buf) + + for _, name := range []string{"json", "csv", "table", "non-interactive"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to be registered", name) + } + } +} + +func TestBatchExportListCommandRequiresBatchID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := aitaskbuilder.NewBatchExportListCommand(mockClient, &buf) + + err := cmd.RunE(cmd, []string{}) + if err == nil { + t.Fatal("expected error for missing batch ID, got nil") + } +} + +func TestBatchExportListCommandHappyPath(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListBatchExportJobs(gomock.Eq(testBatchID)). + Return(batchExportListSampleJobs(), nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportListCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testBatchID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := b.String() + for _, want := range []string{ + "export-unfiltered", + "export-filtered", + "complete", + "generating", + "study_id=study-id-789", + "from=2024-01-01T00:00:00Z", + "to=2024-02-01T00:00:00Z", + } { + if !strings.Contains(output, want) { + t.Errorf("expected output to contain %q, got: %s", want, output) + } + } +} + +func TestBatchExportListCommandEmpty(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListBatchExportJobs(gomock.Eq(testBatchID)). + Return([]client.ExportJobListItem{}, nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportListCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testBatchID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := b.String() + if !strings.Contains(output, "No export jobs found for batch "+testBatchID) { + t.Errorf("expected friendly empty message, got: %s", output) + } +} + +func TestBatchExportListCommandError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListBatchExportJobs(gomock.Eq(testBatchID)). + Return(nil, errors.New("network error")). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportListCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testBatchID}) + w.Flush() + if err == nil { + t.Fatal("expected error on client failure, got nil") + } +} + +func TestBatchExportListCommandJSONOutput(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListBatchExportJobs(gomock.Eq(testBatchID)). + Return(batchExportListSampleJobs(), nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportListCommand(mockClient, w) + if err := cmd.Flags().Set("json", "true"); err != nil { + t.Fatalf("failed to set json flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testBatchID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + var decoded []client.ExportJobListItem + if err := json.Unmarshal(b.Bytes(), &decoded); err != nil { + t.Fatalf("expected valid JSON output, got error %v; output: %s", err, b.String()) + } + if len(decoded) != 2 { + t.Fatalf("expected 2 decoded jobs, got %d", len(decoded)) + } + if decoded[0].Filter != nil { + t.Errorf("expected first job's filter to be nil, got: %+v", decoded[0].Filter) + } + if decoded[1].Filter == nil || decoded[1].Filter.StudyID != "study-id-789" { + t.Errorf("expected second job's filter to be preserved with study_id, got: %+v", decoded[1].Filter) + } +} + +func TestBatchExportListCommandCSVOutput(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListBatchExportJobs(gomock.Eq(testBatchID)). + Return(batchExportListSampleJobs(), nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportListCommand(mockClient, w) + if err := cmd.Flags().Set("csv", "true"); err != nil { + t.Fatalf("failed to set csv flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testBatchID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + reader := csv.NewReader(strings.NewReader(b.String())) + records, err := reader.ReadAll() + if err != nil { + t.Fatalf("expected valid CSV output, got error %v; output: %s", err, b.String()) + } + if len(records) != 3 { // header + 2 rows + t.Fatalf("expected 3 CSV records (header + 2 rows), got %d: %v", len(records), records) + } + if records[0][0] != "ExportID" { + t.Errorf("expected CSV header to start with ExportID, got: %v", records[0]) + } + if !strings.Contains(records[2][1], "study_id=study-id-789") { + t.Errorf("expected filtered row's Filter column to contain study_id, got: %v", records[2]) + } +} + +func TestBatchExportListCommandTableFlagOutput(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListBatchExportJobs(gomock.Eq(testBatchID)). + Return(batchExportListSampleJobs(), nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportListCommand(mockClient, w) + // -n/--non-interactive is a hidden alias for --table; both should render + // the same non-interactive table output. + if err := cmd.Flags().Set("non-interactive", "true"); err != nil { + t.Fatalf("failed to set non-interactive flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testBatchID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := b.String() + if !strings.Contains(output, "export-filtered") || !strings.Contains(output, "study_id=study-id-789") { + t.Errorf("expected -n/--non-interactive to render a table, got: %s", output) + } +} diff --git a/cmd/aitaskbuilder/batch_export_test.go b/cmd/aitaskbuilder/batch_export_test.go index b331f641..82e9bdb6 100644 --- a/cmd/aitaskbuilder/batch_export_test.go +++ b/cmd/aitaskbuilder/batch_export_test.go @@ -78,7 +78,7 @@ func TestBatchExportCommandImmediateComplete(t *testing.T) { mockClient. EXPECT(). - InitiateBatchExport(gomock.Eq(testBatchID)). + InitiateBatchExport(gomock.Eq(testBatchID), gomock.Eq(client.ExportFilter{})). Return(&client.BatchExportResponse{ Status: "complete", URL: srv.URL + "/export.zip", @@ -129,7 +129,7 @@ func TestBatchExportCommandPollingToComplete(t *testing.T) { mockClient := mock_client.NewMockAPI(ctrl) mockClient.EXPECT(). - InitiateBatchExport(gomock.Eq(testBatchID)). + InitiateBatchExport(gomock.Eq(testBatchID), gomock.Eq(client.ExportFilter{})). Return(&client.BatchExportResponse{ Status: "generating", ExportID: testBatchExportID, @@ -179,7 +179,7 @@ func TestBatchExportCommandFailedStatus(t *testing.T) { mockClient := mock_client.NewMockAPI(ctrl) mockClient.EXPECT(). - InitiateBatchExport(gomock.Eq(testBatchID)). + InitiateBatchExport(gomock.Eq(testBatchID), gomock.Eq(client.ExportFilter{})). Return(&client.BatchExportResponse{ Status: "generating", ExportID: testBatchExportID, @@ -211,7 +211,7 @@ func TestBatchExportCommandInitiateError(t *testing.T) { mockClient := mock_client.NewMockAPI(ctrl) mockClient.EXPECT(). - InitiateBatchExport(gomock.Eq(testBatchID)). + InitiateBatchExport(gomock.Eq(testBatchID), gomock.Eq(client.ExportFilter{})). Return(nil, errors.New("network error")). Times(1) @@ -236,7 +236,7 @@ func TestBatchExportCommandDefaultOutputPath(t *testing.T) { mockClient := mock_client.NewMockAPI(ctrl) mockClient.EXPECT(). - InitiateBatchExport(gomock.Eq(testBatchID)). + InitiateBatchExport(gomock.Eq(testBatchID), gomock.Eq(client.ExportFilter{})). Return(&client.BatchExportResponse{ Status: "complete", URL: srv.URL + "/export.zip", @@ -277,3 +277,98 @@ func TestBatchExportCommandDefaultOutputPath(t *testing.T) { t.Fatalf("expected a default output file matching %s-export-*.zip to be created", testBatchID) } } + +// TestBatchExportCommandWithFilterFlags covers passing --study-id, --from, +// and --to, which should be forwarded to InitiateBatchExport as a populated +// client.ExportFilter. +func TestBatchExportCommandWithFilterFlags(t *testing.T) { + zipContent := []byte("PK\x03\x04fake zip content") + srv := newBatchZIPServer(t, zipContent) + defer aitaskbuilder.SetBatchExportDownloadClientForTesting(srv.Client())() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + wantFilter := client.ExportFilter{ + StudyID: "study-id-789", + From: "2024-01-01T00:00:00Z", + To: "2024-02-01T00:00:00Z", + } + + mockClient. + EXPECT(). + InitiateBatchExport(gomock.Eq(testBatchID), gomock.Eq(wantFilter)). + Return(&client.BatchExportResponse{ + Status: "complete", + URL: srv.URL + "/export.zip", + ExpiresAt: "2099-01-01T00:00:00Z", + }, nil). + Times(1) + + outputPath := filepath.Join(t.TempDir(), "filtered-export.zip") + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportCommand(mockClient, w) + if err := cmd.Flags().Set("output", outputPath); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + if err := cmd.Flags().Set("study-id", wantFilter.StudyID); err != nil { + t.Fatalf("failed to set study-id flag: %v", err) + } + if err := cmd.Flags().Set("from", wantFilter.From); err != nil { + t.Fatalf("failed to set from flag: %v", err) + } + if err := cmd.Flags().Set("to", wantFilter.To); err != nil { + t.Fatalf("failed to set to flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testBatchID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +// TestBatchExportCommandWithStudyIDOnly covers passing just --study-id, +// leaving --from and --to empty in the resulting filter. +func TestBatchExportCommandWithStudyIDOnly(t *testing.T) { + zipContent := []byte("PK\x03\x04fake zip content") + srv := newBatchZIPServer(t, zipContent) + defer aitaskbuilder.SetBatchExportDownloadClientForTesting(srv.Client())() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + wantFilter := client.ExportFilter{StudyID: "study-id-789"} + + mockClient. + EXPECT(). + InitiateBatchExport(gomock.Eq(testBatchID), gomock.Eq(wantFilter)). + Return(&client.BatchExportResponse{ + Status: "complete", + URL: srv.URL + "/export.zip", + ExpiresAt: "2099-01-01T00:00:00Z", + }, nil). + Times(1) + + outputPath := filepath.Join(t.TempDir(), "study-only-export.zip") + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := aitaskbuilder.NewBatchExportCommand(mockClient, w) + if err := cmd.Flags().Set("output", outputPath); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + if err := cmd.Flags().Set("study-id", wantFilter.StudyID); err != nil { + t.Fatalf("failed to set study-id flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testBatchID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} diff --git a/cmd/collection/export.go b/cmd/collection/export.go index cf134c79..d5dc326c 100644 --- a/cmd/collection/export.go +++ b/cmd/collection/export.go @@ -36,8 +36,11 @@ var downloadClient = http.DefaultClient // ExportOptions is the options for the collection export command. type ExportOptions struct { - Args []string - Output string + Args []string + Output string + StudyID string + From string + To string } // NewExportCommand creates a new `collection export` command to export a @@ -60,7 +63,14 @@ the resulting ZIP file. The archive contains: - files/ — participant-uploaded files (if any) The export is generated asynchronously. This command will poll until the -archive is ready and then download it automatically.`, +archive is ready and then download it automatically. + +Use --study-id, --from, and/or --to to narrow the export to a subset of +responses. Combining them applies all filters together (AND): --study-id +restricts the export to a specific Study, while --from/--to restrict it to +responses whose created_at falls within an ISO 8601 datetime range (--from +is inclusive, --to is exclusive). Omit all three for a full, unfiltered +export.`, Example: ` Export a collection to the default filename (-export-.zip): @@ -69,6 +79,14 @@ $ prolific collection export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a Export to a custom output path: $ prolific collection export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --output /tmp/my-export.zip + +Export only responses for a specific study: + +$ prolific collection export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --study-id 60d3b2f1a2b3c4d5e6f7a8b9 + +Export only responses created in a date range: + +$ prolific collection export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --from 2024-01-01T00:00:00Z --to 2024-02-01T00:00:00Z `, RunE: func(cmd *cobra.Command, args []string) error { opts.Args = args @@ -86,6 +104,15 @@ $ prolific collection export 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --output /tmp/ } cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path (default: -export.zip)") + cmd.Flags().StringVar(&opts.StudyID, "study-id", "", "Restrict the export to responses submitted under this Study ID") + cmd.Flags().StringVar(&opts.From, "from", "", "Restrict the export to responses created on or after this ISO 8601 datetime (inclusive)") + cmd.Flags().StringVar(&opts.To, "to", "", "Restrict the export to responses created before this ISO 8601 datetime (exclusive)") + + cmd.AddCommand( + NewExportListCommand(c, w), + NewExportDeleteCommand(c, w), + NewExportDownloadCommand(c, w), + ) return cmd } @@ -96,7 +123,8 @@ func exportCollection(c client.API, opts ExportOptions, w io.Writer) error { fmt.Fprintf(w, "Requesting export for collection %s...\n", collectionID) // Step 1: POST to initiate the export job. - initResult, err := c.InitiateCollectionExport(collectionID) + filter := client.ExportFilter{StudyID: opts.StudyID, From: opts.From, To: opts.To} + initResult, err := c.InitiateCollectionExport(collectionID, filter) if err != nil { if shared.IsFeatureNotEnabledError(err) { ui.RenderFeatureAccessMessage(FeatureNameAITBCollection, FeatureContactURLAITBCollection) @@ -114,14 +142,27 @@ func exportCollection(c client.API, opts ExportOptions, w io.Writer) error { return fmt.Errorf("unexpected export status %q for collection %s", initResult.Status, collectionID) } - exportID := initResult.ExportID - // Step 2: Poll GET until complete or failed. + url, err := pollCollectionExportUntilDone(c, collectionID, initResult.ExportID, w) + if err != nil { + return err + } + + return downloadExport(url, opts.Output, w) +} + +// pollCollectionExportUntilDone polls GetCollectionExportStatus for +// collectionID/exportID until the job reaches "complete" or "failed" (or +// the poll deadline is exceeded), printing a "." to w for each poll. It +// returns the presigned download URL on success. Shared by exportCollection +// (which polls a job it just requested) and downloadCollectionExport (which +// polls a pre-existing job that was still generating). +func pollCollectionExportUntilDone(c client.API, collectionID, exportID string, w io.Writer) (string, error) { deadline := time.Now().Add(exportTimeout) for { if time.Now().After(deadline) { - return fmt.Errorf("export timed out after 10 minutes for collection %s", collectionID) + return "", fmt.Errorf("export timed out after 10 minutes for collection %s", collectionID) } fmt.Fprint(w, ".") @@ -129,18 +170,18 @@ func exportCollection(c client.API, opts ExportOptions, w io.Writer) error { pollResult, err := c.GetCollectionExportStatus(collectionID, exportID) if err != nil { - return fmt.Errorf("error polling export status: %s", err.Error()) + return "", fmt.Errorf("error polling export status: %s", err.Error()) } switch pollResult.Status { case exportStatusComplete: - return downloadExport(pollResult.URL, opts.Output, w) + return pollResult.URL, nil case exportStatusFailed: - return fmt.Errorf("export generation failed for collection %s", collectionID) + return "", fmt.Errorf("export generation failed for collection %s", collectionID) case exportStatusGenerating: // continue polling default: - return fmt.Errorf("unexpected export status %q for collection %s", pollResult.Status, collectionID) + return "", fmt.Errorf("unexpected export status %q for collection %s", pollResult.Status, collectionID) } } } diff --git a/cmd/collection/export_delete.go b/cmd/collection/export_delete.go new file mode 100644 index 00000000..843120e8 --- /dev/null +++ b/cmd/collection/export_delete.go @@ -0,0 +1,66 @@ +package collection + +import ( + "errors" + "fmt" + "io" + + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/shared" + "github.com/prolific-oss/cli/ui" + "github.com/spf13/cobra" +) + +// ExportDeleteOptions is the options for the collection export delete command. +type ExportDeleteOptions struct { + Args []string +} + +// NewExportDeleteCommand creates a new `collection export delete` command to +// permanently delete an export job for a collection. +func NewExportDeleteCommand(c client.API, w io.Writer) *cobra.Command { + var opts ExportDeleteOptions + + cmd := &cobra.Command{ + Use: "delete ", + Args: cobra.ExactArgs(2), + Short: "Delete a collection's export job", + Long: `Delete a collection's export job + +This command permanently deletes an export job and, if it completed, its +ZIP archive. An export that is still generating cannot be deleted.`, + Example: ` +Delete an export job for a collection: + +$ prolific collection export delete 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a export-job-uuid-456 +`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Args = args + + if len(opts.Args) < 2 || opts.Args[0] == "" || opts.Args[1] == "" { + return errors.New("please provide a collection ID and an export ID") + } + + return deleteCollectionExport(c, opts, w) + }, + } + + return cmd +} + +func deleteCollectionExport(c client.API, opts ExportDeleteOptions, w io.Writer) error { + collectionID := opts.Args[0] + exportID := opts.Args[1] + + if err := c.DeleteCollectionExport(collectionID, exportID); err != nil { + if shared.IsFeatureNotEnabledError(err) { + ui.RenderFeatureAccessMessage(FeatureNameAITBCollection, FeatureContactURLAITBCollection) + return nil + } + return fmt.Errorf("error deleting export: %s", err.Error()) + } + + fmt.Fprintf(w, "Export %s for collection %s deleted.\n", exportID, collectionID) + + return nil +} diff --git a/cmd/collection/export_delete_test.go b/cmd/collection/export_delete_test.go new file mode 100644 index 00000000..1d68034c --- /dev/null +++ b/cmd/collection/export_delete_test.go @@ -0,0 +1,112 @@ +package collection_test + +import ( + "bufio" + "bytes" + "errors" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/prolific-oss/cli/cmd/collection" + "github.com/prolific-oss/cli/mock_client" +) + +func TestNewExportDeleteCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewExportDeleteCommand(mockClient, &buf) + + if cmd.Use != "delete " { + t.Fatalf("expected use: delete ; got %s", cmd.Use) + } +} + +func TestExportDeleteCommandRequiresArgs(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewExportDeleteCommand(mockClient, &buf) + + if err := cmd.RunE(cmd, []string{}); err == nil { + t.Fatal("expected error for missing args, got nil") + } + + if err := cmd.RunE(cmd, []string{testCollectionID}); err == nil { + t.Fatal("expected error for missing export ID, got nil") + } +} + +func TestExportDeleteCommandHappyPath(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + DeleteCollectionExport(gomock.Eq(testCollectionID), gomock.Eq(testExportID)). + Return(nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportDeleteCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID, testExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := b.String() + want := "Export " + testExportID + " for collection " + testCollectionID + " deleted." + if !strings.Contains(output, want) { + t.Errorf("expected confirmation message %q, got: %s", want, output) + } +} + +func TestExportDeleteCommandError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + DeleteCollectionExport(gomock.Eq(testCollectionID), gomock.Eq(testExportID)). + Return(errors.New("conflict: export still generating")). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportDeleteCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID, testExportID}) + w.Flush() + if err == nil { + t.Fatal("expected error on client failure, got nil") + } +} + +func TestExportDeleteCommandFeatureNotEnabled(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + DeleteCollectionExport(gomock.Eq(testCollectionID), gomock.Eq(testExportID)). + Return(errors.New("request failed: you do not currently have permission to access this feature")). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportDeleteCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID, testExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error for feature-not-enabled, got: %v", err) + } +} diff --git a/cmd/collection/export_download.go b/cmd/collection/export_download.go new file mode 100644 index 00000000..aedcd1d1 --- /dev/null +++ b/cmd/collection/export_download.go @@ -0,0 +1,99 @@ +package collection + +import ( + "errors" + "fmt" + "io" + + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/shared" + "github.com/prolific-oss/cli/ui" + "github.com/spf13/cobra" +) + +// ExportDownloadOptions is the options for the collection export download +// command. +type ExportDownloadOptions struct { + Args []string + Output string +} + +// NewExportDownloadCommand creates a new `collection export download` +// command to download an existing export job's ZIP archive without +// requesting a new export. +func NewExportDownloadCommand(c client.API, w io.Writer) *cobra.Command { + var opts ExportDownloadOptions + + cmd := &cobra.Command{ + Use: "download ", + Args: cobra.ExactArgs(2), + Short: "Download an existing collection export job", + Long: `Download an existing collection export job + +This command downloads the ZIP archive for an export job that was already +requested — for example via 'collection export' or one shown by +'collection export list' — without starting a new export. + +If the job is still generating, this command polls until it completes and +then downloads it automatically, just like 'collection export' does for a +newly-requested job. If the job failed, request a fresh export with +'collection export ' instead.`, + Example: ` +Download an existing, completed export job: + +$ prolific collection export download 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a export-job-uuid-456 + +Download to a custom output path: + +$ prolific collection export download 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a export-job-uuid-456 --output /tmp/my-export.zip +`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Args = args + + if len(opts.Args) < 2 || opts.Args[0] == "" || opts.Args[1] == "" { + return errors.New("please provide a collection ID and an export ID") + } + + if opts.Output == "" { + opts.Output = fmt.Sprintf("%s-export-%s.zip", opts.Args[0], opts.Args[1]) + } + + return downloadCollectionExport(c, opts, w) + }, + } + + cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path (default: -export-.zip)") + + return cmd +} + +func downloadCollectionExport(c client.API, opts ExportDownloadOptions, w io.Writer) error { + collectionID := opts.Args[0] + exportID := opts.Args[1] + + fmt.Fprintf(w, "Checking export %s for collection %s...\n", exportID, collectionID) + + status, err := c.GetCollectionExportStatus(collectionID, exportID) + if err != nil { + if shared.IsFeatureNotEnabledError(err) { + ui.RenderFeatureAccessMessage(FeatureNameAITBCollection, FeatureContactURLAITBCollection) + return nil + } + return fmt.Errorf("error checking export status: %s", err.Error()) + } + + switch status.Status { + case exportStatusComplete: + return downloadExport(status.URL, opts.Output, w) + case exportStatusFailed: + return fmt.Errorf("export %s for collection %s failed to generate; request a new export with 'collection export %s'", exportID, collectionID, collectionID) + case exportStatusGenerating: + url, err := pollCollectionExportUntilDone(c, collectionID, exportID, w) + if err != nil { + return err + } + return downloadExport(url, opts.Output, w) + default: + return fmt.Errorf("unexpected export status %q for collection %s", status.Status, collectionID) + } +} diff --git a/cmd/collection/export_download_test.go b/cmd/collection/export_download_test.go new file mode 100644 index 00000000..d30e1430 --- /dev/null +++ b/cmd/collection/export_download_test.go @@ -0,0 +1,256 @@ +package collection_test + +import ( + "bufio" + "bytes" + "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" +) + +const testCollectionExportID = "export-job-uuid-456" + +func TestNewExportDownloadCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewExportDownloadCommand(mockClient, &buf) + + if cmd.Use != "download " { + t.Fatalf("expected use: download ; got %s", cmd.Use) + } +} + +func TestExportDownloadCommandRequiresArgs(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewExportDownloadCommand(mockClient, &buf) + + for _, args := range [][]string{{}, {testCollectionID}} { + if err := cmd.RunE(cmd, args); err == nil { + t.Fatalf("expected error for args %v, got nil", args) + } + } +} + +// TestExportDownloadCommandAlreadyComplete covers downloading a job that is +// already complete — no polling required. +func TestExportDownloadCommandAlreadyComplete(t *testing.T) { + zipContent := []byte("PK\x03\x04fake zip content") + srv := newZIPServer(t, zipContent) + defer collection.SetDownloadClientForTesting(srv.Client())() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetCollectionExportStatus(gomock.Eq(testCollectionID), gomock.Eq(testCollectionExportID)). + Return(&client.CollectionExportResponse{ + Status: "complete", + URL: srv.URL + "/export.zip", + ExpiresAt: "2099-01-01T00:00:00Z", + }, nil). + Times(1) + + outputPath := filepath.Join(t.TempDir(), "download-complete.zip") + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportDownloadCommand(mockClient, w) + if err := cmd.Flags().Set("output", outputPath); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testCollectionID, testCollectionExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + data, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("expected output file to exist: %v", err) + } + if !bytes.Equal(data, zipContent) { + t.Fatalf("expected file contents to match download, got: %v", data) + } +} + +// TestExportDownloadCommandPollsWhileGenerating covers downloading a job +// that is still generating — the command should poll until complete. +func TestExportDownloadCommandPollsWhileGenerating(t *testing.T) { + defer collection.SetPollSleepForTesting(func(time.Duration) {})() + + zipContent := []byte("PK\x03\x04fake zip content") + srv := newZIPServer(t, zipContent) + defer collection.SetDownloadClientForTesting(srv.Client())() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + gomock.InOrder( + mockClient.EXPECT(). + GetCollectionExportStatus(gomock.Eq(testCollectionID), gomock.Eq(testCollectionExportID)). + Return(&client.CollectionExportResponse{Status: "generating"}, nil). + Times(1), + mockClient.EXPECT(). + GetCollectionExportStatus(gomock.Eq(testCollectionID), gomock.Eq(testCollectionExportID)). + Return(&client.CollectionExportResponse{ + Status: "complete", + URL: srv.URL + "/export.zip", + ExpiresAt: "2099-01-01T00:00:00Z", + }, nil). + Times(1), + ) + + outputPath := filepath.Join(t.TempDir(), "download-polled.zip") + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportDownloadCommand(mockClient, w) + if err := cmd.Flags().Set("output", outputPath); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testCollectionID, testCollectionExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if _, err := os.Stat(outputPath); os.IsNotExist(err) { + t.Fatal("expected output file to exist after polling") + } +} + +func TestExportDownloadCommandFailedStatus(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetCollectionExportStatus(gomock.Eq(testCollectionID), gomock.Eq(testCollectionExportID)). + Return(&client.CollectionExportResponse{Status: "failed"}, nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportDownloadCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID, testCollectionExportID}) + w.Flush() + if err == nil { + t.Fatal("expected error for failed status, got nil") + } + if !strings.Contains(err.Error(), "failed to generate") { + t.Errorf("expected error to explain the job failed, got: %v", err) + } + if !strings.Contains(err.Error(), "collection export "+testCollectionID) { + t.Errorf("expected error to suggest requesting a new export, got: %v", err) + } +} + +func TestExportDownloadCommandStatusError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetCollectionExportStatus(gomock.Eq(testCollectionID), gomock.Eq(testCollectionExportID)). + Return(nil, errors.New("network error")). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportDownloadCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID, testCollectionExportID}) + w.Flush() + if err == nil { + t.Fatal("expected error on client failure, got nil") + } +} + +func TestExportDownloadCommandFeatureNotEnabled(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetCollectionExportStatus(gomock.Eq(testCollectionID), gomock.Eq(testCollectionExportID)). + Return(nil, errors.New("request failed: you do not currently have permission to access this feature")). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportDownloadCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID, testCollectionExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error for feature-not-enabled, got: %v", err) + } +} + +func TestExportDownloadCommandDefaultOutputPath(t *testing.T) { + zipContent := []byte("PK\x03\x04fake zip content") + srv := newZIPServer(t, zipContent) + defer collection.SetDownloadClientForTesting(srv.Client())() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + GetCollectionExportStatus(gomock.Eq(testCollectionID), gomock.Eq(testCollectionExportID)). + Return(&client.CollectionExportResponse{ + Status: "complete", + URL: srv.URL + "/export.zip", + ExpiresAt: "2099-01-01T00:00:00Z", + }, nil). + Times(1) + + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + tmpDir := t.TempDir() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir to temp dir: %v", err) + } + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Errorf("failed to restore working directory: %v", err) + } + }() + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportDownloadCommand(mockClient, w) + + err = cmd.RunE(cmd, []string{testCollectionID, testCollectionExportID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + wantPath := testCollectionID + "-export-" + testCollectionExportID + ".zip" + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("expected default output file %s to exist: %v", wantPath, err) + } +} diff --git a/cmd/collection/export_list.go b/cmd/collection/export_list.go new file mode 100644 index 00000000..21d3ea85 --- /dev/null +++ b/cmd/collection/export_list.go @@ -0,0 +1,127 @@ +package collection + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/shared" + "github.com/prolific-oss/cli/ui" + "github.com/spf13/cobra" +) + +// ExportListOptions is the options for the collection export list command. +type ExportListOptions struct { + Args []string + Output shared.OutputOptions +} + +// NewExportListCommand creates a new `collection export list` command to +// list every export job requested for a collection. +func NewExportListCommand(c client.API, w io.Writer) *cobra.Command { + var opts ExportListOptions + + cmd := &cobra.Command{ + Use: "list ", + Args: cobra.MinimumNArgs(1), + Short: "List a collection's export jobs", + Long: `List a collection's export jobs + +This command lists every export job that has been requested for a +collection, most recent first, including its status and the filter (if any) +it was requested with. A collection can have at most 10 export jobs at +once.`, + Example: ` +List all export jobs for a collection: + +$ prolific collection export list 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a + +You can output as a table +$ prolific collection export list 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --table + +You can output as CSV +$ prolific collection export list 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --csv + +You can output as JSON +$ prolific collection export list 5f8e3c2a-1d4b-4e6f-9a7c-2b0d8f3e1c5a --json +`, + 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") + } + + return listCollectionExports(c, opts, w) + }, + } + + shared.AddOutputFlags(cmd, &opts.Output) + + return cmd +} + +func listCollectionExports(c client.API, opts ExportListOptions, w io.Writer) error { + collectionID := opts.Args[0] + + jobs, err := c.ListCollectionExportJobs(collectionID) + if err != nil { + if shared.IsFeatureNotEnabledError(err) { + ui.RenderFeatureAccessMessage(FeatureNameAITBCollection, FeatureContactURLAITBCollection) + return nil + } + return fmt.Errorf("error listing exports: %s", err.Error()) + } + + switch shared.ResolveFormat(opts.Output) { + case "json": + r := ui.JSONRenderer[client.ExportJobListItem]{} + if err := r.Render(jobs, w); err != nil { + return fmt.Errorf("error: %s", err) + } + case "csv": + r := ui.CsvRenderer[ExportListItem]{} + if err := r.Render(NewExportListItems(jobs), ExportListFields, w); err != nil { + return fmt.Errorf("error: %s", err) + } + default: + if len(jobs) == 0 { + fmt.Fprintf(w, "No export jobs found for collection %s\n", collectionID) + return nil + } + r := ui.TableRenderer[ExportListItem]{} + if err := r.Render(NewExportListItems(jobs), ExportListFields, w); err != nil { + return fmt.Errorf("error: %s", err) + } + } + + return nil +} + +// formatExportJobFilter renders an export job's filter as a short, +// human-readable summary, e.g. "study_id=X, from=Y, to=Z". A nil filter +// (unfiltered/full export) renders as "-". +func formatExportJobFilter(filter *client.ExportJobFilter) string { + if filter == nil { + return "-" + } + + var parts []string + if filter.StudyID != "" { + parts = append(parts, fmt.Sprintf("study_id=%s", filter.StudyID)) + } + if filter.From != "" { + parts = append(parts, fmt.Sprintf("from=%s", filter.From)) + } + if filter.To != "" { + parts = append(parts, fmt.Sprintf("to=%s", filter.To)) + } + + if len(parts) == 0 { + return "none" + } + + return strings.Join(parts, ", ") +} diff --git a/cmd/collection/export_list_items.go b/cmd/collection/export_list_items.go new file mode 100644 index 00000000..7104bfb5 --- /dev/null +++ b/cmd/collection/export_list_items.go @@ -0,0 +1,31 @@ +package collection + +import "github.com/prolific-oss/cli/client" + +// ExportListFields are the fields shown in collection export list table and +// CSV output. +const ExportListFields = "ExportID,Filter,Status,CreatedAt" + +// ExportListItem is the flat presentation model used by table and CSV +// renderers. The API model remains nested (with a pointer Filter field) so +// JSON output preserves the API response shape. +type ExportListItem struct { + ExportID string + Filter string + Status string + CreatedAt string +} + +// NewExportListItems converts export job API models into display-ready rows. +func NewExportListItems(jobs []client.ExportJobListItem) []ExportListItem { + items := make([]ExportListItem, 0, len(jobs)) + for _, job := range jobs { + items = append(items, ExportListItem{ + ExportID: job.ExportID, + Filter: formatExportJobFilter(job.Filter), + Status: job.Status, + CreatedAt: job.CreatedAt, + }) + } + return items +} diff --git a/cmd/collection/export_list_test.go b/cmd/collection/export_list_test.go new file mode 100644 index 00000000..1f0a4cea --- /dev/null +++ b/cmd/collection/export_list_test.go @@ -0,0 +1,291 @@ +package collection_test + +import ( + "bufio" + "bytes" + "encoding/csv" + "encoding/json" + "errors" + "strings" + "testing" + + "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" +) + +func exportListSampleJobs() []client.ExportJobListItem { + return []client.ExportJobListItem{ + { + ExportID: "export-unfiltered", + Filter: nil, + Status: "complete", + CreatedAt: "2024-01-01T00:00:00Z", + }, + { + ExportID: "export-filtered", + Filter: &client.ExportJobFilter{ + StudyID: "study-id-789", + From: "2024-01-01T00:00:00Z", + To: "2024-02-01T00:00:00Z", + }, + Status: "generating", + CreatedAt: "2024-02-01T00:00:00Z", + }, + } +} + +func TestNewExportListCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewExportListCommand(mockClient, &buf) + + if cmd.Use != "list " { + t.Fatalf("expected use: list ; got %s", cmd.Use) + } +} + +func TestExportListCommandRegistersOutputFlags(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewExportListCommand(mockClient, &buf) + + for _, name := range []string{"json", "csv", "table", "non-interactive"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("expected --%s flag to be registered", name) + } + } +} + +func TestExportListCommandRequiresCollectionID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewExportListCommand(mockClient, &buf) + + err := cmd.RunE(cmd, []string{}) + if err == nil { + t.Fatal("expected error for missing collection ID, got nil") + } +} + +func TestExportListCommandHappyPath(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListCollectionExportJobs(gomock.Eq(testCollectionID)). + Return(exportListSampleJobs(), nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportListCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := b.String() + for _, want := range []string{ + "export-unfiltered", + "export-filtered", + "complete", + "generating", + "study_id=study-id-789", + "from=2024-01-01T00:00:00Z", + "to=2024-02-01T00:00:00Z", + } { + if !strings.Contains(output, want) { + t.Errorf("expected output to contain %q, got: %s", want, output) + } + } +} + +func TestExportListCommandEmpty(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListCollectionExportJobs(gomock.Eq(testCollectionID)). + Return([]client.ExportJobListItem{}, nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportListCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := b.String() + if !strings.Contains(output, "No export jobs found for collection "+testCollectionID) { + t.Errorf("expected friendly empty message, got: %s", output) + } +} + +func TestExportListCommandError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListCollectionExportJobs(gomock.Eq(testCollectionID)). + Return(nil, errors.New("network error")). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportListCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID}) + w.Flush() + if err == nil { + t.Fatal("expected error on client failure, got nil") + } +} + +func TestExportListCommandFeatureNotEnabled(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListCollectionExportJobs(gomock.Eq(testCollectionID)). + Return(nil, errors.New("request failed: you do not currently have permission to access this feature")). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportListCommand(mockClient, w) + + err := cmd.RunE(cmd, []string{testCollectionID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error for feature-not-enabled, got: %v", err) + } +} + +func TestExportListCommandJSONOutput(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListCollectionExportJobs(gomock.Eq(testCollectionID)). + Return(exportListSampleJobs(), nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportListCommand(mockClient, w) + if err := cmd.Flags().Set("json", "true"); err != nil { + t.Fatalf("failed to set json flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testCollectionID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + var decoded []client.ExportJobListItem + if err := json.Unmarshal(b.Bytes(), &decoded); err != nil { + t.Fatalf("expected valid JSON output, got error %v; output: %s", err, b.String()) + } + if len(decoded) != 2 { + t.Fatalf("expected 2 decoded jobs, got %d", len(decoded)) + } + if decoded[0].Filter != nil { + t.Errorf("expected first job's filter to be nil, got: %+v", decoded[0].Filter) + } + if decoded[1].Filter == nil || decoded[1].Filter.StudyID != "study-id-789" { + t.Errorf("expected second job's filter to be preserved with study_id, got: %+v", decoded[1].Filter) + } +} + +func TestExportListCommandCSVOutput(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListCollectionExportJobs(gomock.Eq(testCollectionID)). + Return(exportListSampleJobs(), nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportListCommand(mockClient, w) + if err := cmd.Flags().Set("csv", "true"); err != nil { + t.Fatalf("failed to set csv flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testCollectionID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + reader := csv.NewReader(strings.NewReader(b.String())) + records, err := reader.ReadAll() + if err != nil { + t.Fatalf("expected valid CSV output, got error %v; output: %s", err, b.String()) + } + if len(records) != 3 { // header + 2 rows + t.Fatalf("expected 3 CSV records (header + 2 rows), got %d: %v", len(records), records) + } + if records[0][0] != "ExportID" { + t.Errorf("expected CSV header to start with ExportID, got: %v", records[0]) + } + if !strings.Contains(records[2][1], "study_id=study-id-789") { + t.Errorf("expected filtered row's Filter column to contain study_id, got: %v", records[2]) + } +} + +func TestExportListCommandTableFlagOutput(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient.EXPECT(). + ListCollectionExportJobs(gomock.Eq(testCollectionID)). + Return(exportListSampleJobs(), nil). + Times(1) + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportListCommand(mockClient, w) + // -n/--non-interactive is a hidden alias for --table; both should render + // the same non-interactive table output. + if err := cmd.Flags().Set("non-interactive", "true"); err != nil { + t.Fatalf("failed to set non-interactive flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testCollectionID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := b.String() + if !strings.Contains(output, "export-filtered") || !strings.Contains(output, "study_id=study-id-789") { + t.Errorf("expected -n/--non-interactive to render a table, got: %s", output) + } +} diff --git a/cmd/collection/export_test.go b/cmd/collection/export_test.go index 25fa54cb..f8967c8b 100644 --- a/cmd/collection/export_test.go +++ b/cmd/collection/export_test.go @@ -77,7 +77,7 @@ func TestExportCommandImmediateComplete(t *testing.T) { mockClient. EXPECT(). - InitiateCollectionExport(gomock.Eq(testCollectionID)). + InitiateCollectionExport(gomock.Eq(testCollectionID), gomock.Eq(client.ExportFilter{})). Return(&client.CollectionExportResponse{ Status: "complete", URL: srv.URL + "/export.zip", @@ -128,7 +128,7 @@ func TestExportCommandPollingToComplete(t *testing.T) { mockClient := mock_client.NewMockAPI(ctrl) mockClient.EXPECT(). - InitiateCollectionExport(gomock.Eq(testCollectionID)). + InitiateCollectionExport(gomock.Eq(testCollectionID), gomock.Eq(client.ExportFilter{})). Return(&client.CollectionExportResponse{ Status: "generating", ExportID: testExportID, @@ -178,7 +178,7 @@ func TestExportCommandFailedStatus(t *testing.T) { mockClient := mock_client.NewMockAPI(ctrl) mockClient.EXPECT(). - InitiateCollectionExport(gomock.Eq(testCollectionID)). + InitiateCollectionExport(gomock.Eq(testCollectionID), gomock.Eq(client.ExportFilter{})). Return(&client.CollectionExportResponse{ Status: "generating", ExportID: testExportID, @@ -210,7 +210,7 @@ func TestExportCommandInitiateError(t *testing.T) { mockClient := mock_client.NewMockAPI(ctrl) mockClient.EXPECT(). - InitiateCollectionExport(gomock.Eq(testCollectionID)). + InitiateCollectionExport(gomock.Eq(testCollectionID), gomock.Eq(client.ExportFilter{})). Return(nil, errors.New("network error")). Times(1) @@ -225,6 +225,58 @@ func TestExportCommandInitiateError(t *testing.T) { } } +// TestExportCommandWithFilterFlags covers passing --study-id, --from, and +// --to flags, ensuring they are forwarded as a client.ExportFilter. +func TestExportCommandWithFilterFlags(t *testing.T) { + zipContent := []byte("PK\x03\x04fake zip content") + srv := newZIPServer(t, zipContent) + defer collection.SetDownloadClientForTesting(srv.Client())() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + wantFilter := client.ExportFilter{ + StudyID: "study-id-123", + From: "2024-01-01T00:00:00Z", + To: "2024-02-01T00:00:00Z", + } + + mockClient. + EXPECT(). + InitiateCollectionExport(gomock.Eq(testCollectionID), gomock.Eq(wantFilter)). + Return(&client.CollectionExportResponse{ + Status: "complete", + URL: srv.URL + "/export.zip", + ExpiresAt: "2099-01-01T00:00:00Z", + }, nil). + Times(1) + + outputPath := filepath.Join(t.TempDir(), "filtered-export.zip") + + var b bytes.Buffer + w := bufio.NewWriter(&b) + cmd := collection.NewExportCommand(mockClient, w) + if err := cmd.Flags().Set("output", outputPath); err != nil { + t.Fatalf("failed to set output flag: %v", err) + } + if err := cmd.Flags().Set("study-id", wantFilter.StudyID); err != nil { + t.Fatalf("failed to set study-id flag: %v", err) + } + if err := cmd.Flags().Set("from", wantFilter.From); err != nil { + t.Fatalf("failed to set from flag: %v", err) + } + if err := cmd.Flags().Set("to", wantFilter.To); err != nil { + t.Fatalf("failed to set to flag: %v", err) + } + + err := cmd.RunE(cmd, []string{testCollectionID}) + w.Flush() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + func TestExportCommandDefaultOutputPath(t *testing.T) { zipContent := []byte("PK\x03\x04fake zip content") srv := newZIPServer(t, zipContent) @@ -235,7 +287,7 @@ func TestExportCommandDefaultOutputPath(t *testing.T) { mockClient := mock_client.NewMockAPI(ctrl) mockClient.EXPECT(). - InitiateCollectionExport(gomock.Eq(testCollectionID)). + InitiateCollectionExport(gomock.Eq(testCollectionID), gomock.Eq(client.ExportFilter{})). Return(&client.CollectionExportResponse{ Status: "complete", URL: srv.URL + "/export.zip", diff --git a/contract_test/contract_test.go b/contract_test/contract_test.go index fe001592..d6ac4743 100644 --- a/contract_test/contract_test.go +++ b/contract_test/contract_test.go @@ -195,8 +195,16 @@ var operations = []operation{ {operationID: "aiTaskBuilder_DuplicateTaskBuilderBatch", skip: "OUTOFSCOPE: no CLI command for duplicating a batch"}, {operationID: "aiTaskBuilder_SyncTaskBuilderBatch", call: func(c *client.Client) { c.SyncAITaskBuilderBatch("batch-id") }}, {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_RequestBatchExport", call: func(c *client.Client) { + c.InitiateBatchExport("batch-id", client.ExportFilter{ + StudyID: "study-id", + From: "2024-01-01T00:00:00Z", + To: "2024-02-01T00:00:00Z", + }) + }}, {operationID: "aiTaskBuilder_GetBatchExportStatus", call: func(c *client.Client) { c.GetBatchExportStatus("batch-id", "export-id") }}, + {operationID: "aiTaskBuilder_ListBatchExportJobs", call: func(c *client.Client) { c.ListBatchExportJobs("batch-id") }}, + {operationID: "aiTaskBuilder_DeleteBatchExport", call: func(c *client.Client) { c.DeleteBatchExport("batch-id", "export-id") }}, // AI Task Builder — Datasets {operationID: "aiTaskBuilder_CreateTaskBuilderDataset", call: func(c *client.Client) { @@ -240,10 +248,18 @@ var operations = []operation{ }) }}, {operationID: "aiTaskBuilder_GetCollectionResponses", skip: "OUTOFSCOPE: no CLI command for getting collection responses"}, - {operationID: "aiTaskBuilder_RequestCollectionExport", call: func(c *client.Client) { c.InitiateCollectionExport("coll-id") }}, + {operationID: "aiTaskBuilder_RequestCollectionExport", call: func(c *client.Client) { + c.InitiateCollectionExport("coll-id", client.ExportFilter{ + StudyID: "study-id", + From: "2024-01-01T00:00:00Z", + To: "2024-02-01T00:00:00Z", + }) + }}, {operationID: "aiTaskBuilder_GetCollectionExportStatus", call: func(c *client.Client) { c.GetCollectionExportStatus("coll-id", "export-id") }}, + {operationID: "aiTaskBuilder_ListCollectionExportJobs", call: func(c *client.Client) { c.ListCollectionExportJobs("coll-id") }}, + {operationID: "aiTaskBuilder_DeleteCollectionExport", call: func(c *client.Client) { c.DeleteCollectionExport("coll-id", "export-id") }}, // Invitations {operationID: "invitations_CreateInvitation", call: func(c *client.Client) { @@ -268,6 +284,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_CreateConversation", skip: "OUTOFSCOPE: no CLI command for starting a conversation"}, {operationID: "messages_GetConversationMessages", skip: "OUTOFSCOPE: no CLI command for retrieving conversation messages"}, // Studies diff --git a/mock_client/mock_client.go b/mock_client/mock_client.go index 1247ab91..29890bed 100644 --- a/mock_client/mock_client.go +++ b/mock_client/mock_client.go @@ -348,6 +348,34 @@ func (mr *MockAPIMockRecorder) DeleteAllSurveyResponses(surveyID interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllSurveyResponses", reflect.TypeOf((*MockAPI)(nil).DeleteAllSurveyResponses), surveyID) } +// DeleteBatchExport mocks base method. +func (m *MockAPI) DeleteBatchExport(batchID, exportID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteBatchExport", batchID, exportID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteBatchExport indicates an expected call of DeleteBatchExport. +func (mr *MockAPIMockRecorder) DeleteBatchExport(batchID, exportID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBatchExport", reflect.TypeOf((*MockAPI)(nil).DeleteBatchExport), batchID, exportID) +} + +// DeleteCollectionExport mocks base method. +func (m *MockAPI) DeleteCollectionExport(collectionID, exportID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCollectionExport", collectionID, exportID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteCollectionExport indicates an expected call of DeleteCollectionExport. +func (mr *MockAPIMockRecorder) DeleteCollectionExport(collectionID, exportID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCollectionExport", reflect.TypeOf((*MockAPI)(nil).DeleteCollectionExport), collectionID, exportID) +} + // DeleteHookSubscription mocks base method. func (m *MockAPI) DeleteHookSubscription(subscriptionID string) error { m.ctrl.T.Helper() @@ -1111,33 +1139,63 @@ func (mr *MockAPIMockRecorder) GetWorkspaces(limit, offset interface{}) *gomock. } // InitiateBatchExport mocks base method. -func (m *MockAPI) InitiateBatchExport(batchID string) (*client.BatchExportResponse, error) { +func (m *MockAPI) InitiateBatchExport(batchID string, filter client.ExportFilter) (*client.BatchExportResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InitiateBatchExport", batchID) + ret := m.ctrl.Call(m, "InitiateBatchExport", batchID, filter) ret0, _ := ret[0].(*client.BatchExportResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // InitiateBatchExport indicates an expected call of InitiateBatchExport. -func (mr *MockAPIMockRecorder) InitiateBatchExport(batchID interface{}) *gomock.Call { +func (mr *MockAPIMockRecorder) InitiateBatchExport(batchID, filter interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiateBatchExport", reflect.TypeOf((*MockAPI)(nil).InitiateBatchExport), batchID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiateBatchExport", reflect.TypeOf((*MockAPI)(nil).InitiateBatchExport), batchID, filter) } // InitiateCollectionExport mocks base method. -func (m *MockAPI) InitiateCollectionExport(collectionID string) (*client.CollectionExportResponse, error) { +func (m *MockAPI) InitiateCollectionExport(collectionID string, filter client.ExportFilter) (*client.CollectionExportResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InitiateCollectionExport", collectionID) + ret := m.ctrl.Call(m, "InitiateCollectionExport", collectionID, filter) ret0, _ := ret[0].(*client.CollectionExportResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // InitiateCollectionExport indicates an expected call of InitiateCollectionExport. -func (mr *MockAPIMockRecorder) InitiateCollectionExport(collectionID interface{}) *gomock.Call { +func (mr *MockAPIMockRecorder) InitiateCollectionExport(collectionID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiateCollectionExport", reflect.TypeOf((*MockAPI)(nil).InitiateCollectionExport), collectionID, filter) +} + +// ListBatchExportJobs mocks base method. +func (m *MockAPI) ListBatchExportJobs(batchID string) ([]client.ExportJobListItem, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListBatchExportJobs", batchID) + ret0, _ := ret[0].([]client.ExportJobListItem) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListBatchExportJobs indicates an expected call of ListBatchExportJobs. +func (mr *MockAPIMockRecorder) ListBatchExportJobs(batchID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBatchExportJobs", reflect.TypeOf((*MockAPI)(nil).ListBatchExportJobs), batchID) +} + +// ListCollectionExportJobs mocks base method. +func (m *MockAPI) ListCollectionExportJobs(collectionID string) ([]client.ExportJobListItem, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListCollectionExportJobs", collectionID) + ret0, _ := ret[0].([]client.ExportJobListItem) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListCollectionExportJobs indicates an expected call of ListCollectionExportJobs. +func (mr *MockAPIMockRecorder) ListCollectionExportJobs(collectionID interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitiateCollectionExport", reflect.TypeOf((*MockAPI)(nil).InitiateCollectionExport), collectionID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCollectionExportJobs", reflect.TypeOf((*MockAPI)(nil).ListCollectionExportJobs), collectionID) } // ListCredentialPools mocks base method.