From 96c36eb577fca67d005ba52eebb084a87dcc10b7 Mon Sep 17 00:00:00 2001 From: Cem Date: Tue, 14 Jul 2026 17:02:42 +0100 Subject: [PATCH 1/4] feat(DCT-51): audio_url and batch preview --- client/client.go | 13 ++ client/responses.go | 4 + cmd/aitaskbuilder/batch_preview.go | 109 +++++++++++ cmd/aitaskbuilder/batch_preview_test.go | 173 ++++++++++++++++++ cmd/aitaskbuilder/batch_setup_test.go | 2 +- cmd/aitaskbuilder/batches.go | 13 ++ cmd/aitaskbuilder/constants.go | 1 + cmd/aitaskbuilder/create_dataset.go | 9 +- cmd/aitaskbuilder/dataset_schema.go | 3 +- cmd/aitaskbuilder/dataset_schema_test.go | 10 +- cmd/aitaskbuilder/get_batch_test.go | 7 +- contract_test/contract_test.go | 1 + docs/examples/dataset-schema.json | 1 + mock_client/mock_client.go | 15 ++ .../manual-tests/test_audio_batch_preview.py | 131 +++++++++++++ 15 files changed, 481 insertions(+), 11 deletions(-) create mode 100644 cmd/aitaskbuilder/batch_preview.go create mode 100644 cmd/aitaskbuilder/batch_preview_test.go create mode 100755 scripts/manual-tests/test_audio_batch_preview.py diff --git a/client/client.go b/client/client.go index a63c8134..b972b63a 100644 --- a/client/client.go +++ b/client/client.go @@ -123,6 +123,7 @@ type API interface { GetAITaskBuilderBatches(workspaceID string) (*GetAITaskBuilderBatchesResponse, error) GetAITaskBuilderResponses(batchID string) (*GetAITaskBuilderResponsesResponse, error) GetAITaskBuilderTasks(batchID string) (*GetAITaskBuilderTasksResponse, error) + GetAITaskBuilderTaskGroups(batchID string) (*GetAITaskBuilderTaskGroupsResponse, error) InitiateBatchExport(batchID string) (*BatchExportResponse, error) GetBatchExportStatus(batchID, exportID string) (*BatchExportResponse, error) SyncAITaskBuilderBatch(batchID string) (*AITaskBuilderBatchSyncResponse, error) @@ -1384,6 +1385,18 @@ func (c *Client) GetAITaskBuilderTasks(batchID string) (*GetAITaskBuilderTasksRe return &response, nil } +// GetAITaskBuilderTaskGroups will return the task group IDs for an AI Task Builder batch. +func (c *Client) GetAITaskBuilderTaskGroups(batchID string) (*GetAITaskBuilderTaskGroupsResponse, error) { + var response GetAITaskBuilderTaskGroupsResponse + + url := fmt.Sprintf("/api/v1/data-collection/batches/%s/task-groups", batchID) + _, 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 +} + // 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. diff --git a/client/responses.go b/client/responses.go index cd8126fc..ab34f37b 100644 --- a/client/responses.go +++ b/client/responses.go @@ -262,6 +262,10 @@ type GetAITaskBuilderResponsesResponse struct { // The API returns a simple array of task ID strings. type GetAITaskBuilderTasksResponse []string +// GetAITaskBuilderTaskGroupsResponse is the response for the get AI Task Builder task groups endpoint. +// The API returns a simple array of task group ID strings. +type GetAITaskBuilderTaskGroupsResponse []string + // GetAITaskBuilderDatasetStatusResponse is the response for the get AI Task Builder dataset status endpoint. type GetAITaskBuilderDatasetStatusResponse struct { Status model.DatasetStatus `json:"status"` diff --git a/cmd/aitaskbuilder/batch_preview.go b/cmd/aitaskbuilder/batch_preview.go new file mode 100644 index 00000000..24e68964 --- /dev/null +++ b/cmd/aitaskbuilder/batch_preview.go @@ -0,0 +1,109 @@ +package aitaskbuilder + +import ( + "errors" + "fmt" + "io" + + "github.com/pkg/browser" + "github.com/prolific-oss/cli/client" + "github.com/spf13/cobra" +) + +// BrowserOpener is a function type for opening URLs in a browser. +// This allows for dependency injection in tests. +type BrowserOpener func(url string) error + +// DefaultBrowserOpener uses the system browser to open URLs. +var DefaultBrowserOpener BrowserOpener = browser.OpenURL + +// BatchPreviewOptions is the options for the batch preview command. +type BatchPreviewOptions struct { + Args []string + BatchID string + BrowserOpener BrowserOpener +} + +// NewBatchPreviewCommand creates a new `batch preview` command to open a batch +// preview in the browser. +func NewBatchPreviewCommand(c client.API, w io.Writer) *cobra.Command { + return NewBatchPreviewCommandWithOpener(c, w, DefaultBrowserOpener) +} + +// NewBatchPreviewCommandWithOpener creates a new `batch preview` command with a +// custom browser opener. This is useful for testing to avoid opening actual +// browser windows. +func NewBatchPreviewCommandWithOpener(c client.API, w io.Writer, browserOpener BrowserOpener) *cobra.Command { + var opts BatchPreviewOptions + opts.BrowserOpener = browserOpener + + cmd := &cobra.Command{ + Use: "preview", + Short: "Preview a batch in the browser", + Long: `Preview a batch in the browser + +Opens the batch's first task group in your default web browser so you can +preview it before launch.`, + Example: ` +Preview an AI Task Builder batch in the browser: +$ prolific aitaskbuilder batch preview -b + `, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Args = args + + err := renderAITaskBuilderBatchPreview(c, opts, w) + if err != nil { + return fmt.Errorf("error: %s", err.Error()) + } + + return nil + }, + } + + flags := cmd.Flags() + flags.StringVarP(&opts.BatchID, "batch-id", "b", "", "Batch ID (required) - The ID of the batch to preview.") + + _ = cmd.MarkFlagRequired("batch-id") + + return cmd +} + +// renderAITaskBuilderBatchPreview validates access to the batch, resolves a task +// group, builds the preview URL, prints it, and attempts to open it in a browser. +func renderAITaskBuilderBatchPreview(c client.API, opts BatchPreviewOptions, w io.Writer) error { + if opts.BatchID == "" { + return errors.New(ErrBatchIDRequired) + } + + // Fetch batch to validate access + _, err := c.GetAITaskBuilderBatch(opts.BatchID) + if err != nil { + return fmt.Errorf("failed to get batch: %s", err.Error()) + } + + taskGroups, err := c.GetAITaskBuilderTaskGroups(opts.BatchID) + if err != nil { + return fmt.Errorf("failed to get task groups: %s", err.Error()) + } + if len(*taskGroups) == 0 { + return fmt.Errorf("%s %s", ErrNoTaskGroupsFound, opts.BatchID) + } + + taskGroupID := (*taskGroups)[0] + + // Build the preview URL and display it + previewURL := GetBatchPreviewURL(opts.BatchID, taskGroupID) + fmt.Fprintln(w, "Opening batch preview in browser...") + fmt.Fprintln(w) + fmt.Fprintln(w, previewURL) + + // Attempt to open the browser - don't fail if it doesn't work + // (e.g., in headless/CI environments) + if opts.BrowserOpener != nil { + if err := opts.BrowserOpener(previewURL); err != nil { + fmt.Fprintln(w, "(Browser did not open automatically - use the URL above)") + } + } + + return nil +} diff --git a/cmd/aitaskbuilder/batch_preview_test.go b/cmd/aitaskbuilder/batch_preview_test.go new file mode 100644 index 00000000..9d49999f --- /dev/null +++ b/cmd/aitaskbuilder/batch_preview_test.go @@ -0,0 +1,173 @@ +package aitaskbuilder_test + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "os" + "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" + "github.com/prolific-oss/cli/model" +) + +// noOpBrowserOpener is a no-op browser opener for testing. +func noOpBrowserOpener(url string) error { + return nil +} + +func TestNewBatchPreviewCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) + + use := "preview" + short := "Preview a batch in the browser" + + if cmd.Use != use { + t.Fatalf("expected use: %s; got %s", use, cmd.Use) + } + + if cmd.Short != short { + t.Fatalf("expected short: %s; got %s", short, cmd.Short) + } +} + +func TestBatchPreviewRequiresBatchID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) + err := cmd.RunE(cmd, nil) + + if err == nil { + t.Fatal("expected error when batch-id is missing") + } + + expected := fmt.Sprintf("error: %s", aitaskbuilder.ErrBatchIDRequired) + if err.Error() != expected { + t.Fatalf("expected error %q, got %q", expected, err.Error()) + } +} + +func TestBatchPreviewCallsGetBatchAndTaskGroups(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := testBatchUUID + taskGroupID := "task-group-1" + + response := client.GetAITaskBuilderBatchResponse{ + AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID}, + } + taskGroups := client.GetAITaskBuilderTaskGroupsResponse{taskGroupID} + + c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(&response, nil).Times(1) + c.EXPECT().GetAITaskBuilderTaskGroups(gomock.Eq(batchID)).Return(&taskGroups, nil).Times(1) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + + cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, writer, noOpBrowserOpener) + _ = cmd.Flags().Set("batch-id", batchID) + err := cmd.RunE(cmd, nil) + writer.Flush() + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestBatchPreviewReturnsErrorOnBatchClientError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := "an-invalid-batch-id" + + c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(nil, errors.New("batch not found")).Times(1) + + cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) + _ = cmd.Flags().Set("batch-id", batchID) + err := cmd.RunE(cmd, nil) + + expected := "error: failed to get batch: batch not found" + if err == nil || err.Error() != expected { + t.Fatalf("expected error %q, got %v", expected, err) + } +} + +func TestBatchPreviewReturnsErrorWhenNoTaskGroups(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := testBatchUUID + + response := client.GetAITaskBuilderBatchResponse{ + AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID}, + } + taskGroups := client.GetAITaskBuilderTaskGroupsResponse{} + + c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(&response, nil).Times(1) + c.EXPECT().GetAITaskBuilderTaskGroups(gomock.Eq(batchID)).Return(&taskGroups, nil).Times(1) + + cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) + _ = cmd.Flags().Set("batch-id", batchID) + err := cmd.RunE(cmd, nil) + + expected := fmt.Sprintf("error: %s %s", aitaskbuilder.ErrNoTaskGroupsFound, batchID) + if err == nil || err.Error() != expected { + t.Fatalf("expected error %q, got %v", expected, err) + } +} + +func TestBatchPreviewOutputContainsURL(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := testBatchUUID + taskGroupID := "task-group-1" + + response := client.GetAITaskBuilderBatchResponse{ + AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID}, + } + taskGroups := client.GetAITaskBuilderTaskGroupsResponse{taskGroupID} + + c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(&response, nil).Times(1) + c.EXPECT().GetAITaskBuilderTaskGroups(gomock.Eq(batchID)).Return(&taskGroups, nil).Times(1) + + var b bytes.Buffer + writer := bufio.NewWriter(&b) + + cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, writer, noOpBrowserOpener) + _ = cmd.Flags().Set("batch-id", batchID) + err := cmd.RunE(cmd, nil) + writer.Flush() + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := b.String() + expectedStrings := []string{ + "Opening batch preview in browser", + "data-collection-tool/batches/" + batchID + "/task-groups/" + taskGroupID, + "preview=true", + } + + for _, expected := range expectedStrings { + if !bytes.Contains([]byte(output), []byte(expected)) { + t.Errorf("expected output to contain %q, got: %s", expected, output) + } + } +} diff --git a/cmd/aitaskbuilder/batch_setup_test.go b/cmd/aitaskbuilder/batch_setup_test.go index 09eb4cbb..6f98802b 100644 --- a/cmd/aitaskbuilder/batch_setup_test.go +++ b/cmd/aitaskbuilder/batch_setup_test.go @@ -155,7 +155,7 @@ func TestNewBatchSetupCommandMissingRequiredFlags(t *testing.T) { }, { name: "missing dataset-id flag", - args: []string{"--batch-id", "01954894-65b3-779e-aaf6-348698e23634", "--tasks-per-group", "3"}, + args: []string{"--batch-id", testBatchUUID, "--tasks-per-group", "3"}, expectedErr: `required flag(s) "dataset-id" not set`, }, { diff --git a/cmd/aitaskbuilder/batches.go b/cmd/aitaskbuilder/batches.go index 8593cff7..674f4317 100644 --- a/cmd/aitaskbuilder/batches.go +++ b/cmd/aitaskbuilder/batches.go @@ -1,9 +1,11 @@ package aitaskbuilder import ( + "fmt" "io" "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/config" "github.com/spf13/cobra" ) @@ -27,7 +29,18 @@ func NewBatchesCommand(client client.API, w io.Writer) *cobra.Command { NewGetBatchesListCommand(client, w), NewGetResponsesCommand(client, w), NewBatchTasksCommand(client, w), + NewBatchPreviewCommand(client, w), ) return cmd } + +// GetBatchPreviewPath returns the URL path to a batch preview, agnostic of domain. +func GetBatchPreviewPath(batchID, taskGroupID string) string { + return fmt.Sprintf("data-collection-tool/batches/%s/task-groups/%s?preview=true", batchID, taskGroupID) +} + +// GetBatchPreviewURL returns the full URL to a batch preview using configuration. +func GetBatchPreviewURL(batchID, taskGroupID string) string { + return fmt.Sprintf("%s/%s", config.GetApplicationURL(), GetBatchPreviewPath(batchID, taskGroupID)) +} diff --git a/cmd/aitaskbuilder/constants.go b/cmd/aitaskbuilder/constants.go index 5d3204a4..a3385fa8 100644 --- a/cmd/aitaskbuilder/constants.go +++ b/cmd/aitaskbuilder/constants.go @@ -17,6 +17,7 @@ const ( ErrFilePathRequired = "file path is required" ErrInstructionInputRequired = "either instructions file (-f) or JSON string (-j) must be provided" ErrNameRequired = "name is required" + ErrNoTaskGroupsFound = "no task groups found for batch" ErrSchemaInvalidJSON = "schema contains invalid JSON" ErrSchemaStrictSetInBoth = "cannot set strict in both --schema and --strict" ErrStrictRequiresSchema = "--strict requires --schema" diff --git a/cmd/aitaskbuilder/create_dataset.go b/cmd/aitaskbuilder/create_dataset.go index fdc9b5e7..cc6f65ed 100644 --- a/cmd/aitaskbuilder/create_dataset.go +++ b/cmd/aitaskbuilder/create_dataset.go @@ -44,15 +44,16 @@ passes it through unchanged. The value is the full schema object, for example: "fields": { "question": { "type": "text", "label": "Question" }, "image": { "type": "image_url" }, + "audio": { "type": "audio_url" }, "source": { "type": "metadata" }, "group": { "type": "task_group_id" } } } -Field types are text, image_url, metadata, and task_group_id (at most one). By -default schemas are created with "strict": false. Use --strict to enable strict -mode when the schema JSON does not already set "strict" (passing --strict -alongside a schema that sets "strict" is an error). See +Field types are text, image_url, audio_url, metadata, and task_group_id (at +most one). By default schemas are created with "strict": false. Use --strict +to enable strict mode when the schema JSON does not already set "strict" +(passing --strict alongside a schema that sets "strict" is an error). See docs/examples/dataset-schema.json for a full example. Schemas are only accepted for workspaces where the typed-dataset feature is diff --git a/cmd/aitaskbuilder/dataset_schema.go b/cmd/aitaskbuilder/dataset_schema.go index ef388d48..115a72ab 100644 --- a/cmd/aitaskbuilder/dataset_schema.go +++ b/cmd/aitaskbuilder/dataset_schema.go @@ -18,6 +18,7 @@ var validDatasetSchemaFieldTypes = map[string]bool{ "image_url": true, "metadata": true, "task_group_id": true, + "audio_url": true, } // rawDatasetSchema mirrors DatasetSchema but distinguishes an absent "strict" @@ -54,7 +55,7 @@ func resolveDatasetSchema(schemaInput string, strict, strictSet bool) (*client.D taskGroupIDCount := 0 for key, field := range parsed.Fields { if !validDatasetSchemaFieldTypes[field.Type] { - return nil, fmt.Errorf("field %q has invalid type %q; must be one of text, image_url, metadata, task_group_id", key, field.Type) + return nil, fmt.Errorf("field %q has invalid type %q; must be one of text, image_url, metadata, task_group_id, audio_url", key, field.Type) } if field.Type == "task_group_id" { taskGroupIDCount++ diff --git a/cmd/aitaskbuilder/dataset_schema_test.go b/cmd/aitaskbuilder/dataset_schema_test.go index 38a43caf..9934ede8 100644 --- a/cmd/aitaskbuilder/dataset_schema_test.go +++ b/cmd/aitaskbuilder/dataset_schema_test.go @@ -18,6 +18,7 @@ func TestResolveDatasetSchemaInlineValid(t *testing.T) { "fields": { "question": { "type": "text", "label": "Question" }, "image": { "type": "image_url" }, + "audio": { "type": "audio_url" }, "source": { "type": "metadata" }, "group": { "type": "task_group_id" } } @@ -33,8 +34,11 @@ func TestResolveDatasetSchemaInlineValid(t *testing.T) { if schema.Strict == nil || !*schema.Strict { t.Fatal("expected strict to be true from JSON") } - if len(schema.Fields) != 4 { - t.Fatalf("expected 4 fields; got %d", len(schema.Fields)) + if len(schema.Fields) != 5 { + t.Fatalf("expected 5 fields; got %d", len(schema.Fields)) + } + if schema.Fields["audio"].Type != "audio_url" { + t.Fatalf("unexpected audio field: %+v", schema.Fields["audio"]) } if schema.Fields["question"].Type != "text" || schema.Fields["question"].Label != "Question" { t.Fatalf("unexpected question field: %+v", schema.Fields["question"]) @@ -154,7 +158,7 @@ func TestResolveDatasetSchemaInvalidFieldType(t *testing.T) { if !strings.Contains(msg, `"q"`) || !strings.Contains(msg, `"number"`) { t.Fatalf("expected error to name field and type; got %v", err) } - if !strings.Contains(msg, "text, image_url, metadata, task_group_id") { + if !strings.Contains(msg, "text, image_url, metadata, task_group_id, audio_url") { t.Fatalf("expected error to list allowed types; got %v", err) } } diff --git a/cmd/aitaskbuilder/get_batch_test.go b/cmd/aitaskbuilder/get_batch_test.go index 8b17db4f..b8bde491 100644 --- a/cmd/aitaskbuilder/get_batch_test.go +++ b/cmd/aitaskbuilder/get_batch_test.go @@ -16,6 +16,9 @@ import ( "github.com/prolific-oss/cli/model" ) +// testBatchUUID is a shared batch ID used across multiple aitaskbuilder tests. +const testBatchUUID = "01954894-65b3-779e-aaf6-348698e23634" + func TestNewGetBatchCommand(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -40,7 +43,7 @@ func TestNewGetBatchCommandCallsAPI(t *testing.T) { defer ctrl.Finish() c := mock_client.NewMockAPI(ctrl) - batchID := "01954894-65b3-779e-aaf6-348698e23634" + batchID := testBatchUUID createdAt, _ := time.Parse(time.RFC3339, "2025-02-27T18:03:59.795Z") response := client.GetAITaskBuilderBatchResponse{ @@ -84,7 +87,7 @@ func TestNewGetBatchCommandCallsAPI(t *testing.T) { writer.Flush() expected := `AI Task Builder Batch Details: -ID: 01954894-65b3-779e-aaf6-348698e23634 +ID: ` + batchID + ` Name: Test Batch Status: UNINITIALISED Total Task Count: 0 diff --git a/contract_test/contract_test.go b/contract_test/contract_test.go index cbd09b44..91132144 100644 --- a/contract_test/contract_test.go +++ b/contract_test/contract_test.go @@ -274,6 +274,7 @@ var operations = []operation{ }) }}, {operationID: "get-project-studies", call: func(c *client.Client) { c.GetStudies("", "proj-id") }}, + {operationID: "delete-project-study", skip: "OUTOFSCOPE: no CLI command for deleting a study"}, {operationID: "get-study", call: func(c *client.Client) { c.GetStudy("study-id") }}, {operationID: "delete-study", skip: "OUTOFSCOPE: no CLI command for deleting a study"}, {operationID: "update-study", call: func(c *client.Client) { diff --git a/docs/examples/dataset-schema.json b/docs/examples/dataset-schema.json index fbac5a67..df374bd5 100644 --- a/docs/examples/dataset-schema.json +++ b/docs/examples/dataset-schema.json @@ -3,6 +3,7 @@ "fields": { "question": { "type": "text", "label": "Question" }, "image": { "type": "image_url", "label": "Reference image" }, + "audio": { "type": "audio_url", "label": "Reference audio" }, "source": { "type": "metadata" }, "group": { "type": "task_group_id" } } diff --git a/mock_client/mock_client.go b/mock_client/mock_client.go index 13a4e172..1894273f 100644 --- a/mock_client/mock_client.go +++ b/mock_client/mock_client.go @@ -540,6 +540,21 @@ func (mr *MockAPIMockRecorder) GetAITaskBuilderResponses(batchID interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAITaskBuilderResponses", reflect.TypeOf((*MockAPI)(nil).GetAITaskBuilderResponses), batchID) } +// GetAITaskBuilderTaskGroups mocks base method. +func (m *MockAPI) GetAITaskBuilderTaskGroups(batchID string) (*client.GetAITaskBuilderTaskGroupsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAITaskBuilderTaskGroups", batchID) + ret0, _ := ret[0].(*client.GetAITaskBuilderTaskGroupsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAITaskBuilderTaskGroups indicates an expected call of GetAITaskBuilderTaskGroups. +func (mr *MockAPIMockRecorder) GetAITaskBuilderTaskGroups(batchID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAITaskBuilderTaskGroups", reflect.TypeOf((*MockAPI)(nil).GetAITaskBuilderTaskGroups), batchID) +} + // GetAITaskBuilderTasks mocks base method. func (m *MockAPI) GetAITaskBuilderTasks(batchID string) (*client.GetAITaskBuilderTasksResponse, error) { m.ctrl.T.Helper() diff --git a/scripts/manual-tests/test_audio_batch_preview.py b/scripts/manual-tests/test_audio_batch_preview.py new file mode 100755 index 00000000..f50e1979 --- /dev/null +++ b/scripts/manual-tests/test_audio_batch_preview.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +End-to-end manual test for DCT-51: audio_url dataset schema field + +`aitaskbuilder batch preview` command. + +Builds the CLI, creates a dataset with an audio_url field, uploads sample +data, creates a batch, adds instructions, sets up the batch, then previews it. + +Usage: + python3 scripts/manual-tests/test_audio_batch_preview.py [workspace_id] + +Requires PROLIFIC_TOKEN / PROLIFIC_URL set in the environment for the target +API. Run from anywhere; paths are resolved relative to the repo root. +""" + +import os +import re +import subprocess +import sys +import tempfile + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +CLI_BINARY = os.path.join(tempfile.gettempdir(), "prolific-cli") +DEFAULT_WORKSPACE_ID = "679271425fe00981084a5f58" # DCT Workspace + + +def run(args, check=True): + print(f"\n$ {' '.join(args)}") + result = subprocess.run(args, capture_output=True, text=True) + print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + if check and result.returncode != 0: + print(f"Command failed with exit code {result.returncode}", file=sys.stderr) + sys.exit(result.returncode) + return result.stdout + + +def extract_field(output, label): + match = re.search(rf"^{re.escape(label)}:\s*(\S+)", output, re.MULTILINE) + if not match: + print(f"Could not find '{label}:' in output:\n{output}", file=sys.stderr) + sys.exit(1) + return match.group(1) + + +def build_cli(): + print("Building CLI...") + run(["go", "build", "-o", CLI_BINARY, "."], check=True) + + +def main(): + os.chdir(REPO_ROOT) + workspace_id = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_WORKSPACE_ID + + build_cli() + + # 1. Create dataset with an audio_url field + schema = ( + '{"fields":{"question":{"type":"text","label":"Question"},' + '"clip":{"type":"audio_url","label":"Audio clip"}}}' + ) + output = run( + [ + CLI_BINARY, "aitaskbuilder", "dataset", "create", + "-n", "Audio URL Test Dataset", + "-w", workspace_id, + "--strict", + "--schema", schema, + ] + ) + dataset_id = extract_field(output, "ID") + print(f"Created dataset: {dataset_id}") + + # 2. Upload sample data containing audio URLs + csv_path = os.path.join(tempfile.gettempdir(), "audio-dataset.csv") + with open(csv_path, "w") as f: + f.write( + "question,clip\n" + '"What emotion is being expressed?","https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"\n' + '"Transcribe the spoken word.","https://www.w3schools.com/html/horse.mp3"\n' + ) + + dataset_id = "019f6123-1fdc-7518-a3ec-75b830c5a5f4" + run([CLI_BINARY, "aitaskbuilder", "dataset", "upload", "-d", dataset_id, "-f", csv_path]) + + # 3. Check dataset status (informational; may need polling until READY) + run([CLI_BINARY, "aitaskbuilder", "dataset", "check", "-d", dataset_id], check=False) + + # 4. Create batch linked to the dataset + output = run( + [ + CLI_BINARY, "aitaskbuilder", "batch", "create", + "-n", "Audio URL Test Batch", + "-w", workspace_id, + "-d", dataset_id, + "--task-name", "Audio Review Task", + "--task-introduction", "Listen to the audio clip and answer the question.", + "--task-steps", "1. Listen to the clip\\n2. Answer the question", + ] + ) + batch_id = extract_field(output, "ID") + print(f"Created batch: {batch_id}") + + # 5. Add instructions (required before setup) + run( + [ + CLI_BINARY, "aitaskbuilder", "batch", "instructions", + "-b", batch_id, + "-j", '[{"type":"free_text","created_by":"Cem","description":"Please describe what you heard."}]', + ] + ) + + # 6. Setup the batch + run( + [ + CLI_BINARY, "aitaskbuilder", "batch", "setup", + "-b", batch_id, + "-d", dataset_id, + "--tasks-per-group", "1", + ] + ) + + # 7. Preview the batch (the new command under test) + run([CLI_BINARY, "aitaskbuilder", "batch", "preview", "-b", batch_id]) + + print("\nDone. Review output above for correctness.") + + +if __name__ == "__main__": + main() From 6f9f06cf7d1e45fe194f12ede70d11351980be68 Mon Sep 17 00:00:00 2001 From: Cem Date: Tue, 14 Jul 2026 18:03:49 +0100 Subject: [PATCH 2/4] feat(DCT-51): audio_url and batch preview command --- client/client.go | 13 ++ client/responses.go | 16 ++ cmd/aitaskbuilder/batch_preview.go | 26 ++- cmd/aitaskbuilder/batch_preview_test.go | 64 ++++-- cmd/aitaskbuilder/get_dataset_status_test.go | 7 +- cmd/aitaskbuilder/upload_dataset.go | 180 ++++++++++++++++ cmd/aitaskbuilder/upload_dataset_test.go | 55 +++++ contract_test/contract_test.go | 2 +- mock_client/mock_client.go | 15 ++ .../manual-tests/test_audio_batch_preview.go | 192 ++++++++++++++++++ .../manual-tests/test_audio_batch_preview.py | 131 ------------ 11 files changed, 547 insertions(+), 154 deletions(-) create mode 100644 scripts/manual-tests/test_audio_batch_preview.go delete mode 100755 scripts/manual-tests/test_audio_batch_preview.py diff --git a/client/client.go b/client/client.go index b972b63a..5e2437ef 100644 --- a/client/client.go +++ b/client/client.go @@ -118,6 +118,7 @@ type API interface { CreateAITaskBuilderDataset(workspaceID string, payload CreateAITaskBuilderDatasetPayload) (*CreateAITaskBuilderDatasetResponse, error) CreateAITaskBuilderCollection(payload model.CreateAITaskBuilderCollection) (*CreateAITaskBuilderCollectionResponse, error) GetAITaskBuilderBatch(batchID string) (*GetAITaskBuilderBatchResponse, error) + GetAITaskBuilderDataset(datasetID string) (*GetAITaskBuilderDatasetResponse, error) UpdateAITaskBuilderBatch(params UpdateBatchParams) (*UpdateAITaskBuilderBatchResponse, error) GetAITaskBuilderBatchStatus(batchID string) (*GetAITaskBuilderBatchStatusResponse, error) GetAITaskBuilderBatches(workspaceID string) (*GetAITaskBuilderBatchesResponse, error) @@ -1476,6 +1477,18 @@ func (c *Client) GetAITaskBuilderBatchSyncStatus(batchID, syncID string) (*AITas return &response, nil } +// GetAITaskBuilderDataset will return an AI Task Builder dataset by ID. +func (c *Client) GetAITaskBuilderDataset(datasetID string) (*GetAITaskBuilderDatasetResponse, error) { + var response GetAITaskBuilderDatasetResponse + + url := fmt.Sprintf("/api/v1/data-collection/datasets/%s", datasetID) + _, 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 +} + // GetAITaskBuilderDatasetStatus will return the status of an AI Task Builder dataset. func (c *Client) GetAITaskBuilderDatasetStatus(datasetID string) (*GetAITaskBuilderDatasetStatusResponse, error) { var response GetAITaskBuilderDatasetStatusResponse diff --git a/client/responses.go b/client/responses.go index ab34f37b..d22b69da 100644 --- a/client/responses.go +++ b/client/responses.go @@ -266,6 +266,22 @@ type GetAITaskBuilderTasksResponse []string // The API returns a simple array of task group ID strings. type GetAITaskBuilderTaskGroupsResponse []string +// GetAITaskBuilderDatasetResponse is the response for the get AI Task Builder dataset endpoint. +type GetAITaskBuilderDatasetResponse struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + CreatedBy string `json:"created_by"` + WorkspaceID string `json:"workspace_id"` + TotalDatapointCount int `json:"total_datapoint_count"` + SchemaVersion int `json:"schema_version"` + Status model.DatasetStatus `json:"status,omitempty"` + Filename *string `json:"filename,omitempty"` + HasPredeterminedGroupingID *bool `json:"has_predetermined_grouping_id,omitempty"` + Schema *DatasetSchema `json:"schema"` + Imports []model.DatasetImportJob `json:"imports"` +} + // GetAITaskBuilderDatasetStatusResponse is the response for the get AI Task Builder dataset status endpoint. type GetAITaskBuilderDatasetStatusResponse struct { Status model.DatasetStatus `json:"status"` diff --git a/cmd/aitaskbuilder/batch_preview.go b/cmd/aitaskbuilder/batch_preview.go index 24e68964..4d4ec3da 100644 --- a/cmd/aitaskbuilder/batch_preview.go +++ b/cmd/aitaskbuilder/batch_preview.go @@ -38,7 +38,16 @@ func NewBatchPreviewCommandWithOpener(c client.API, w io.Writer, browserOpener B opts.BrowserOpener = browserOpener cmd := &cobra.Command{ - Use: "preview", + Use: "preview ", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 && opts.BatchID == "" { + return errors.New("please provide a batch ID") + } + if len(args) > 1 { + return errors.New("accepts at most 1 arg(s), received " + fmt.Sprint(len(args))) + } + return nil + }, Short: "Preview a batch in the browser", Long: `Preview a batch in the browser @@ -46,14 +55,17 @@ Opens the batch's first task group in your default web browser so you can preview it before launch.`, Example: ` Preview an AI Task Builder batch in the browser: -$ prolific aitaskbuilder batch preview -b +$ prolific aitaskbuilder batch preview `, RunE: func(cmd *cobra.Command, args []string) error { opts.Args = args + if opts.BatchID == "" && len(args) > 0 { + opts.BatchID = args[0] + } err := renderAITaskBuilderBatchPreview(c, opts, w) if err != nil { - return fmt.Errorf("error: %s", err.Error()) + return fmt.Errorf("error: %s", err) } return nil @@ -61,9 +73,7 @@ $ prolific aitaskbuilder batch preview -b } flags := cmd.Flags() - flags.StringVarP(&opts.BatchID, "batch-id", "b", "", "Batch ID (required) - The ID of the batch to preview.") - - _ = cmd.MarkFlagRequired("batch-id") + flags.StringVarP(&opts.BatchID, "batch-id", "b", "", "Batch ID to preview. Optional when provided as a positional argument.") return cmd } @@ -78,12 +88,12 @@ func renderAITaskBuilderBatchPreview(c client.API, opts BatchPreviewOptions, w i // Fetch batch to validate access _, err := c.GetAITaskBuilderBatch(opts.BatchID) if err != nil { - return fmt.Errorf("failed to get batch: %s", err.Error()) + return fmt.Errorf("failed to get batch: %s", err) } taskGroups, err := c.GetAITaskBuilderTaskGroups(opts.BatchID) if err != nil { - return fmt.Errorf("failed to get task groups: %s", err.Error()) + return fmt.Errorf("failed to get task groups: %s", err) } if len(*taskGroups) == 0 { return fmt.Errorf("%s %s", ErrNoTaskGroupsFound, opts.BatchID) diff --git a/cmd/aitaskbuilder/batch_preview_test.go b/cmd/aitaskbuilder/batch_preview_test.go index 9d49999f..e64d5fd8 100644 --- a/cmd/aitaskbuilder/batch_preview_test.go +++ b/cmd/aitaskbuilder/batch_preview_test.go @@ -20,6 +20,8 @@ func noOpBrowserOpener(url string) error { return nil } +const testTaskGroupID = "task-group-1" + func TestNewBatchPreviewCommand(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -27,7 +29,7 @@ func TestNewBatchPreviewCommand(t *testing.T) { cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) - use := "preview" + use := "preview " short := "Preview a batch in the browser" if cmd.Use != use { @@ -45,25 +47,47 @@ func TestBatchPreviewRequiresBatchID(t *testing.T) { c := mock_client.NewMockAPI(ctrl) cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) - err := cmd.RunE(cmd, nil) + err := cmd.Args(cmd, []string{}) if err == nil { t.Fatal("expected error when batch-id is missing") } - expected := fmt.Sprintf("error: %s", aitaskbuilder.ErrBatchIDRequired) + expected := "please provide a batch ID" if err.Error() != expected { t.Fatalf("expected error %q, got %q", expected, err.Error()) } } +func TestBatchPreviewAcceptsPositionalBatchID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + batchID := testBatchUUID + taskGroupID := testTaskGroupID + + response := client.GetAITaskBuilderBatchResponse{ + AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID}, + } + taskGroups := client.GetAITaskBuilderTaskGroupsResponse{taskGroupID} + + c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(&response, nil).Times(1) + c.EXPECT().GetAITaskBuilderTaskGroups(gomock.Eq(batchID)).Return(&taskGroups, nil).Times(1) + + cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) + if err := cmd.RunE(cmd, []string{batchID}); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + func TestBatchPreviewCallsGetBatchAndTaskGroups(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() c := mock_client.NewMockAPI(ctrl) batchID := testBatchUUID - taskGroupID := "task-group-1" + taskGroupID := testTaskGroupID response := client.GetAITaskBuilderBatchResponse{ AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID}, @@ -77,8 +101,7 @@ func TestBatchPreviewCallsGetBatchAndTaskGroups(t *testing.T) { writer := bufio.NewWriter(&b) cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, writer, noOpBrowserOpener) - _ = cmd.Flags().Set("batch-id", batchID) - err := cmd.RunE(cmd, nil) + err := cmd.RunE(cmd, []string{batchID}) writer.Flush() if err != nil { @@ -96,8 +119,7 @@ func TestBatchPreviewReturnsErrorOnBatchClientError(t *testing.T) { c.EXPECT().GetAITaskBuilderBatch(gomock.Eq(batchID)).Return(nil, errors.New("batch not found")).Times(1) cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) - _ = cmd.Flags().Set("batch-id", batchID) - err := cmd.RunE(cmd, nil) + err := cmd.RunE(cmd, []string{batchID}) expected := "error: failed to get batch: batch not found" if err == nil || err.Error() != expected { @@ -121,8 +143,7 @@ func TestBatchPreviewReturnsErrorWhenNoTaskGroups(t *testing.T) { c.EXPECT().GetAITaskBuilderTaskGroups(gomock.Eq(batchID)).Return(&taskGroups, nil).Times(1) cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) - _ = cmd.Flags().Set("batch-id", batchID) - err := cmd.RunE(cmd, nil) + err := cmd.RunE(cmd, []string{batchID}) expected := fmt.Sprintf("error: %s %s", aitaskbuilder.ErrNoTaskGroupsFound, batchID) if err == nil || err.Error() != expected { @@ -136,7 +157,7 @@ func TestBatchPreviewOutputContainsURL(t *testing.T) { c := mock_client.NewMockAPI(ctrl) batchID := testBatchUUID - taskGroupID := "task-group-1" + taskGroupID := testTaskGroupID response := client.GetAITaskBuilderBatchResponse{ AITaskBuilderBatch: model.AITaskBuilderBatch{ID: batchID}, @@ -150,8 +171,7 @@ func TestBatchPreviewOutputContainsURL(t *testing.T) { writer := bufio.NewWriter(&b) cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, writer, noOpBrowserOpener) - _ = cmd.Flags().Set("batch-id", batchID) - err := cmd.RunE(cmd, nil) + err := cmd.RunE(cmd, []string{batchID}) writer.Flush() if err != nil { @@ -171,3 +191,21 @@ func TestBatchPreviewOutputContainsURL(t *testing.T) { } } } + +func TestGetBatchPreviewPath(t *testing.T) { + path := aitaskbuilder.GetBatchPreviewPath("batch-123", "task-group-456") + + expected := "data-collection-tool/batches/batch-123/task-groups/task-group-456?preview=true" + if path != expected { + t.Fatalf("expected path %q, got %q", expected, path) + } +} + +func TestGetBatchPreviewURL(t *testing.T) { + url := aitaskbuilder.GetBatchPreviewURL("batch-123", "task-group-456") + + expected := "https://app.prolific.com/data-collection-tool/batches/batch-123/task-groups/task-group-456?preview=true" + if url != expected { + t.Fatalf("expected URL %q, got %q", expected, url) + } +} diff --git a/cmd/aitaskbuilder/get_dataset_status_test.go b/cmd/aitaskbuilder/get_dataset_status_test.go index 991f91ec..64c9d7b8 100644 --- a/cmd/aitaskbuilder/get_dataset_status_test.go +++ b/cmd/aitaskbuilder/get_dataset_status_test.go @@ -19,7 +19,12 @@ import ( func setupMockClient(t *testing.T) *mock_client.MockAPI { ctrl := gomock.NewController(t) t.Cleanup(func() { ctrl.Finish() }) - return mock_client.NewMockAPI(ctrl) + c := mock_client.NewMockAPI(ctrl) + c.EXPECT(). + GetAITaskBuilderDataset(gomock.Any()). + Return(&client.GetAITaskBuilderDatasetResponse{}, nil). + AnyTimes() + return c } func TestNewGetDatasetStatusCommand(t *testing.T) { diff --git a/cmd/aitaskbuilder/upload_dataset.go b/cmd/aitaskbuilder/upload_dataset.go index 2ce801ff..2477a565 100644 --- a/cmd/aitaskbuilder/upload_dataset.go +++ b/cmd/aitaskbuilder/upload_dataset.go @@ -1,11 +1,15 @@ package aitaskbuilder import ( + "bufio" "context" + "encoding/csv" + "encoding/json" "errors" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "slices" @@ -27,6 +31,15 @@ const ( // DatasetUploadPollSleep is the sleep function used between dataset import status polls. var DatasetUploadPollSleep func(time.Duration) = time.Sleep +var validAudioURLFileExtensions = map[string]bool{ + ".aac": true, + ".m4a": true, + ".mp3": true, + ".wav": true, +} + +const supportedAudioURLFileExtensions = ".aac, .m4a, .mp3, .wav" + // DatasetUploadOptions are the options for uploading to an AI Task Builder dataset. type DatasetUploadOptions struct { Args []string @@ -110,6 +123,15 @@ func uploadDatasetFile(client client.API, opts DatasetUploadOptions, w io.Writer return err } + dataset, err := client.GetAITaskBuilderDataset(opts.DatasetID) + if err != nil { + return fmt.Errorf("failed to get dataset: %w", err) + } + + if err := validateAudioURLFields(opts.FilePath, uploadRequest.Format, dataset.Schema); err != nil { + return err + } + fmt.Fprintf(w, "Getting upload URL for dataset %s and file %s...\n", opts.DatasetID, uploadRequest.UploadFilename) // Get upload URL from API @@ -257,6 +279,164 @@ func uploadFileToPresignedURL(filePath, uploadURL, method, contentType string) e return nil } +func validateAudioURLFields(filePath string, format model.DatasetImportFormat, schema *client.DatasetSchema) error { + if schema == nil { + return nil + } + + audioFields := make(map[string]struct{}) + for fieldName, field := range schema.Fields { + if field.Type == "audio_url" { + audioFields[fieldName] = struct{}{} + } + } + + if len(audioFields) == 0 { + return nil + } + + switch format { + case model.DatasetImportFormatCSV: + return validateAudioURLFieldsInCSV(filePath, audioFields) + case model.DatasetImportFormatJSONL: + return ValidateAudioURLFieldsInJSONL(filePath, audioFields) + default: + return nil + } +} + +func validateAudioURLFieldsInCSV(filePath string, audioFields map[string]struct{}) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", filePath, err) + } + defer file.Close() + + reader := csv.NewReader(file) + headers, err := reader.Read() + if err != nil { + return fmt.Errorf("failed to read CSV header from %s: %w", filePath, err) + } + + audioColumnIndexes := make(map[int]string) + for idx, header := range headers { + fieldName := strings.TrimSpace(header) + if _, ok := audioFields[fieldName]; ok { + audioColumnIndexes[idx] = fieldName + } + } + + if len(audioColumnIndexes) == 0 { + return nil + } + + recordIndex := 1 + for { + record, err := reader.Read() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("failed to read CSV record %d from %s: %w", recordIndex, filePath, err) + } + + for idx, fieldName := range audioColumnIndexes { + if idx >= len(record) { + continue + } + + if err := validateAudioURLValue(recordIndex, fieldName, record[idx]); err != nil { + return err + } + } + + recordIndex++ + } +} + +func ValidateAudioURLFieldsInJSONL(filePath string, audioFields map[string]struct{}) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file %s: %w", filePath, err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + recordIndex := 1 + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + recordIndex++ + continue + } + + record := make(map[string]any) + if err := json.Unmarshal([]byte(line), &record); err != nil { + return fmt.Errorf("failed to parse JSONL record %d from %s: %w", recordIndex, filePath, err) + } + + for fieldName := range audioFields { + value, ok := record[fieldName] + if !ok || value == nil { + continue + } + + valueString, ok := value.(string) + if !ok { + return fmt.Errorf( + "record %d field %s: audio URL must be a string ending with one of %s", + recordIndex, + fieldName, + supportedAudioURLFileExtensions, + ) + } + + if err := validateAudioURLValue(recordIndex, fieldName, valueString); err != nil { + return err + } + } + + recordIndex++ + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to read JSONL file %s: %w", filePath, err) + } + + return nil +} + +func validateAudioURLValue(recordIndex int, fieldName, value string) error { + trimmedValue := strings.TrimSpace(value) + if trimmedValue == "" { + return nil + } + + if !hasSupportedAudioURLExtension(trimmedValue) { + return fmt.Errorf( + "record %d field %s: audio URL %q must end with one of %s", + recordIndex, + fieldName, + trimmedValue, + supportedAudioURLFileExtensions, + ) + } + + return nil +} + +func hasSupportedAudioURLExtension(value string) bool { + parsedURL, err := url.ParseRequestURI(value) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return false + } + + extension := strings.ToLower(filepath.Ext(parsedURL.Path)) + return validAudioURLFileExtensions[extension] +} + func waitForDatasetImport( client client.API, datasetID, importID string, diff --git a/cmd/aitaskbuilder/upload_dataset_test.go b/cmd/aitaskbuilder/upload_dataset_test.go index 929daf50..18e4acdb 100644 --- a/cmd/aitaskbuilder/upload_dataset_test.go +++ b/cmd/aitaskbuilder/upload_dataset_test.go @@ -16,6 +16,7 @@ import ( "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" "github.com/prolific-oss/cli/model" ) @@ -248,6 +249,60 @@ func TestDatasetUploadCommandFormatOverrideAppendsExtension(t *testing.T) { } } +func TestDatasetUploadCommandRejectsUnsupportedCSVAudioExtension(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "dataset.csv") + if err := os.WriteFile(filePath, []byte("question,clip\nhello,https://example.com/audio.txt\n"), 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + c.EXPECT(). + GetAITaskBuilderDataset(gomock.Eq("dataset-audio")). + Return(&client.GetAITaskBuilderDatasetResponse{ + Schema: &client.DatasetSchema{ + Fields: map[string]client.DatasetSchemaField{ + "clip": {Type: "audio_url"}, + }, + }, + }, nil). + Times(1) + + cmd := aitaskbuilder.NewDatasetUploadCommand(c, os.Stdout) + _ = cmd.Flags().Set("dataset-id", "dataset-audio") + _ = cmd.Flags().Set("file", filePath) + + err := cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected invalid audio URL extension error") + } + + if !strings.Contains(err.Error(), `must end with one of .aac, .m4a, .mp3, .wav`) { + t.Fatalf("expected supported extensions in error, got %v", err) + } +} + +func TestValidateAudioURLFieldsInJSONLRejectsUnsupportedAudioExtension(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "dataset.jsonl") + if err := os.WriteFile(filePath, []byte("{\"clip\":\"https://example.com/audio.mov\"}\n"), 0o600); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + audioFields := map[string]struct{}{ + "clip": {}, + } + + err := aitaskbuilder.ValidateAudioURLFieldsInJSONL(filePath, audioFields) + if err == nil { + t.Fatal("expected invalid audio URL extension error") + } + + if !strings.Contains(err.Error(), "record 1 field clip") { + t.Fatalf("expected record location in error, got %v", err) + } +} + func TestDatasetUploadCommandRendersPartialImportSummary(t *testing.T) { defer setDatasetUploadPollSleepForTesting(func(time.Duration) {})() diff --git a/contract_test/contract_test.go b/contract_test/contract_test.go index 91132144..0befd8c9 100644 --- a/contract_test/contract_test.go +++ b/contract_test/contract_test.go @@ -198,7 +198,7 @@ var operations = []operation{ c.CreateAITaskBuilderDataset("ws-id", client.CreateAITaskBuilderDatasetPayload{Name: "t"}) }}, {operationID: "get-dataset-upload-url", call: func(c *client.Client) { c.GetAITaskBuilderDatasetUploadURL("ds-id", "data.jsonl") }}, - {operationID: "get-task-builder-dataset", skip: "OUTOFSCOPE: no CLI command for retrieving a specific dataset by ID"}, + {operationID: "get-task-builder-dataset", call: func(c *client.Client) { c.GetAITaskBuilderDataset("ds-id") }}, {operationID: "get-task-builder-dataset-status", call: func(c *client.Client) { c.GetAITaskBuilderDatasetStatus("ds-id") }}, {operationID: "get-dataset-import-status", call: func(c *client.Client) { c.GetAITaskBuilderDatasetImportStatus("ds-id", "import-id") diff --git a/mock_client/mock_client.go b/mock_client/mock_client.go index 1894273f..6fcce556 100644 --- a/mock_client/mock_client.go +++ b/mock_client/mock_client.go @@ -480,6 +480,21 @@ func (mr *MockAPIMockRecorder) GetAITaskBuilderBatches(workspaceID interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAITaskBuilderBatches", reflect.TypeOf((*MockAPI)(nil).GetAITaskBuilderBatches), workspaceID) } +// GetAITaskBuilderDataset mocks base method. +func (m *MockAPI) GetAITaskBuilderDataset(datasetID string) (*client.GetAITaskBuilderDatasetResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAITaskBuilderDataset", datasetID) + ret0, _ := ret[0].(*client.GetAITaskBuilderDatasetResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAITaskBuilderDataset indicates an expected call of GetAITaskBuilderDataset. +func (mr *MockAPIMockRecorder) GetAITaskBuilderDataset(datasetID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAITaskBuilderDataset", reflect.TypeOf((*MockAPI)(nil).GetAITaskBuilderDataset), datasetID) +} + // GetAITaskBuilderDatasetImportStatus mocks base method. func (m *MockAPI) GetAITaskBuilderDatasetImportStatus(datasetID, importID string) (*client.GetAITaskBuilderDatasetImportStatusResponse, error) { m.ctrl.T.Helper() diff --git a/scripts/manual-tests/test_audio_batch_preview.go b/scripts/manual-tests/test_audio_batch_preview.go new file mode 100644 index 00000000..88a423f4 --- /dev/null +++ b/scripts/manual-tests/test_audio_batch_preview.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +const defaultWorkspaceID = "679271425fe00981084a5f58" // DCT Workspace + +var fieldPattern = regexp.MustCompile(`(?m)^([A-Za-z ]+):\s*(\S+)$`) + +func main() { + repoRoot, err := repoRoot() + if err != nil { + fatal(err) + } + + workspaceID := defaultWorkspaceID + if len(os.Args) > 1 { + workspaceID = os.Args[1] + } + + cliBinary := filepath.Join(os.TempDir(), "prolific-cli") + if err := buildCLI(repoRoot, cliBinary); err != nil { + fatal(err) + } + + schema := `{"fields":{"question":{"type":"text","label":"Question"},"clip":{"type":"audio_url","label":"Audio clip"}}}` + batchItems := `[{"rows":[{"columns":[{"items":[{"type":"dataset_field","field":"question"},{"type":"dataset_field","field":"clip"},{"type":"free_text","description":"Please describe what you heard."}]}]}]}]` + + // Create a V4 dataset whose schema includes an audio_url field. + output, err := run(repoRoot, cliBinary, + "aitaskbuilder", "dataset", "create", + "-n", "Audio URL Test Dataset", + "-w", workspaceID, + "--strict", + "--schema", schema, + ) + if err != nil { + fatal(err) + } + + datasetID, err := extractField(output, "ID") + if err != nil { + fatal(err) + } + fmt.Printf("Created dataset: %s\n", datasetID) + + csvPath := filepath.Join(os.TempDir(), "audio-dataset.csv") + csvContents := strings.Join([]string{ + "question,clip", + `"What emotion is being expressed?","https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"`, + `"Transcribe the spoken word.","https://www.w3schools.com/html/horse.mp3"`, + "", + }, "\n") + if err := os.WriteFile(csvPath, []byte(csvContents), 0o600); err != nil { + fatal(fmt.Errorf("failed to write CSV fixture: %w", err)) + } + + invalidCSVPath := filepath.Join(os.TempDir(), "audio-dataset-invalid.csv") + invalidCSVContents := strings.Join([]string{ + "question,clip", + `"This row should fail validation.","https://example.com/not-audio.txt"`, + "", + }, "\n") + if err := os.WriteFile(invalidCSVPath, []byte(invalidCSVContents), 0o600); err != nil { + fatal(fmt.Errorf("failed to write invalid CSV fixture: %w", err)) + } + + // Attempt an invalid upload first to confirm audio_url extension validation rejects non-audio URLs. + if _, err := runAllowFailure(repoRoot, cliBinary, "aitaskbuilder", "dataset", "upload", "-d", datasetID, "-f", invalidCSVPath); err == nil { + fatal(fmt.Errorf("expected invalid audio URL upload to fail")) + } else { + fmt.Println("Confirmed invalid audio URL upload was rejected.") + } + + // Upload valid sample data containing supported audio URL extensions. + if _, err := run(repoRoot, cliBinary, "aitaskbuilder", "dataset", "upload", "-d", datasetID, "-f", csvPath); err != nil { + fatal(err) + } + + // Check dataset status for extra visibility while running the manual flow. + _, _ = runAllowFailure(repoRoot, cliBinary, "aitaskbuilder", "dataset", "check", "-d", datasetID) + + // Create a batch linked to the dataset and define the participant layout via batch_items. + // batch_items replaces the deprecated standalone instructions endpoint, and this + // manual flow intentionally references the audio_url dataset field even though + // the checked-in OpenAPI file has not caught up with the backend yet. + output, err = run(repoRoot, cliBinary, + "aitaskbuilder", "batch", "create", + "-n", "Audio URL Test Batch", + "-w", workspaceID, + "-d", datasetID, + "--task-name", "Audio Review Task", + "--task-introduction", "Listen to the audio clip and answer the question.", + "--task-steps", "1. Listen to the clip\\n2. Answer the question", + "--batch-items-json", batchItems, + ) + if err != nil { + fatal(err) + } + + batchID, err := extractField(output, "ID") + if err != nil { + fatal(err) + } + fmt.Printf("Created batch: %s\n", batchID) + + // Set up the batch so the persisted preview route has a task group to open. + if _, err := run(repoRoot, cliBinary, + "aitaskbuilder", "batch", "setup", + "-b", batchID, + "-d", datasetID, + "--tasks-per-group", "1", + ); err != nil { + fatal(err) + } + + // Preview the batch through the researcher preview URL flow. + if _, err := run(repoRoot, cliBinary, "aitaskbuilder", "batch", "preview", batchID); err != nil { + fatal(err) + } + + fmt.Println("\nDone. Review output above for correctness.") +} + +func repoRoot() (string, error) { + _, filePath, _, ok := runtime.Caller(0) + if !ok { + return "", fmt.Errorf("failed to resolve script path") + } + + return filepath.Abs(filepath.Join(filepath.Dir(filePath), "..", "..")) +} + +func buildCLI(repoRoot, cliBinary string) error { + fmt.Println("Building CLI...") + cmd := exec.CommandContext(context.Background(), "go", "build", "-o", cliBinary, ".") + _, err := runWithCheck(true, repoRoot, "go", cmd) + return err +} + +func run(repoRoot, cliBinary string, args ...string) (string, error) { + // #nosec G702 -- cliBinary is the local binary built by this script, not shell-expanded user input. + cmd := exec.CommandContext(context.Background(), cliBinary, args...) + return runWithCheck(true, repoRoot, cliBinary, cmd) +} + +func runAllowFailure(repoRoot, cliBinary string, args ...string) (string, error) { + // #nosec G702 -- cliBinary is the local binary built by this script, not shell-expanded user input. + cmd := exec.CommandContext(context.Background(), cliBinary, args...) + return runWithCheck(false, repoRoot, cliBinary, cmd) +} + +func runWithCheck(check bool, repoRoot, command string, cmd *exec.Cmd) (string, error) { + fmt.Printf("\n$ %s %s\n", command, strings.Join(cmd.Args[1:], " ")) + + cmd.Dir = repoRoot + cmd.Env = os.Environ() + + output, err := cmd.CombinedOutput() + text := string(output) + fmt.Print(text) + + if err != nil && check { + return text, fmt.Errorf("command failed: %w", err) + } + + return text, err +} + +func extractField(output, label string) (string, error) { + matches := fieldPattern.FindAllStringSubmatch(output, -1) + for _, match := range matches { + if len(match) >= 3 && match[1] == label { + return match[2], nil + } + } + + return "", fmt.Errorf("could not find %q in output", label) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} diff --git a/scripts/manual-tests/test_audio_batch_preview.py b/scripts/manual-tests/test_audio_batch_preview.py deleted file mode 100755 index f50e1979..00000000 --- a/scripts/manual-tests/test_audio_batch_preview.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -""" -End-to-end manual test for DCT-51: audio_url dataset schema field + -`aitaskbuilder batch preview` command. - -Builds the CLI, creates a dataset with an audio_url field, uploads sample -data, creates a batch, adds instructions, sets up the batch, then previews it. - -Usage: - python3 scripts/manual-tests/test_audio_batch_preview.py [workspace_id] - -Requires PROLIFIC_TOKEN / PROLIFIC_URL set in the environment for the target -API. Run from anywhere; paths are resolved relative to the repo root. -""" - -import os -import re -import subprocess -import sys -import tempfile - -REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -CLI_BINARY = os.path.join(tempfile.gettempdir(), "prolific-cli") -DEFAULT_WORKSPACE_ID = "679271425fe00981084a5f58" # DCT Workspace - - -def run(args, check=True): - print(f"\n$ {' '.join(args)}") - result = subprocess.run(args, capture_output=True, text=True) - print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) - if check and result.returncode != 0: - print(f"Command failed with exit code {result.returncode}", file=sys.stderr) - sys.exit(result.returncode) - return result.stdout - - -def extract_field(output, label): - match = re.search(rf"^{re.escape(label)}:\s*(\S+)", output, re.MULTILINE) - if not match: - print(f"Could not find '{label}:' in output:\n{output}", file=sys.stderr) - sys.exit(1) - return match.group(1) - - -def build_cli(): - print("Building CLI...") - run(["go", "build", "-o", CLI_BINARY, "."], check=True) - - -def main(): - os.chdir(REPO_ROOT) - workspace_id = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_WORKSPACE_ID - - build_cli() - - # 1. Create dataset with an audio_url field - schema = ( - '{"fields":{"question":{"type":"text","label":"Question"},' - '"clip":{"type":"audio_url","label":"Audio clip"}}}' - ) - output = run( - [ - CLI_BINARY, "aitaskbuilder", "dataset", "create", - "-n", "Audio URL Test Dataset", - "-w", workspace_id, - "--strict", - "--schema", schema, - ] - ) - dataset_id = extract_field(output, "ID") - print(f"Created dataset: {dataset_id}") - - # 2. Upload sample data containing audio URLs - csv_path = os.path.join(tempfile.gettempdir(), "audio-dataset.csv") - with open(csv_path, "w") as f: - f.write( - "question,clip\n" - '"What emotion is being expressed?","https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"\n' - '"Transcribe the spoken word.","https://www.w3schools.com/html/horse.mp3"\n' - ) - - dataset_id = "019f6123-1fdc-7518-a3ec-75b830c5a5f4" - run([CLI_BINARY, "aitaskbuilder", "dataset", "upload", "-d", dataset_id, "-f", csv_path]) - - # 3. Check dataset status (informational; may need polling until READY) - run([CLI_BINARY, "aitaskbuilder", "dataset", "check", "-d", dataset_id], check=False) - - # 4. Create batch linked to the dataset - output = run( - [ - CLI_BINARY, "aitaskbuilder", "batch", "create", - "-n", "Audio URL Test Batch", - "-w", workspace_id, - "-d", dataset_id, - "--task-name", "Audio Review Task", - "--task-introduction", "Listen to the audio clip and answer the question.", - "--task-steps", "1. Listen to the clip\\n2. Answer the question", - ] - ) - batch_id = extract_field(output, "ID") - print(f"Created batch: {batch_id}") - - # 5. Add instructions (required before setup) - run( - [ - CLI_BINARY, "aitaskbuilder", "batch", "instructions", - "-b", batch_id, - "-j", '[{"type":"free_text","created_by":"Cem","description":"Please describe what you heard."}]', - ] - ) - - # 6. Setup the batch - run( - [ - CLI_BINARY, "aitaskbuilder", "batch", "setup", - "-b", batch_id, - "-d", dataset_id, - "--tasks-per-group", "1", - ] - ) - - # 7. Preview the batch (the new command under test) - run([CLI_BINARY, "aitaskbuilder", "batch", "preview", "-b", batch_id]) - - print("\nDone. Review output above for correctness.") - - -if __name__ == "__main__": - main() From b06f6044898f8ddacfd5fd28e588184f7740192b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:03:48 +0000 Subject: [PATCH 3/4] fix(aitaskbuilder): reject conflicting batch preview ID inputs --- cmd/aitaskbuilder/batch_preview.go | 7 ++++--- cmd/aitaskbuilder/batch_preview_test.go | 21 +++++++++++++++++++++ cmd/aitaskbuilder/constants.go | 1 + 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/cmd/aitaskbuilder/batch_preview.go b/cmd/aitaskbuilder/batch_preview.go index 4d4ec3da..6bc97785 100644 --- a/cmd/aitaskbuilder/batch_preview.go +++ b/cmd/aitaskbuilder/batch_preview.go @@ -43,10 +43,11 @@ func NewBatchPreviewCommandWithOpener(c client.API, w io.Writer, browserOpener B if len(args) == 0 && opts.BatchID == "" { return errors.New("please provide a batch ID") } - if len(args) > 1 { - return errors.New("accepts at most 1 arg(s), received " + fmt.Sprint(len(args))) + if len(args) > 0 && opts.BatchID != "" { + return errors.New(ErrBatchIDArgAndFlagConflict) } - return nil + + return cobra.MaximumNArgs(1)(cmd, args) }, Short: "Preview a batch in the browser", Long: `Preview a batch in the browser diff --git a/cmd/aitaskbuilder/batch_preview_test.go b/cmd/aitaskbuilder/batch_preview_test.go index e64d5fd8..fe2be7a9 100644 --- a/cmd/aitaskbuilder/batch_preview_test.go +++ b/cmd/aitaskbuilder/batch_preview_test.go @@ -81,6 +81,27 @@ func TestBatchPreviewAcceptsPositionalBatchID(t *testing.T) { } } +func TestBatchPreviewRejectsBatchIDFlagAndPositionalArgument(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + c := mock_client.NewMockAPI(ctrl) + + cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) + if err := cmd.Flags().Set("batch-id", testBatchUUID); err != nil { + t.Fatalf("expected no error setting --batch-id, got %v", err) + } + + err := cmd.Args(cmd, []string{"another-batch-id"}) + if err == nil { + t.Fatal("expected error when both --batch-id and positional batch ID are provided") + } + + expected := aitaskbuilder.ErrBatchIDArgAndFlagConflict + if err.Error() != expected { + t.Fatalf("expected error %q, got %q", expected, err.Error()) + } +} + func TestBatchPreviewCallsGetBatchAndTaskGroups(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cmd/aitaskbuilder/constants.go b/cmd/aitaskbuilder/constants.go index a3385fa8..0a1646ef 100644 --- a/cmd/aitaskbuilder/constants.go +++ b/cmd/aitaskbuilder/constants.go @@ -31,4 +31,5 @@ const ( ErrWorkspaceIDRequired = "workspace ID is required" ErrAtLeastOneUpdateFieldRequired = "at least one of --name, --dataset-id, task detail flags, batch-items flags, --auto-sync, or --no-auto-sync must be provided" ErrAutoSyncFlagsMutuallyExclusive = "cannot use --auto-sync and --no-auto-sync together" + ErrBatchIDArgAndFlagConflict = "cannot specify both --batch-id (-b) and positional batch ID" ) From af9d3c776d6c1cee55b4871ffed978ecbda82fa3 Mon Sep 17 00:00:00 2001 From: Cem Date: Wed, 15 Jul 2026 10:44:12 +0100 Subject: [PATCH 4/4] feat(DCT-51): use positional arg only --- cmd/aitaskbuilder/batch_preview.go | 20 +++----------- cmd/aitaskbuilder/batch_preview_test.go | 26 ------------------- cmd/aitaskbuilder/constants.go | 1 - .../manual-tests/test_audio_batch_preview.go | 2 +- 4 files changed, 4 insertions(+), 45 deletions(-) diff --git a/cmd/aitaskbuilder/batch_preview.go b/cmd/aitaskbuilder/batch_preview.go index 6bc97785..814f2d53 100644 --- a/cmd/aitaskbuilder/batch_preview.go +++ b/cmd/aitaskbuilder/batch_preview.go @@ -38,17 +38,8 @@ func NewBatchPreviewCommandWithOpener(c client.API, w io.Writer, browserOpener B opts.BrowserOpener = browserOpener cmd := &cobra.Command{ - Use: "preview ", - Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 && opts.BatchID == "" { - return errors.New("please provide a batch ID") - } - if len(args) > 0 && opts.BatchID != "" { - return errors.New(ErrBatchIDArgAndFlagConflict) - } - - return cobra.MaximumNArgs(1)(cmd, args) - }, + Use: "preview ", + Args: cobra.ExactArgs(1), Short: "Preview a batch in the browser", Long: `Preview a batch in the browser @@ -60,9 +51,7 @@ $ prolific aitaskbuilder batch preview `, RunE: func(cmd *cobra.Command, args []string) error { opts.Args = args - if opts.BatchID == "" && len(args) > 0 { - opts.BatchID = args[0] - } + opts.BatchID = args[0] err := renderAITaskBuilderBatchPreview(c, opts, w) if err != nil { @@ -73,9 +62,6 @@ $ prolific aitaskbuilder batch preview }, } - flags := cmd.Flags() - flags.StringVarP(&opts.BatchID, "batch-id", "b", "", "Batch ID to preview. Optional when provided as a positional argument.") - return cmd } diff --git a/cmd/aitaskbuilder/batch_preview_test.go b/cmd/aitaskbuilder/batch_preview_test.go index fe2be7a9..9a27d24c 100644 --- a/cmd/aitaskbuilder/batch_preview_test.go +++ b/cmd/aitaskbuilder/batch_preview_test.go @@ -52,11 +52,6 @@ func TestBatchPreviewRequiresBatchID(t *testing.T) { if err == nil { t.Fatal("expected error when batch-id is missing") } - - expected := "please provide a batch ID" - if err.Error() != expected { - t.Fatalf("expected error %q, got %q", expected, err.Error()) - } } func TestBatchPreviewAcceptsPositionalBatchID(t *testing.T) { @@ -81,27 +76,6 @@ func TestBatchPreviewAcceptsPositionalBatchID(t *testing.T) { } } -func TestBatchPreviewRejectsBatchIDFlagAndPositionalArgument(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - c := mock_client.NewMockAPI(ctrl) - - cmd := aitaskbuilder.NewBatchPreviewCommandWithOpener(c, os.Stdout, noOpBrowserOpener) - if err := cmd.Flags().Set("batch-id", testBatchUUID); err != nil { - t.Fatalf("expected no error setting --batch-id, got %v", err) - } - - err := cmd.Args(cmd, []string{"another-batch-id"}) - if err == nil { - t.Fatal("expected error when both --batch-id and positional batch ID are provided") - } - - expected := aitaskbuilder.ErrBatchIDArgAndFlagConflict - if err.Error() != expected { - t.Fatalf("expected error %q, got %q", expected, err.Error()) - } -} - func TestBatchPreviewCallsGetBatchAndTaskGroups(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/cmd/aitaskbuilder/constants.go b/cmd/aitaskbuilder/constants.go index 0a1646ef..a3385fa8 100644 --- a/cmd/aitaskbuilder/constants.go +++ b/cmd/aitaskbuilder/constants.go @@ -31,5 +31,4 @@ const ( ErrWorkspaceIDRequired = "workspace ID is required" ErrAtLeastOneUpdateFieldRequired = "at least one of --name, --dataset-id, task detail flags, batch-items flags, --auto-sync, or --no-auto-sync must be provided" ErrAutoSyncFlagsMutuallyExclusive = "cannot use --auto-sync and --no-auto-sync together" - ErrBatchIDArgAndFlagConflict = "cannot specify both --batch-id (-b) and positional batch ID" ) diff --git a/scripts/manual-tests/test_audio_batch_preview.go b/scripts/manual-tests/test_audio_batch_preview.go index 88a423f4..5d2629c4 100644 --- a/scripts/manual-tests/test_audio_batch_preview.go +++ b/scripts/manual-tests/test_audio_batch_preview.go @@ -11,7 +11,7 @@ import ( "strings" ) -const defaultWorkspaceID = "679271425fe00981084a5f58" // DCT Workspace +const defaultWorkspaceID = "679271425fe00981084a5f58" var fieldPattern = regexp.MustCompile(`(?m)^([A-Za-z ]+):\s*(\S+)$`)