diff --git a/cmd/collection/collection.go b/cmd/collection/collection.go index 36c3be8..8170c22 100644 --- a/cmd/collection/collection.go +++ b/cmd/collection/collection.go @@ -20,6 +20,7 @@ func NewCollectionCommand(client client.API, w io.Writer) *cobra.Command { NewCreateCollectionCommand(client, w), NewUpdateCommand(client, w), NewPublishCommand(client, w), + NewPreviewCommand(client, w), ) return cmd } diff --git a/cmd/collection/preview.go b/cmd/collection/preview.go new file mode 100644 index 0000000..001d635 --- /dev/null +++ b/cmd/collection/preview.go @@ -0,0 +1,92 @@ +package collection + +import ( + "errors" + "fmt" + "io" + + "github.com/pkg/browser" + "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/cmd/shared" + "github.com/prolific-oss/cli/ui" + collectionui "github.com/prolific-oss/cli/ui/collection" + "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 + +// PreviewOptions is the options for the preview collection command. +type PreviewOptions struct { + Args []string + BrowserOpener BrowserOpener +} + +// NewPreviewCommand creates a new `collection preview` command to open a collection +// preview in the browser. +func NewPreviewCommand(c client.API, w io.Writer) *cobra.Command { + return NewPreviewCommandWithOpener(c, w, DefaultBrowserOpener) +} + +// NewPreviewCommandWithOpener creates a new `collection preview` command with a custom browser opener. +// This is useful for testing to avoid opening actual browser windows. +func NewPreviewCommandWithOpener(c client.API, w io.Writer, browserOpener BrowserOpener) *cobra.Command { + var opts PreviewOptions + opts.BrowserOpener = browserOpener + + cmd := &cobra.Command{ + Use: "preview ", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 || args[0] == "" { + return errors.New("please provide a collection ID") + } + return nil + }, + Short: "Preview a collection in the browser", + Long: `Preview a collection in the browser + +Opens the collection in your default web browser so you can preview +it before publishing.`, + Example: ` +Preview a collection in the browser + +$ prolific collection preview 123456789 +`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Args = args + collectionID := opts.Args[0] + + // Fetch collection to validate access + _, err := c.GetCollection(collectionID) + if err != nil { + if shared.IsFeatureNotEnabledError(err) { + ui.RenderFeatureAccessMessage(FeatureNameAITBCollection, FeatureContactURLAITBCollection) + return nil + } + return fmt.Errorf("failed to get collection: %s", err.Error()) + } + + // Build the preview URL and display it + previewURL := collectionui.GetCollectionPreviewURL(collectionID) + fmt.Fprintln(w, "Opening collection 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 + }, + } + + return cmd +} diff --git a/cmd/collection/preview_test.go b/cmd/collection/preview_test.go new file mode 100644 index 0000000..0efa7ac --- /dev/null +++ b/cmd/collection/preview_test.go @@ -0,0 +1,213 @@ +package collection_test + +import ( + "bufio" + "bytes" + "errors" + "strings" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/prolific-oss/cli/cmd/collection" + "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 TestNewPreviewCommand(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewPreviewCommandWithOpener(mockClient, &buf, noOpBrowserOpener) + + use := "preview " + short := "Preview a collection 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 TestPreviewCommandRequiresCollectionID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewPreviewCommandWithOpener(mockClient, &buf, noOpBrowserOpener) + + // Test the Args validator directly + err := cmd.Args(cmd, []string{}) + if err == nil { + t.Fatalf("expected error for missing collection ID, got nil") + } + + expected := "please provide a collection ID" + if err.Error() != expected { + t.Fatalf("expected error %q, got %q", expected, err.Error()) + } +} + +func TestPreviewCommandRequiresNonEmptyCollectionID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + var buf bytes.Buffer + cmd := collection.NewPreviewCommandWithOpener(mockClient, &buf, noOpBrowserOpener) + + // Test the Args validator directly + err := cmd.Args(cmd, []string{""}) + if err == nil { + t.Fatalf("expected error for empty collection ID, got nil") + } + + expected := "please provide a collection ID" + if err.Error() != expected { + t.Fatalf("expected error %q, got %q", expected, err.Error()) + } +} + +func TestPreviewCommandCallsGetCollection(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "Test Collection", + CreatedAt: time.Now(), + CreatedBy: "test-user", + ItemCount: 10, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + cmd := collection.NewPreviewCommandWithOpener(mockClient, writer, noOpBrowserOpener) + + err := cmd.RunE(cmd, []string{testCollectionID}) + writer.Flush() + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestPreviewCommandReturnsErrorOnClientError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(nil, errors.New("collection not found")). + Times(1) + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + cmd := collection.NewPreviewCommandWithOpener(mockClient, writer, noOpBrowserOpener) + + err := cmd.RunE(cmd, []string{testCollectionID}) + writer.Flush() + + if err == nil { + t.Fatal("expected error, got nil") + } + + expected := "failed to get collection: collection not found" + if err.Error() != expected { + t.Fatalf("expected error %q, got %q", expected, err.Error()) + } +} + +func TestPreviewCommandHandlesFeatureNotEnabledError(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + // The feature not enabled error must contain "request failed", "permission", and "feature" + featureError := errors.New("request failed: you do not currently have permission to access this feature") + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(nil, featureError). + Times(1) + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + cmd := collection.NewPreviewCommandWithOpener(mockClient, writer, noOpBrowserOpener) + + err := cmd.RunE(cmd, []string{testCollectionID}) + writer.Flush() + + // When feature is not enabled, the command should not return an error + // but should display a feature access message + if err != nil { + t.Fatalf("expected no error for feature not enabled, got: %v", err) + } +} + +func TestPreviewCommandOutputContainsURL(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockClient := mock_client.NewMockAPI(ctrl) + + testCollection := &model.Collection{ + ID: testCollectionID, + Name: "My Test Collection", + CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), + CreatedBy: "user@example.com", + ItemCount: 25, + } + + mockClient. + EXPECT(). + GetCollection(gomock.Eq(testCollectionID)). + Return(testCollection, nil). + Times(1) + + var buf bytes.Buffer + writer := bufio.NewWriter(&buf) + cmd := collection.NewPreviewCommandWithOpener(mockClient, writer, noOpBrowserOpener) + + err := cmd.RunE(cmd, []string{testCollectionID}) + writer.Flush() + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + output := buf.String() + + // Check that the output contains the expected URL components + expectedStrings := []string{ + "Opening collection preview in browser", + "data-collection-tool/collections/" + testCollectionID, + "preview=true", + } + + for _, expected := range expectedStrings { + if !strings.Contains(output, expected) { + t.Errorf("expected output to contain %q, got: %s", expected, output) + } + } +} diff --git a/config/config.go b/config/config.go index 1423db7..da2e15f 100644 --- a/config/config.go +++ b/config/config.go @@ -2,10 +2,20 @@ // including base URLs for the Prolific application and API. package config -// GetApplicationURL will return the Application URL. This could be updated -// to understand different environments based on API URL perhaps? +import ( + "strings" + + "github.com/spf13/viper" +) + +// DefaultApplicationURL is the default Prolific application URL. +const DefaultApplicationURL = "https://app.prolific.com" + +// GetApplicationURL will return the Application URL. This can be overridden +// using the PROLIFIC_APPLICATION_URL environment variable. func GetApplicationURL() string { - return "https://app.prolific.com" + viper.SetDefault("PROLIFIC_APPLICATION_URL", DefaultApplicationURL) + return strings.TrimRight(viper.GetString("PROLIFIC_APPLICATION_URL"), "/") } // GetAPIURL will return the API URL. This is the default API, but we allow diff --git a/ui/collection/view.go b/ui/collection/view.go index 37143ab..bf26706 100644 --- a/ui/collection/view.go +++ b/ui/collection/view.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/prolific-oss/cli/client" + "github.com/prolific-oss/cli/config" "github.com/prolific-oss/cli/model" "github.com/prolific-oss/cli/ui" ) @@ -93,3 +94,13 @@ func renderCollectionString(collection model.Collection) string { return content } + +// GetCollectionPreviewPath returns the URL path to a collection preview, agnostic of domain +func GetCollectionPreviewPath(ID string) string { + return fmt.Sprintf("data-collection-tool/collections/%s?preview=true", ID) +} + +// GetCollectionPreviewURL returns the full URL to a collection preview using configuration +func GetCollectionPreviewURL(ID string) string { + return fmt.Sprintf("%s/%s", config.GetApplicationURL(), GetCollectionPreviewPath(ID)) +} diff --git a/ui/collection/view_test.go b/ui/collection/view_test.go new file mode 100644 index 0000000..196d3e1 --- /dev/null +++ b/ui/collection/view_test.go @@ -0,0 +1,61 @@ +package collection_test + +import ( + "strings" + "testing" + + "github.com/prolific-oss/cli/config" + "github.com/prolific-oss/cli/ui/collection" +) + +func TestGetCollectionPreviewPath(t *testing.T) { + collectionID := "test-collection-123" + + path := collection.GetCollectionPreviewPath(collectionID) + + expectedPath := "data-collection-tool/collections/test-collection-123?preview=true" + if path != expectedPath { + t.Fatalf("expected path %q, got %q", expectedPath, path) + } +} + +func TestGetCollectionPreviewPathContainsPreviewParam(t *testing.T) { + collectionID := "abc123" + + path := collection.GetCollectionPreviewPath(collectionID) + + if !strings.Contains(path, "preview=true") { + t.Fatalf("expected path to contain 'preview=true', got %q", path) + } +} + +func TestGetCollectionPreviewURL(t *testing.T) { + collectionID := "test-collection-456" + + url := collection.GetCollectionPreviewURL(collectionID) + + expectedURL := config.GetApplicationURL() + "/data-collection-tool/collections/test-collection-456?preview=true" + if url != expectedURL { + t.Fatalf("expected URL %q, got %q", expectedURL, url) + } +} + +func TestGetCollectionPreviewURLUsesApplicationURL(t *testing.T) { + collectionID := "xyz789" + + url := collection.GetCollectionPreviewURL(collectionID) + + if !strings.HasPrefix(url, config.GetApplicationURL()) { + t.Fatalf("expected URL to start with application URL %q, got %q", config.GetApplicationURL(), url) + } +} + +func TestGetCollectionPreviewURLContainsCollectionID(t *testing.T) { + collectionID := "my-unique-id" + + url := collection.GetCollectionPreviewURL(collectionID) + + if !strings.Contains(url, collectionID) { + t.Fatalf("expected URL to contain collection ID %q, got %q", collectionID, url) + } +}