diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 9b47c3e8..ac11cc6c 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -26,13 +26,20 @@ 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", 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,20 +47,26 @@ 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 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") + // Add the search subcommand + cmd.AddCommand(NewSearchCommand(clients)) + return cmd } @@ -61,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 @@ -77,17 +90,16 @@ func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []st 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" } @@ -95,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 new file mode 100644 index 00000000..f6944c31 --- /dev/null +++ b/cmd/docs/search.go @@ -0,0 +1,144 @@ +// 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" + "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" +) + +type searchConfig struct { + output string + limit int +} + +func makeAbsoluteURL(relativeURL string) string { + if strings.HasPrefix(relativeURL, "http") { + return relativeURL + } + return docsURL + relativeURL +} + +func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { + cfg := &searchConfig{} + + cmd := &cobra.Command{ + Use: "search ", + Short: "Search Slack developer docs", + 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 text results", + Command: "docs search \"Block Kit\"", + }, + { + Meaning: "Search docs and open results in browser", + Command: "docs search \"webhooks\" --output=browser", + }, + { + 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, cfg) + }, + } + + 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) error { + ctx := cmd.Context() + + query := strings.Join(args, " ") + + 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", + ) + } +} + +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 + } + + 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.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) + + return nil +} + +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 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) + + return nil +} diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go new file mode 100644 index 00000000..5736aa68 --- /dev/null +++ b/cmd/docs/search_test.go @@ -0,0 +1,281 @@ +// 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" + "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" +) + +// mockDocsAPI implements api.DocsClient for testing +type mockDocsAPI struct { + searchResponse *api.DocsSearchResponse + searchError error +} + +func (m *mockDocsAPI) DocsSearch(ctx context.Context, query string, limit int) (*api.DocsSearchResponse, error) { + return m.searchResponse, m.searchError +} + +func setupDocsAPITest(t *testing.T, response *api.DocsSearchResponse, err error) *shared.ClientFactory { + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + mockDocsAPI := &mockDocsAPI{ + searchResponse: response, + searchError: err, + } + + // 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 clients +} + +// docsAPIWrapper wraps APIInterface to mock DocsSearch while delegating other methods +type docsAPIWrapper struct { + api.APIInterface + mock *mockDocsAPI +} + +func (w *docsAPIWrapper) DocsSearch(ctx context.Context, query string, limit int) (*api.DocsSearchResponse, error) { + return w.mock.DocsSearch(ctx, query, limit) +} + +// Text and JSON Output Tests + +// Verifies that HTTP request errors are properly caught and returned as errors. +func Test_Docs_SearchCommand_TextJSONOutput_HTTPError(t *testing.T) { + ctx := slackcontext.MockContext(context.Background()) + clients := setupDocsAPITest(t, nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) + + err := fetchAndOutputSearchResults(ctx, clients, "test", 20) + assert.Error(t, err) +} + +// Verifies that HTTP errors from the API are properly caught and returned as errors. +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) +} + +// Verifies that malformed JSON responses are caught during parsing and returned as errors. +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) +} + +// Verifies that valid JSON responses with no results are correctly parsed and output without errors. +func Test_Docs_SearchCommand_TextJSONOutput_EmptyResults(t *testing.T) { + response := &api.DocsSearchResponse{ + TotalResults: 0, + Results: []api.DocsSearchItem{}, + Limit: 20, + } + + 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_TextJSONOutput_QueryFormats(t *testing.T) { + response := &api.DocsSearchResponse{ + TotalResults: 2, + Limit: 20, + Results: []api.DocsSearchItem{ + { + Title: "Block Kit", + URL: "/block-kit", + }, + { + Title: "Block Kit Elements", + URL: "/block-kit/elements", + }, + }, + } + + tests := map[string]struct { + query string + limit int + }{ + "single word query": { + query: "messaging", + limit: 20, + }, + "multiple words": { + query: "socket mode", + limit: 20, + }, + "special characters": { + query: "messages & webhooks", + limit: 20, + }, + "custom limit": { + query: "Block Kit", + limit: 5, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + clients := setupDocsAPITest(t, response, nil) + err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, tc.query, tc.limit) + require.NoError(t, err) + }) + } +} + +// 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. +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) + }) +} 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..66465644 --- /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("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/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) +} 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