From f248043adfbb2dda53ae353567a7e0ffc61c5cd6 Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Mon, 23 Mar 2026 18:42:37 -0700 Subject: [PATCH 1/8] go --- cmd/docs/docs.go | 9 +- cmd/docs/search.go | 140 +++++++++++++++++++++++++ cmd/docs/search_test.go | 223 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 cmd/docs/search.go create mode 100644 cmd/docs/search_test.go diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 9b47c3e8..d351d454 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -47,12 +47,18 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { Command: "docs --search", }, }), + Args: cobra.ArbitraryArgs, // Allow any arguments RunE: func(cmd *cobra.Command, args []string) error { return runDocsCommand(clients, cmd, args) }, + // Disable automatic suggestions for unknown commands + DisableSuggestions: true, } - cmd.Flags().BoolVar(&searchMode, "search", false, "open Slack docs search page or search with query") + cmd.Flags().BoolVar(&searchMode, "search", false, "[DEPRECATED] open Slack docs search page or search with query (use 'docs search' subcommand instead)") + + // Add the experimental search subcommand + cmd.AddCommand(NewSearchCommand(clients)) return cmd } @@ -74,6 +80,7 @@ func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []st } if cmd.Flags().Changed("search") { + clients.IO.PrintWarning(ctx, "The `--search` flag is deprecated. Use 'docs search' subcommand instead.") if len(args) > 0 { // --search "query" (space-separated) - join all args as the query query := strings.Join(args, " ") diff --git a/cmd/docs/search.go b/cmd/docs/search.go new file mode 100644 index 00000000..d70ba4bd --- /dev/null +++ b/cmd/docs/search.go @@ -0,0 +1,140 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package docs + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slacktrace" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +var searchOutputFlag string +var searchLimitFlag int + +// response from the Slack docs search API +type DocsSearchResponse struct { + TotalResults int `json:"total_results"` + Results []DocsSearchResult `json:"results"` + Limit int `json:"limit"` +} + +// single search result +type DocsSearchResult struct { + URL string `json:"url"` + Title string `json:"title"` +} + +func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "search ", + Short: "Search Slack developer docs (experimental)", + Long: "Search the Slack developer docs and return results in browser or JSON format", + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Search docs and open results in browser", + Command: "docs search \"Block Kit\"", + }, + { + Meaning: "Search docs and return JSON results", + Command: "docs search \"webhooks\" --output=json", + }, + { + Meaning: "Search docs with limited JSON results", + Command: "docs search \"api\" --output=json --limit=5", + }, + }), + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDocsSearchCommand(clients, cmd, args, http.DefaultClient) + }, + } + + cmd.Flags().StringVar(&searchOutputFlag, "output", "json", "output format: browser, json") + cmd.Flags().IntVar(&searchLimitFlag, "limit", 20, "maximum number of search results to return (only applies with --output=json)") + + return cmd +} + +// handles the docs search subcommand +func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string, httpClient *http.Client) error { + ctx := cmd.Context() + + query := strings.Join(args, " ") + + if searchOutputFlag == "json" { + return fetchAndOutputSearchResults(ctx, clients, query, searchLimitFlag, httpClient) + } + + // Browser output - open search results in browser + encodedQuery := url.QueryEscape(query) + docsURL := fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery) + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "Docs Search", + Secondary: []string{ + docsURL, + }, + })) + + clients.Browser().OpenURL(docsURL) + clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) + + return nil +} + +// fetches search results from the docs API and outputs as JSON +func fetchAndOutputSearchResults(ctx context.Context, clients *shared.ClientFactory, query string, limit int, httpClient *http.Client) error { + // Build API URL with limit parameter + apiURL := fmt.Sprintf("https://docs-slack-d-search-api-duu9zr.herokuapp.com/api/search?q=%s&limit=%d", url.QueryEscape(query), limit) + + // Make HTTP request + resp, err := httpClient.Get(apiURL) + if err != nil { + return fmt.Errorf("failed to fetch search results: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API returned status %d", resp.StatusCode) + } + + // Parse JSON response + var searchResponse DocsSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResponse); err != nil { + return fmt.Errorf("failed to parse search results: %w", err) + } + + // Output as JSON + output, err := json.MarshalIndent(searchResponse, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON output: %w", err) + } + + fmt.Println(string(output)) + + // Trace the successful API call + clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) + + return nil +} diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go new file mode 100644 index 00000000..fa8c4e8c --- /dev/null +++ b/cmd/docs/search_test.go @@ -0,0 +1,223 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package docs + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "testing" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockRoundTripper implements http.RoundTripper to mock HTTP responses for testing. +// It allows tests to control the response status code and body without making real network calls. +// It also captures the request URL for assertion purposes. +type mockRoundTripper struct { + response string + status int + capturedURL string +} + +// RoundTrip executes a mocked HTTP request and returns a controlled response. +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + m.capturedURL = req.URL.String() + return &http.Response{ + StatusCode: m.status, + Body: io.NopCloser(bytes.NewBufferString(m.response)), + Header: make(http.Header), + }, nil +} + +// setupJSONOutputTest creates a mock client and clients factory for JSON output tests. +func setupJSONOutputTest(t *testing.T, response string, status int) (*http.Client, *shared.ClientFactory, *mockRoundTripper) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + mockTransport := &mockRoundTripper{ + response: response, + status: status, + } + mockClient := &http.Client{ + Transport: mockTransport, + } + + return mockClient, clients, mockTransport +} + +// JSON Output Tests + +// Test_Docs_SearchCommand_JSONOutput_APIError verifies that HTTP errors from the API +// (e.g., 404 Not Found) are properly caught and returned as errors. +func Test_Docs_SearchCommand_JSONOutput_APIError(t *testing.T) { + mockClient, clients, _ := setupJSONOutputTest(t, `{"error": "not found"}`, http.StatusNotFound) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent", 20, mockClient) + assert.Error(t, err) + assert.Contains(t, err.Error(), "API returned status 404") +} + +// Test_Docs_SearchCommand_JSONOutput_InvalidJSON verifies that malformed JSON responses +// from the API are caught during parsing and returned as errors. +func Test_Docs_SearchCommand_JSONOutput_InvalidJSON(t *testing.T) { + mockClient, clients, _ := setupJSONOutputTest(t, `{invalid json}`, http.StatusOK) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "test", 20, mockClient) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse search results") +} + +// Test_Docs_SearchCommand_JSONOutput_EmptyResults verifies that valid JSON responses with no results +// are correctly parsed and output without errors. +func Test_Docs_SearchCommand_JSONOutput_EmptyResults(t *testing.T) { + mockResponse := `{ + "total_results": 0, + "limit": 20, + "results": [] + }` + + mockClient, clients, _ := setupJSONOutputTest(t, mockResponse, http.StatusOK) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent query", 20, mockClient) + require.NoError(t, err) +} + +// Test_Docs_SearchCommand_JSONOutput_QueryFormats tests JSON output with various query formats +// to ensure proper URL encoding, API parameter handling, and response parsing. +func Test_Docs_SearchCommand_JSONOutput_QueryFormats(t *testing.T) { + mockResponse := `{ + "total_results": 2, + "limit": 20, + "results": [ + { + "title": "Block Kit", + "url": "https://docs.slack.dev/block-kit" + }, + { + "title": "Block Kit Elements", + "url": "https://docs.slack.dev/block-kit/elements" + } + ] + }` + + tests := map[string]struct { + query string + limit int + expected string + }{ + "single word query": { + query: "messaging", + limit: 20, + expected: "messaging", + }, + "multiple words": { + query: "socket mode", + limit: 20, + expected: "socket+mode", + }, + "special characters": { + query: "messages & webhooks", + limit: 20, + expected: "messages+%26+webhooks", + }, + "custom limit": { + query: "Block Kit", + limit: 5, + expected: "Block+Kit", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + mockClient, clients, mockTransport := setupJSONOutputTest(t, mockResponse, http.StatusOK) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, tc.query, tc.limit, mockClient) + require.NoError(t, err) + assert.Contains(t, mockTransport.capturedURL, "q="+tc.expected) + assert.Contains(t, mockTransport.capturedURL, "limit="+fmt.Sprint(tc.limit)) + }) + } +} + +// Browser Output Tests + +// Test_Docs_SearchCommand_BrowserOutput tests the browser output mode with various query formats +// to ensure proper URL encoding and command execution. +func Test_Docs_SearchCommand_BrowserOutput(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "opens browser with search query using space syntax": { + CmdArgs: []string{"search", "messaging", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := "https://docs.slack.dev/search/?q=messaging" + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + ExpectedOutputs: []string{ + "Docs Search", + "https://docs.slack.dev/search/?q=messaging", + }, + }, + "handles search with multiple arguments": { + CmdArgs: []string{"search", "Block", "Kit", "Element", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := "https://docs.slack.dev/search/?q=Block+Kit+Element" + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + ExpectedOutputs: []string{ + "Docs Search", + "https://docs.slack.dev/search/?q=Block+Kit+Element", + }, + }, + "handles search query with multiple words": { + CmdArgs: []string{"search", "socket mode", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := "https://docs.slack.dev/search/?q=socket+mode" + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + ExpectedOutputs: []string{ + "Docs Search", + "https://docs.slack.dev/search/?q=socket+mode", + }, + }, + "handles special characters in search query": { + CmdArgs: []string{"search", "messages & webhooks", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := "https://docs.slack.dev/search/?q=messages+%26+webhooks" + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + ExpectedOutputs: []string{ + "Docs Search", + "https://docs.slack.dev/search/?q=messages+%26+webhooks", + }, + }, + "handles search query with quotes": { + CmdArgs: []string{"search", "webhook \"send message\"", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := "https://docs.slack.dev/search/?q=webhook+%22send+message%22" + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + ExpectedOutputs: []string{ + "Docs Search", + "https://docs.slack.dev/search/?q=webhook+%22send+message%22", + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCommand(cf) + }) +} From 94c8a90491e4e61f77ec6c24199c618f2b627e8f Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Tue, 24 Mar 2026 09:38:33 -0700 Subject: [PATCH 2/8] refactoring --- cmd/docs/docs.go | 2 +- cmd/docs/search.go | 46 +++++++++++++++++++---------------------- cmd/docs/search_test.go | 42 +++++++++++++++++++++++-------------- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index d351d454..37b0f0c4 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -57,7 +57,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd.Flags().BoolVar(&searchMode, "search", false, "[DEPRECATED] open Slack docs search page or search with query (use 'docs search' subcommand instead)") - // Add the experimental search subcommand + // Add the search subcommand cmd.AddCommand(NewSearchCommand(clients)) return cmd diff --git a/cmd/docs/search.go b/cmd/docs/search.go index d70ba4bd..3063ff8e 100644 --- a/cmd/docs/search.go +++ b/cmd/docs/search.go @@ -20,6 +20,7 @@ import ( "fmt" "net/http" "net/url" + "os" "strings" "github.com/slackapi/slack-cli/internal/shared" @@ -28,26 +29,30 @@ import ( "github.com/spf13/cobra" ) -var searchOutputFlag string -var searchLimitFlag int +const docsSearchAPIURL = "https://docs-slack-d-search-api-duu9zr.herokuapp.com/api/search" + +type searchConfig struct { + output string + limit int +} -// response from the Slack docs search API type DocsSearchResponse struct { TotalResults int `json:"total_results"` Results []DocsSearchResult `json:"results"` Limit int `json:"limit"` } -// single search result type DocsSearchResult struct { URL string `json:"url"` Title string `json:"title"` } func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { + cfg := &searchConfig{} + cmd := &cobra.Command{ Use: "search ", - Short: "Search Slack developer docs (experimental)", + Short: "Search Slack developer docs", Long: "Search the Slack developer docs and return results in browser or JSON format", Example: style.ExampleCommandsf([]style.ExampleCommand{ { @@ -65,27 +70,25 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { }), Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runDocsSearchCommand(clients, cmd, args, http.DefaultClient) + return runDocsSearchCommand(clients, cmd, args, cfg, http.DefaultClient) }, } - cmd.Flags().StringVar(&searchOutputFlag, "output", "json", "output format: browser, json") - cmd.Flags().IntVar(&searchLimitFlag, "limit", 20, "maximum number of search results to return (only applies with --output=json)") + cmd.Flags().StringVar(&cfg.output, "output", "json", "output format: browser, json") + cmd.Flags().IntVar(&cfg.limit, "limit", 20, "maximum number of search results to return (only applies with --output=json)") return cmd } -// handles the docs search subcommand -func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string, httpClient *http.Client) error { +func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string, cfg *searchConfig, httpClient *http.Client) error { ctx := cmd.Context() query := strings.Join(args, " ") - if searchOutputFlag == "json" { - return fetchAndOutputSearchResults(ctx, clients, query, searchLimitFlag, httpClient) + if cfg.output == "json" { + return fetchAndOutputSearchResults(ctx, clients, query, cfg.limit, httpClient) } - // Browser output - open search results in browser encodedQuery := url.QueryEscape(query) docsURL := fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery) @@ -103,12 +106,9 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg return nil } -// fetches search results from the docs API and outputs as JSON func fetchAndOutputSearchResults(ctx context.Context, clients *shared.ClientFactory, query string, limit int, httpClient *http.Client) error { - // Build API URL with limit parameter - apiURL := fmt.Sprintf("https://docs-slack-d-search-api-duu9zr.herokuapp.com/api/search?q=%s&limit=%d", url.QueryEscape(query), limit) + apiURL := fmt.Sprintf("%s?q=%s&limit=%d", docsSearchAPIURL, url.QueryEscape(query), limit) - // Make HTTP request resp, err := httpClient.Get(apiURL) if err != nil { return fmt.Errorf("failed to fetch search results: %w", err) @@ -119,21 +119,17 @@ func fetchAndOutputSearchResults(ctx context.Context, clients *shared.ClientFact return fmt.Errorf("API returned status %d", resp.StatusCode) } - // Parse JSON response var searchResponse DocsSearchResponse if err := json.NewDecoder(resp.Body).Decode(&searchResponse); err != nil { return fmt.Errorf("failed to parse search results: %w", err) } - // Output as JSON - output, err := json.MarshalIndent(searchResponse, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON output: %w", err) + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(searchResponse); err != nil { + return fmt.Errorf("failed to output search results: %w", err) } - fmt.Println(string(output)) - - // Trace the successful API call clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) return nil diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go index fa8c4e8c..5d39156d 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -49,8 +49,23 @@ func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) }, nil } -// setupJSONOutputTest creates a mock client and clients factory for JSON output tests. -func setupJSONOutputTest(t *testing.T, response string, status int) (*http.Client, *shared.ClientFactory, *mockRoundTripper) { +func setupJSONOutputTest(t *testing.T, response string, status int) (*http.Client, *shared.ClientFactory) { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + mockTransport := &mockRoundTripper{ + response: response, + status: status, + } + mockClient := &http.Client{ + Transport: mockTransport, + } + + return mockClient, clients +} + +func setupJSONOutputTestWithCapture(t *testing.T, response string, status int) (*http.Client, *shared.ClientFactory, *mockRoundTripper) { clientsMock := shared.NewClientsMock() clientsMock.AddDefaultMocks() clients := shared.NewClientFactory(clientsMock.MockClientFactory()) @@ -68,26 +83,23 @@ func setupJSONOutputTest(t *testing.T, response string, status int) (*http.Clien // JSON Output Tests -// Test_Docs_SearchCommand_JSONOutput_APIError verifies that HTTP errors from the API -// (e.g., 404 Not Found) are properly caught and returned as errors. +// Verifies that HTTP errors from the API are properly caught and returned as errors. func Test_Docs_SearchCommand_JSONOutput_APIError(t *testing.T) { - mockClient, clients, _ := setupJSONOutputTest(t, `{"error": "not found"}`, http.StatusNotFound) + mockClient, clients := setupJSONOutputTest(t, `{"error": "not found"}`, http.StatusNotFound) err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent", 20, mockClient) assert.Error(t, err) assert.Contains(t, err.Error(), "API returned status 404") } -// Test_Docs_SearchCommand_JSONOutput_InvalidJSON verifies that malformed JSON responses -// from the API are caught during parsing and returned as errors. +// Verifies that malformed JSON responses are caught during parsing and returned as errors. func Test_Docs_SearchCommand_JSONOutput_InvalidJSON(t *testing.T) { - mockClient, clients, _ := setupJSONOutputTest(t, `{invalid json}`, http.StatusOK) + mockClient, clients := setupJSONOutputTest(t, `{invalid json}`, http.StatusOK) err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "test", 20, mockClient) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to parse search results") } -// Test_Docs_SearchCommand_JSONOutput_EmptyResults verifies that valid JSON responses with no results -// are correctly parsed and output without errors. +// Verifies that valid JSON responses with no results are correctly parsed and output without errors. func Test_Docs_SearchCommand_JSONOutput_EmptyResults(t *testing.T) { mockResponse := `{ "total_results": 0, @@ -95,13 +107,12 @@ func Test_Docs_SearchCommand_JSONOutput_EmptyResults(t *testing.T) { "results": [] }` - mockClient, clients, _ := setupJSONOutputTest(t, mockResponse, http.StatusOK) + mockClient, clients := setupJSONOutputTest(t, mockResponse, http.StatusOK) err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent query", 20, mockClient) require.NoError(t, err) } -// Test_Docs_SearchCommand_JSONOutput_QueryFormats tests JSON output with various query formats -// to ensure proper URL encoding, API parameter handling, and response parsing. +// Verifies that various query formats are properly URL encoded and API parameters are correctly passed. func Test_Docs_SearchCommand_JSONOutput_QueryFormats(t *testing.T) { mockResponse := `{ "total_results": 2, @@ -147,7 +158,7 @@ func Test_Docs_SearchCommand_JSONOutput_QueryFormats(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - mockClient, clients, mockTransport := setupJSONOutputTest(t, mockResponse, http.StatusOK) + mockClient, clients, mockTransport := setupJSONOutputTestWithCapture(t, mockResponse, http.StatusOK) err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, tc.query, tc.limit, mockClient) require.NoError(t, err) assert.Contains(t, mockTransport.capturedURL, "q="+tc.expected) @@ -158,8 +169,7 @@ func Test_Docs_SearchCommand_JSONOutput_QueryFormats(t *testing.T) { // Browser Output Tests -// Test_Docs_SearchCommand_BrowserOutput tests the browser output mode with various query formats -// to ensure proper URL encoding and command execution. +// Verifies that browser output mode correctly handles various query formats and opens the correct URLs. func Test_Docs_SearchCommand_BrowserOutput(t *testing.T) { testutil.TableTestCommand(t, testutil.CommandTests{ "opens browser with search query using space syntax": { From d7bbb84c1d44437960ac404c2915355008fb99c1 Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Tue, 24 Mar 2026 09:46:45 -0700 Subject: [PATCH 3/8] go --- cmd/docs/docs.go | 8 ++++---- cmd/docs/search.go | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 37b0f0c4..51159d56 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -32,7 +32,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "docs", Short: "Open Slack developer docs", - Long: "Open the Slack developer docs in your browser, with optional search functionality", + Long: "Open the Slack developer docs in your browser or search them using the search subcommand", Example: style.ExampleCommandsf([]style.ExampleCommand{ { Meaning: "Open Slack developer docs homepage", @@ -40,11 +40,11 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { }, { Meaning: "Search Slack developer docs for Block Kit", - Command: "docs --search \"Block Kit\"", + Command: "docs search \"Block Kit\"", }, { - Meaning: "Open Slack docs search page", - Command: "docs --search", + Meaning: "Search docs and open results in browser", + Command: "docs search \"Block Kit\" --output=browser", }, }), Args: cobra.ArbitraryArgs, // Allow any arguments diff --git a/cmd/docs/search.go b/cmd/docs/search.go index 3063ff8e..a6257084 100644 --- a/cmd/docs/search.go +++ b/cmd/docs/search.go @@ -56,12 +56,12 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { Long: "Search the Slack developer docs and return results in browser or JSON format", Example: style.ExampleCommandsf([]style.ExampleCommand{ { - Meaning: "Search docs and open results in browser", + Meaning: "Search docs and return JSON results", Command: "docs search \"Block Kit\"", }, { - Meaning: "Search docs and return JSON results", - Command: "docs search \"webhooks\" --output=json", + Meaning: "Search docs and open results in browser", + Command: "docs search \"webhooks\" --output=browser", }, { Meaning: "Search docs with limited JSON results", From 727d7cec00a32354ab6295ef3aec49f9cc7fb5f1 Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Tue, 24 Mar 2026 09:51:10 -0700 Subject: [PATCH 4/8] more test coverage --- cmd/docs/search_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go index 5d39156d..fb073433 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -37,10 +37,14 @@ type mockRoundTripper struct { response string status int capturedURL string + returnError bool } // RoundTrip executes a mocked HTTP request and returns a controlled response. func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if m.returnError { + return nil, fmt.Errorf("mock network error") + } m.capturedURL = req.URL.String() return &http.Response{ StatusCode: m.status, @@ -83,6 +87,29 @@ func setupJSONOutputTestWithCapture(t *testing.T, response string, status int) ( // JSON Output Tests +// Verifies that HTTP request errors are properly caught and returned as errors. +func Test_Docs_SearchCommand_JSONOutput_HTTPError(t *testing.T) { + ctx := slackcontext.MockContext(context.Background()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + // Create a mock transport that returns an error + mockTransport := &mockRoundTripper{ + response: "", + status: 0, + } + mockTransport.returnError = true + + mockClient := &http.Client{ + Transport: mockTransport, + } + + err := fetchAndOutputSearchResults(ctx, clients, "test", 20, mockClient) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch search results") +} + // Verifies that HTTP errors from the API are properly caught and returned as errors. func Test_Docs_SearchCommand_JSONOutput_APIError(t *testing.T) { mockClient, clients := setupJSONOutputTest(t, `{"error": "not found"}`, http.StatusNotFound) From 49857131f3f76a89c193c1da30bf626ce5783f47 Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Thu, 26 Mar 2026 14:37:45 -0700 Subject: [PATCH 5/8] refactor --- cmd/docs/docs.go | 21 +++-- cmd/docs/search.go | 110 +++++++++++++----------- cmd/docs/search_test.go | 180 ++++++++++++++++----------------------- internal/api/api_mock.go | 10 +++ internal/api/docs.go | 76 +++++++++++++++++ internal/api/types.go | 1 + 6 files changed, 231 insertions(+), 167 deletions(-) create mode 100644 internal/api/docs.go diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 51159d56..30f5d92c 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -26,8 +26,15 @@ import ( "github.com/spf13/cobra" ) +const docsURL = "https://docs.slack.dev" + var searchMode bool +func buildDocsSearchURL(query string) string { + encodedQuery := url.QueryEscape(query) + return fmt.Sprintf("%s/search/?q=%s", docsURL, encodedQuery) +} + func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "docs", @@ -67,7 +74,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error { ctx := cmd.Context() - var docsURL string + var finalURL string var sectionText string // Validate: if there are arguments, --search flag must be used @@ -80,21 +87,19 @@ func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []st } if cmd.Flags().Changed("search") { - clients.IO.PrintWarning(ctx, "The `--search` flag is deprecated. Use 'docs search' subcommand instead.") if len(args) > 0 { // --search "query" (space-separated) - join all args as the query query := strings.Join(args, " ") - encodedQuery := url.QueryEscape(query) - docsURL = fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery) + finalURL = buildDocsSearchURL(query) sectionText = "Docs Search" } else { // --search (no argument) - open search page - docsURL = "https://docs.slack.dev/search/" + finalURL = fmt.Sprintf("%s/search/", docsURL) sectionText = "Docs Search" } } else { // No search flag: default homepage - docsURL = "https://docs.slack.dev" + finalURL = docsURL sectionText = "Docs Open" } @@ -102,11 +107,11 @@ func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []st Emoji: "books", Text: sectionText, Secondary: []string{ - docsURL, + finalURL, }, })) - clients.Browser().OpenURL(docsURL) + clients.Browser().OpenURL(finalURL) if cmd.Flags().Changed("search") { traceValue := "" diff --git a/cmd/docs/search.go b/cmd/docs/search.go index a6257084..f6944c31 100644 --- a/cmd/docs/search.go +++ b/cmd/docs/search.go @@ -18,33 +18,25 @@ import ( "context" "encoding/json" "fmt" - "net/http" - "net/url" - "os" "strings" "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" "github.com/spf13/cobra" ) -const docsSearchAPIURL = "https://docs-slack-d-search-api-duu9zr.herokuapp.com/api/search" - type searchConfig struct { output string limit int } -type DocsSearchResponse struct { - TotalResults int `json:"total_results"` - Results []DocsSearchResult `json:"results"` - Limit int `json:"limit"` -} - -type DocsSearchResult struct { - URL string `json:"url"` - Title string `json:"title"` +func makeAbsoluteURL(relativeURL string) string { + if strings.HasPrefix(relativeURL, "http") { + return relativeURL + } + return docsURL + relativeURL } func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { @@ -53,10 +45,10 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "search ", Short: "Search Slack developer docs", - Long: "Search the Slack developer docs and return results in browser or JSON format", + Long: "Search the Slack developer docs and return results in text, JSON, or browser format", Example: style.ExampleCommandsf([]style.ExampleCommand{ { - Meaning: "Search docs and return JSON results", + Meaning: "Search docs and return text results", Command: "docs search \"Block Kit\"", }, { @@ -70,64 +62,80 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { }), Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runDocsSearchCommand(clients, cmd, args, cfg, http.DefaultClient) + return runDocsSearchCommand(clients, cmd, args, cfg) }, } - cmd.Flags().StringVar(&cfg.output, "output", "json", "output format: browser, json") - cmd.Flags().IntVar(&cfg.limit, "limit", 20, "maximum number of search results to return (only applies with --output=json)") + cmd.Flags().StringVar(&cfg.output, "output", "text", "output format: text, json, browser") + cmd.Flags().IntVar(&cfg.limit, "limit", 20, "maximum number of search results to return (only applies with --output=json and --output=text)") return cmd } -func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string, cfg *searchConfig, httpClient *http.Client) error { +func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string, cfg *searchConfig) error { ctx := cmd.Context() query := strings.Join(args, " ") - if cfg.output == "json" { - return fetchAndOutputSearchResults(ctx, clients, query, cfg.limit, httpClient) + switch cfg.output { + case "json": + return fetchAndOutputSearchResults(ctx, clients, query, cfg.limit) + case "text": + return fetchAndOutputTextResults(ctx, clients, query, cfg.limit) + case "browser": + docsSearchURL := buildDocsSearchURL(query) + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "Docs Search", + Secondary: []string{ + docsSearchURL, + }, + })) + + clients.Browser().OpenURL(docsSearchURL) + clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) + + return nil + default: + return slackerror.New(slackerror.ErrInvalidFlag).WithMessage( + "Invalid output format: %s", cfg.output, + ).WithRemediation( + "Use one of: text, json, browser", + ) } +} - encodedQuery := url.QueryEscape(query) - docsURL := fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery) +func fetchAndOutputSearchResults(ctx context.Context, clients *shared.ClientFactory, query string, limit int) error { + searchResponse, err := clients.API().DocsSearch(ctx, query, limit) + if err != nil { + return err + } - clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ - Emoji: "books", - Text: "Docs Search", - Secondary: []string{ - docsURL, - }, - })) + for i := range searchResponse.Results { + searchResponse.Results[i].URL = makeAbsoluteURL(searchResponse.Results[i].URL) + } + + encoder := json.NewEncoder(clients.IO.WriteOut()) + encoder.SetIndent("", " ") + if err := encoder.Encode(searchResponse); err != nil { + return slackerror.New(slackerror.ErrUnableToParseJSON).WithRootCause(err) + } - clients.Browser().OpenURL(docsURL) clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) return nil } -func fetchAndOutputSearchResults(ctx context.Context, clients *shared.ClientFactory, query string, limit int, httpClient *http.Client) error { - apiURL := fmt.Sprintf("%s?q=%s&limit=%d", docsSearchAPIURL, url.QueryEscape(query), limit) - - resp, err := httpClient.Get(apiURL) +func fetchAndOutputTextResults(ctx context.Context, clients *shared.ClientFactory, query string, limit int) error { + searchResponse, err := clients.API().DocsSearch(ctx, query, limit) if err != nil { - return fmt.Errorf("failed to fetch search results: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("API returned status %d", resp.StatusCode) - } - - var searchResponse DocsSearchResponse - if err := json.NewDecoder(resp.Body).Decode(&searchResponse); err != nil { - return fmt.Errorf("failed to parse search results: %w", err) + return err } - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - if err := encoder.Encode(searchResponse); err != nil { - return fmt.Errorf("failed to output search results: %w", err) + for _, result := range searchResponse.Results { + absoluteURL := makeAbsoluteURL(result.URL) + fmt.Fprintf(clients.IO.WriteOut(), "%s\n%s\n\n", result.Title, absoluteURL) } clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go index fb073433..8fbc6af2 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -15,181 +15,145 @@ package docs import ( - "bytes" "context" - "fmt" - "io" - "net/http" "testing" + "github.com/slackapi/slack-cli/internal/api" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// mockRoundTripper implements http.RoundTripper to mock HTTP responses for testing. -// It allows tests to control the response status code and body without making real network calls. -// It also captures the request URL for assertion purposes. -type mockRoundTripper struct { - response string - status int - capturedURL string - returnError bool +// mockDocsAPI implements api.DocsClient for testing +type mockDocsAPI struct { + searchResponse *api.DocsSearchResponse + searchError error } -// RoundTrip executes a mocked HTTP request and returns a controlled response. -func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - if m.returnError { - return nil, fmt.Errorf("mock network error") - } - m.capturedURL = req.URL.String() - return &http.Response{ - StatusCode: m.status, - Body: io.NopCloser(bytes.NewBufferString(m.response)), - Header: make(http.Header), - }, nil +func (m *mockDocsAPI) DocsSearch(ctx context.Context, query string, limit int) (*api.DocsSearchResponse, error) { + return m.searchResponse, m.searchError } -func setupJSONOutputTest(t *testing.T, response string, status int) (*http.Client, *shared.ClientFactory) { +func setupDocsAPITest(t *testing.T, response *api.DocsSearchResponse, err error) *shared.ClientFactory { clientsMock := shared.NewClientsMock() clientsMock.AddDefaultMocks() clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - mockTransport := &mockRoundTripper{ - response: response, - status: status, + mockDocsAPI := &mockDocsAPI{ + searchResponse: response, + searchError: err, } - mockClient := &http.Client{ - Transport: mockTransport, + + // Override the API to return our mock for DocsSearch + originalAPI := clients.API + clients.API = func() api.APIInterface { + realAPI := originalAPI() + // Return a wrapper that intercepts DocsSearch calls + return &docsAPIWrapper{ + APIInterface: realAPI, + mock: mockDocsAPI, + } } - return mockClient, clients + return clients } -func setupJSONOutputTestWithCapture(t *testing.T, response string, status int) (*http.Client, *shared.ClientFactory, *mockRoundTripper) { - clientsMock := shared.NewClientsMock() - clientsMock.AddDefaultMocks() - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - mockTransport := &mockRoundTripper{ - response: response, - status: status, - } - mockClient := &http.Client{ - Transport: mockTransport, - } +// docsAPIWrapper wraps APIInterface to mock DocsSearch while delegating other methods +type docsAPIWrapper struct { + api.APIInterface + mock *mockDocsAPI +} - return mockClient, clients, mockTransport +func (w *docsAPIWrapper) DocsSearch(ctx context.Context, query string, limit int) (*api.DocsSearchResponse, error) { + return w.mock.DocsSearch(ctx, query, limit) } -// JSON Output Tests +// Text and JSON Output Tests // Verifies that HTTP request errors are properly caught and returned as errors. -func Test_Docs_SearchCommand_JSONOutput_HTTPError(t *testing.T) { +func Test_Docs_SearchCommand_TextJSONOutput_HTTPError(t *testing.T) { ctx := slackcontext.MockContext(context.Background()) - clientsMock := shared.NewClientsMock() - clientsMock.AddDefaultMocks() - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - // Create a mock transport that returns an error - mockTransport := &mockRoundTripper{ - response: "", - status: 0, - } - mockTransport.returnError = true - - mockClient := &http.Client{ - Transport: mockTransport, - } + clients := setupDocsAPITest(t, nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) - err := fetchAndOutputSearchResults(ctx, clients, "test", 20, mockClient) + err := fetchAndOutputSearchResults(ctx, clients, "test", 20) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to fetch search results") } // Verifies that HTTP errors from the API are properly caught and returned as errors. -func Test_Docs_SearchCommand_JSONOutput_APIError(t *testing.T) { - mockClient, clients := setupJSONOutputTest(t, `{"error": "not found"}`, http.StatusNotFound) - err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent", 20, mockClient) +func Test_Docs_SearchCommand_TextJSONOutput_APIError(t *testing.T) { + clients := setupDocsAPITest(t, nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent", 20) assert.Error(t, err) - assert.Contains(t, err.Error(), "API returned status 404") } // Verifies that malformed JSON responses are caught during parsing and returned as errors. -func Test_Docs_SearchCommand_JSONOutput_InvalidJSON(t *testing.T) { - mockClient, clients := setupJSONOutputTest(t, `{invalid json}`, http.StatusOK) - err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "test", 20, mockClient) +func Test_Docs_SearchCommand_TextJSONOutput_InvalidJSON(t *testing.T) { + clients := setupDocsAPITest(t, nil, slackerror.New(slackerror.ErrHTTPResponseInvalid)) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "test", 20) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse search results") } // Verifies that valid JSON responses with no results are correctly parsed and output without errors. -func Test_Docs_SearchCommand_JSONOutput_EmptyResults(t *testing.T) { - mockResponse := `{ - "total_results": 0, - "limit": 20, - "results": [] - }` +func Test_Docs_SearchCommand_TextJSONOutput_EmptyResults(t *testing.T) { + response := &api.DocsSearchResponse{ + TotalResults: 0, + Results: []api.DocsSearchItem{}, + Limit: 20, + } - mockClient, clients := setupJSONOutputTest(t, mockResponse, http.StatusOK) - err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent query", 20, mockClient) + clients := setupDocsAPITest(t, response, nil) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent query", 20) require.NoError(t, err) } // Verifies that various query formats are properly URL encoded and API parameters are correctly passed. -func Test_Docs_SearchCommand_JSONOutput_QueryFormats(t *testing.T) { - mockResponse := `{ - "total_results": 2, - "limit": 20, - "results": [ +func Test_Docs_SearchCommand_TextJSONOutput_QueryFormats(t *testing.T) { + response := &api.DocsSearchResponse{ + TotalResults: 2, + Limit: 20, + Results: []api.DocsSearchItem{ { - "title": "Block Kit", - "url": "https://docs.slack.dev/block-kit" + Title: "Block Kit", + URL: "/block-kit", }, { - "title": "Block Kit Elements", - "url": "https://docs.slack.dev/block-kit/elements" - } - ] - }` + Title: "Block Kit Elements", + URL: "/block-kit/elements", + }, + }, + } tests := map[string]struct { - query string - limit int - expected string + query string + limit int }{ "single word query": { - query: "messaging", - limit: 20, - expected: "messaging", + query: "messaging", + limit: 20, }, "multiple words": { - query: "socket mode", - limit: 20, - expected: "socket+mode", + query: "socket mode", + limit: 20, }, "special characters": { - query: "messages & webhooks", - limit: 20, - expected: "messages+%26+webhooks", + query: "messages & webhooks", + limit: 20, }, "custom limit": { - query: "Block Kit", - limit: 5, - expected: "Block+Kit", + query: "Block Kit", + limit: 5, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { - mockClient, clients, mockTransport := setupJSONOutputTestWithCapture(t, mockResponse, http.StatusOK) - err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, tc.query, tc.limit, mockClient) + clients := setupDocsAPITest(t, response, nil) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, tc.query, tc.limit) require.NoError(t, err) - assert.Contains(t, mockTransport.capturedURL, "q="+tc.expected) - assert.Contains(t, mockTransport.capturedURL, "limit="+fmt.Sprint(tc.limit)) }) } } diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index c68b8953..951b60bb 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -381,3 +381,13 @@ func (m *APIMock) DeveloperAppInstall(ctx context.Context, IO iostreams.IOStream args := m.Called(ctx, IO, token, app, botScopes, outgoingDomains, orgGrantWorkspaceID, autoAAARequest) return args.Get(0).(DeveloperAppInstallResult), args.Get(1).(types.InstallState), args.Error(2) } + +// DocsClient + +func (m *APIMock) DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) { + args := m.Called(ctx, query, limit) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*DocsSearchResponse), args.Error(1) +} diff --git a/internal/api/docs.go b/internal/api/docs.go new file mode 100644 index 00000000..cf18e844 --- /dev/null +++ b/internal/api/docs.go @@ -0,0 +1,76 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/opentracing/opentracing-go" +) + +const docsSearchAPIURL = "https://docs-slack-d-search-api-duu9zr.herokuapp.com" + +type DocsClient interface { + DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) +} + +type DocsSearchResponse struct { + TotalResults int `json:"total_results"` + Results []DocsSearchItem `json:"results"` + Limit int `json:"limit"` +} + +type DocsSearchItem struct { + URL string `json:"url"` + Title string `json:"title"` +} + +// DocsSearch searches the Slack developer docs API +func (c *Client) DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) { + var span opentracing.Span + span, _ = opentracing.StartSpanFromContext(ctx, "apiclient.DocsSearch") + defer span.Finish() + + endpoint := fmt.Sprintf("api/search?q=%s&limit=%d", url.QueryEscape(query), limit) + sURL := docsSearchAPIURL + "/" + endpoint + + span.SetTag("request_url", sURL) + + req, err := http.NewRequestWithContext(ctx, "GET", sURL, nil) + if err != nil { + return nil, errHTTPRequestFailed.WithRootCause(err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, errHTTPRequestFailed.WithRootCause(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errHTTPRequestFailed.WithMessage(fmt.Sprintf("API returned status %d", resp.StatusCode)) + } + + var searchResponse DocsSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&searchResponse); err != nil { + return nil, errHTTPResponseInvalid.WithRootCause(err) + } + + return &searchResponse, nil +} diff --git a/internal/api/types.go b/internal/api/types.go index 22f0431c..df5e570c 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -26,6 +26,7 @@ type APIInterface interface { ChannelClient CollaboratorsClient DatastoresClient + DocsClient ExternalAuthClient FunctionDistributionClient SandboxClient From 91b5ac501948416198d412d98a21c26d48812086 Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Thu, 26 Mar 2026 14:39:41 -0700 Subject: [PATCH 6/8] remove deprecation warning --- cmd/docs/docs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 30f5d92c..ac11cc6c 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -62,7 +62,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { DisableSuggestions: true, } - cmd.Flags().BoolVar(&searchMode, "search", false, "[DEPRECATED] open Slack docs search page or search with query (use 'docs search' subcommand instead)") + cmd.Flags().BoolVar(&searchMode, "search", false, "open Slack docs search page or search with query") // Add the search subcommand cmd.AddCommand(NewSearchCommand(clients)) From 086b9e5661fe7355784d4a6f3462c7bf59b5ec5c Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Thu, 26 Mar 2026 14:48:14 -0700 Subject: [PATCH 7/8] go --- internal/api/docs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/docs.go b/internal/api/docs.go index cf18e844..66465644 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -64,7 +64,7 @@ func (c *Client) DocsSearch(ctx context.Context, query string, limit int) (*Docs defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, errHTTPRequestFailed.WithMessage(fmt.Sprintf("API returned status %d", resp.StatusCode)) + return nil, errHTTPRequestFailed.WithMessage("API returned status %d", resp.StatusCode) } var searchResponse DocsSearchResponse From 47613d012d7716c04154bba04640264726206193 Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Thu, 26 Mar 2026 15:36:47 -0700 Subject: [PATCH 8/8] test --- cmd/docs/search_test.go | 57 ++++++++++ internal/api/docs_test.go | 219 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 internal/api/docs_test.go diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go index 8fbc6af2..5736aa68 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -158,6 +158,63 @@ func Test_Docs_SearchCommand_TextJSONOutput_QueryFormats(t *testing.T) { } } +// JSON Output Tests + +// Verifies that JSON output mode correctly formats and outputs search results. +func Test_Docs_SearchCommand_JSONOutput(t *testing.T) { + response := &api.DocsSearchResponse{ + TotalResults: 1, + Limit: 20, + Results: []api.DocsSearchItem{ + { + Title: "Block Kit", + URL: "/block-kit", + }, + }, + } + + clients := setupDocsAPITest(t, response, nil) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "Block Kit", 20) + require.NoError(t, err) +} + +// Text Output Tests + +// Verifies that text output mode correctly formats and outputs search results. +func Test_Docs_SearchCommand_TextOutput(t *testing.T) { + response := &api.DocsSearchResponse{ + TotalResults: 1, + Limit: 20, + Results: []api.DocsSearchItem{ + { + Title: "Block Kit", + URL: "/block-kit", + }, + }, + } + + clients := setupDocsAPITest(t, response, nil) + err := fetchAndOutputTextResults(slackcontext.MockContext(context.Background()), clients, "Block Kit", 20) + require.NoError(t, err) +} + +// Invalid Output Format Tests + +// Verifies that invalid output format returns an error with helpful remediation. +func Test_Docs_SearchCommand_InvalidOutputFormat(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "rejects invalid output format": { + CmdArgs: []string{"search", "test", "--output=invalid"}, + ExpectedErrorStrings: []string{ + "Invalid output format", + "Use one of: text, json, browser", + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCommand(cf) + }) +} + // Browser Output Tests // Verifies that browser output mode correctly handles various query formats and opens the correct URLs. diff --git a/internal/api/docs_test.go b/internal/api/docs_test.go new file mode 100644 index 00000000..b4706756 --- /dev/null +++ b/internal/api/docs_test.go @@ -0,0 +1,219 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockRoundTripper implements http.RoundTripper for testing +type mockRoundTripper struct { + response *http.Response + err error + capturedURL string +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + m.capturedURL = req.URL.String() + return m.response, m.err +} + +// Test_DocsSearch_Success verifies successful API response parsing +func Test_DocsSearch_Success(t *testing.T) { + responseBody := DocsSearchResponse{ + TotalResults: 2, + Limit: 20, + Results: []DocsSearchItem{ + { + Title: "Block Kit", + URL: "/block-kit", + }, + { + Title: "Block Kit Elements", + URL: "/block-kit/elements", + }, + }, + } + + bodyBytes, err := json.Marshal(responseBody) + require.NoError(t, err) + + mockTransport := &mockRoundTripper{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(bodyBytes)), + Header: make(http.Header), + }, + } + + httpClient := &http.Client{ + Transport: mockTransport, + } + + client := &Client{ + httpClient: httpClient, + } + + result, err := client.DocsSearch(context.Background(), "Block Kit", 20) + require.NoError(t, err) + assert.Equal(t, 2, result.TotalResults) + assert.Equal(t, 20, result.Limit) + assert.Len(t, result.Results, 2) + assert.Equal(t, "Block Kit", result.Results[0].Title) + assert.Equal(t, "/block-kit", result.Results[0].URL) +} + +// Test_DocsSearch_EmptyResults verifies handling of empty results +func Test_DocsSearch_EmptyResults(t *testing.T) { + responseBody := DocsSearchResponse{ + TotalResults: 0, + Limit: 20, + Results: []DocsSearchItem{}, + } + + bodyBytes, err := json.Marshal(responseBody) + require.NoError(t, err) + + mockTransport := &mockRoundTripper{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(bodyBytes)), + Header: make(http.Header), + }, + } + + httpClient := &http.Client{ + Transport: mockTransport, + } + + client := &Client{ + httpClient: httpClient, + } + + result, err := client.DocsSearch(context.Background(), "nonexistent", 20) + require.NoError(t, err) + assert.Equal(t, 0, result.TotalResults) + assert.Len(t, result.Results, 0) +} + +// Test_DocsSearch_QueryEncoding verifies query parameters are properly encoded +func Test_DocsSearch_QueryEncoding(t *testing.T) { + responseBody := DocsSearchResponse{ + TotalResults: 0, + Limit: 20, + Results: []DocsSearchItem{}, + } + + bodyBytes, err := json.Marshal(responseBody) + require.NoError(t, err) + + mockTransport := &mockRoundTripper{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(bodyBytes)), + Header: make(http.Header), + }, + } + + httpClient := &http.Client{ + Transport: mockTransport, + } + + client := &Client{ + httpClient: httpClient, + } + + _, err = client.DocsSearch(context.Background(), "messages & webhooks", 5) + require.NoError(t, err) + + // Verify URL encoding + assert.Contains(t, mockTransport.capturedURL, "q=messages+%26+webhooks") + assert.Contains(t, mockTransport.capturedURL, "limit=5") +} + +// Test_DocsSearch_HTTPError verifies HTTP request errors are handled +func Test_DocsSearch_HTTPError(t *testing.T) { + mockTransport := &mockRoundTripper{ + err: fmt.Errorf("network error"), + } + + httpClient := &http.Client{ + Transport: mockTransport, + } + + client := &Client{ + httpClient: httpClient, + } + + result, err := client.DocsSearch(context.Background(), "test", 20) + assert.Error(t, err) + assert.Nil(t, result) +} + +// Test_DocsSearch_NonOKStatus verifies non-200 status codes are handled +func Test_DocsSearch_NonOKStatus(t *testing.T) { + mockTransport := &mockRoundTripper{ + response: &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewBufferString("")), + Header: make(http.Header), + }, + } + + httpClient := &http.Client{ + Transport: mockTransport, + } + + client := &Client{ + httpClient: httpClient, + } + + result, err := client.DocsSearch(context.Background(), "test", 20) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "404") +} + +// Test_DocsSearch_InvalidJSON verifies invalid JSON responses are handled +func Test_DocsSearch_InvalidJSON(t *testing.T) { + mockTransport := &mockRoundTripper{ + response: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString("{invalid json}")), + Header: make(http.Header), + }, + } + + httpClient := &http.Client{ + Transport: mockTransport, + } + + client := &Client{ + httpClient: httpClient, + } + + result, err := client.DocsSearch(context.Background(), "test", 20) + assert.Error(t, err) + assert.Nil(t, result) +}