From 9e1e0a1be53def17296c4cc8cb4715c441a10c4b Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 19 Jul 2026 13:57:36 -0700 Subject: [PATCH] feat(search): serve glean search from the platform API by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag path calls POST /api/search and falls back to the classic API (with a one-time warning) when the tenant gate is off; GLEAN_LEGACY_APIS=1 forces the classic path. Platform responses are emitted as-is (snake_case, never cleansed); CleanseSearchResponse and --raw now apply only to legacy responses. Explicitly-set flags without a platform equivalent produce a stderr note instead of silently doing nothing. --json payloads are platform-shaped and never auto-fall-back: the body is coupled to the endpoint, so gate-closed produces an actionable error naming GLEAN_LEGACY_APIS=1 (under which the payload is parsed as the classic shape, exactly as before). MockTransport gains per-path Routes so tests can drive the platform 404 → legacy 200 fallback sequence. Co-Authored-By: Claude Fable 5 --- cmd/schema.go | 26 ++-- cmd/search.go | 161 ++++++++++++++++++---- cmd/search_test.go | 217 +++++++++++++++++++++++++++--- internal/testutils/mock_client.go | 23 +++- 4 files changed, 362 insertions(+), 65 deletions(-) diff --git a/cmd/schema.go b/cmd/schema.go index b64dc04..a28f37c 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -12,28 +12,28 @@ func init() { // Register schemas for all commands at startup. schema.Register(schema.CommandSchema{ Command: "search", - Description: "Search for content in your Glean instance. Results are JSON.", + Description: "Search for content in your Glean instance via the platform API (POST /api/search); responses use its snake_case shape. Falls back to the classic API with a warning when the platform API is not enabled; GLEAN_LEGACY_APIS=1 forces the classic API. Results are JSON.", Flags: map[string]schema.FlagSchema{ - "--json": {Type: "string", Description: "Complete JSON request body (overrides individual flags)", Required: false}, + "--json": {Type: "string", Description: "Complete JSON request body in the platform shape: query, page_size, cursor, datasources, datasource_instances, filters, time_range (overrides individual flags). With GLEAN_LEGACY_APIS=1, parsed as the classic shape", Required: false}, "--query": {Type: "string", Description: "Search query (positional arg)", Required: true}, "--page-size": {Type: "integer", Default: 10, Description: "Number of results per page"}, - "--max-snippet-size": {Type: "integer", Default: 0, Description: "Maximum snippet size in characters"}, + "--max-snippet-size": {Type: "integer", Default: 0, Description: "Maximum snippet size in characters (legacy API only)"}, "--timeout": {Type: "integer", Default: 30000, Description: "Request timeout in milliseconds"}, - "--disable-spellcheck": {Type: "boolean", Default: false, Description: "Disable spellcheck"}, + "--disable-spellcheck": {Type: "boolean", Default: false, Description: "Disable spellcheck (legacy API only)"}, "--datasource": {Type: "[]string", Description: "Filter by datasource (repeatable)"}, "--type": {Type: "[]string", Description: "Filter by document type (repeatable)"}, - "--tab": {Type: "[]string", Description: "Filter by result tab IDs (repeatable)"}, - "--response-hints": {Type: "[]string", Default: []string{"RESULTS", "QUERY_METADATA"}, Description: "Response hints"}, - "--facet-bucket-size": {Type: "integer", Default: 10, Description: "Maximum facet buckets per result"}, - "--disable-query-autocorrect": {Type: "boolean", Default: false, Description: "Disable automatic query corrections"}, - "--fetch-all-datasource-counts": {Type: "boolean", Default: false, Description: "Return counts for all datasources"}, - "--query-overrides-facet-filters": {Type: "boolean", Default: false, Description: "Allow query operators to override facet filters"}, - "--return-llm-content": {Type: "boolean", Default: false, Description: "Return expanded LLM-friendly content"}, + "--tab": {Type: "[]string", Description: "Filter by result tab IDs (repeatable, legacy API only)"}, + "--response-hints": {Type: "[]string", Default: []string{"RESULTS", "QUERY_METADATA"}, Description: "Response hints (legacy API only)"}, + "--facet-bucket-size": {Type: "integer", Default: 10, Description: "Maximum facet buckets per result (legacy API only)"}, + "--disable-query-autocorrect": {Type: "boolean", Default: false, Description: "Disable automatic query corrections (legacy API only)"}, + "--fetch-all-datasource-counts": {Type: "boolean", Default: false, Description: "Return counts for all datasources (legacy API only)"}, + "--query-overrides-facet-filters": {Type: "boolean", Default: false, Description: "Allow query operators to override facet filters (legacy API only)"}, + "--return-llm-content": {Type: "boolean", Default: false, Description: "Return expanded LLM-friendly content (legacy API only)"}, "--output": {Type: "enum", Enum: []string{"json", "ndjson", "text"}, Default: "json", Description: "Output format"}, "--dry-run": {Type: "boolean", Default: false, Description: "Print request body without sending"}, }, - Example: `glean search "vacation policy" | jq '.results[].document.title' -glean search --json '{"query":"Q1 reports","pageSize":5,"datasources":["confluence"]}' | jq .`, + Example: `glean search "vacation policy" | jq '.results[].title' +glean search --json '{"query":"Q1 reports","page_size":5,"datasources":["confluence"]}' | jq .`, }) schema.Register(schema.CommandSchema{ diff --git a/cmd/search.go b/cmd/search.go index 00bba7e..0d6c21d 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "encoding/json" "fmt" "io" @@ -9,15 +10,42 @@ import ( "github.com/gleanwork/api-client-go/models/components" gleanClient "github.com/gleanwork/glean-cli/internal/client" "github.com/gleanwork/glean-cli/internal/output" + "github.com/gleanwork/glean-cli/internal/platform" "github.com/gleanwork/glean-cli/internal/search" "github.com/spf13/cobra" ) +// searchTextFn renders whichever search response shape the request produced: +// the platform response (default) or the classic response (legacy fallback). func searchTextFn(w io.Writer, v any) error { - resp, ok := v.(*components.SearchResponse) - if !ok { + switch resp := v.(type) { + case *components.PlatformSearchResponse: + return platformSearchTextFn(w, resp) + case *components.SearchResponse: + return legacySearchTextFn(w, resp) + default: return output.WriteJSON(w, v) } +} + +func platformSearchTextFn(w io.Writer, resp *components.PlatformSearchResponse) error { + var rows [][]string + for _, r := range resp.Results { + snippet := "" + if len(r.Snippets) > 0 { + snippet = r.Snippets[0] + } + rows = append(rows, []string{ + output.Truncate(r.Title, 50), + r.Datasource, + r.URL, + output.Truncate(snippet, 60), + }) + } + return output.WriteTable(w, []string{"TITLE", "SOURCE", "URL", "SNIPPET"}, rows) +} + +func legacySearchTextFn(w io.Writer, resp *components.SearchResponse) error { var rows [][]string for _, r := range resp.Results { title, source, url, snippet := "", "", "", "" @@ -70,14 +98,19 @@ func NewCmdSearch() *cobra.Command { Short: "Search for content in your Glean instance", Long: `Search for content in your Glean instance. +Search is served by the platform API (POST /api/search) and results use its +snake_case response shape. If the platform API is not enabled on your +instance, the command falls back to the classic API with a warning; set +GLEAN_LEGACY_APIS=1 to use the classic API directly. + Results are written as JSON to stdout by default, making the output easy to pipe to jq or other tools. Example: glean search "vacation policy" - glean search "vacation policy" | jq '.results[].document.title' - glean search --json '{"query":"Q1 reports","pageSize":5}' | jq . - glean search --output ndjson "engineering docs" | head -3 | jq .document.title + glean search "vacation policy" | jq '.results[].title' + glean search --json '{"query":"Q1 reports","page_size":5}' | jq . + glean search --output ndjson "engineering docs" | head -3 | jq .title glean search --dry-run "test"`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -85,9 +118,14 @@ Example: return fmt.Errorf("requires a query argument or --json payload") } - // --json path: parse directly into SearchRequest + // --json path: the payload shape is coupled to the endpoint, so a + // user-authored body is never replayed against the other surface. + // Platform shape by default; classic shape under GLEAN_LEGACY_APIS=1. if jsonPayload != "" { - var req components.SearchRequest + if platform.Legacy() { + return runLegacyJSONSearch(cmd, jsonPayload, dryRun, outputFormat, raw) + } + var req components.PlatformSearchRequest if err := json.Unmarshal([]byte(jsonPayload), &req); err != nil { return fmt.Errorf("invalid --json payload: %w", err) } @@ -98,23 +136,14 @@ Example: if err != nil { return err } - resp, err := sdk.Client.Search.Query(cmd.Context(), req, nil) + resp, err := sdk.Search.Query(cmd.Context(), req) if err != nil { - return fmt.Errorf("search request failed: %w", err) - } - // Text output operates on the raw SDK struct directly; - // cleansing is redundant since the table selects its own fields. - if outputFormat == output.OutputText { - return output.WriteFormatted(cmd.OutOrStdout(), resp.SearchResponse, outputFormat, searchTextFn) - } - var result any = resp.SearchResponse - if !raw { - result, err = output.CleanseSearchResponse(result) - if err != nil { - return err + if platform.IsGateClosed(err) { + return platform.GateClosedErr("/api/search", err) } + return fmt.Errorf("search request failed: %w", err) } - return output.WriteFormatted(cmd.OutOrStdout(), result, outputFormat, searchTextFn) + return output.WriteFormatted(cmd.OutOrStdout(), resp.PlatformSearchResponse, outputFormat, searchTextFn) } // flag-based path @@ -153,31 +182,46 @@ Example: opts.Query = args[0] if dryRun { - return output.WriteJSON(cmd.OutOrStdout(), search.BuildSearchRequest(opts)) + if platform.Legacy() { + return output.WriteJSON(cmd.OutOrStdout(), search.BuildSearchRequest(opts)) + } + return output.WriteJSON(cmd.OutOrStdout(), search.BuildPlatformSearchRequest(opts)) } sdk, err := gleanClient.NewFromConfig() if err != nil { return err } - resp, err := search.RunSearchSDK(cmd.Context(), opts, sdk) + if !platform.Legacy() { + if ignored := platformIgnoredFlags(cmd); len(ignored) > 0 { + fmt.Fprintf(cmd.ErrOrStderr(), + "Note: flags ignored by platform search (legacy-only): %s\n", + strings.Join(ignored, ", ")) + } + } + + result, viaLegacy, err := platform.Run(cmd.Context(), cmd.ErrOrStderr(), "/api/search", + func(ctx context.Context) (any, error) { return search.RunPlatformSearch(ctx, opts, sdk) }, + func(ctx context.Context) (any, error) { return search.RunSearchSDK(ctx, opts, sdk) }, + ) if err != nil { return err } // Text output operates on the raw SDK struct directly; // cleansing is redundant since the table selects its own fields. if outputFormat == output.OutputText { - return output.WriteFormatted(cmd.OutOrStdout(), resp, outputFormat, searchTextFn) + return output.WriteFormatted(cmd.OutOrStdout(), result, outputFormat, searchTextFn) } - var result any = resp - if !raw { + // The classic response carries superfluous properties and is + // cleansed unless --raw; platform responses are emitted as-is. + if viaLegacy && !raw { result, err = output.CleanseSearchResponse(result) if err != nil { return err } } if fields != "" { - if !raw { + if viaLegacy && !raw { if stripped := output.WarnStrippedFields(fields); len(stripped) > 0 { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: field(s) %s not available in cleansed output (use --raw for the full response)\n", @@ -190,11 +234,11 @@ Example: }, } - cmd.Flags().StringVar(&jsonPayload, "json", "", "Complete JSON request body (overrides all other flags)") + cmd.Flags().StringVar(&jsonPayload, "json", "", "Complete JSON request body in the platform shape (overrides all other flags); with GLEAN_LEGACY_APIS=1, parsed as the classic shape") cmd.Flags().StringVar(&outputFormat, "output", "json", "Output format: json, ndjson, or text") cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated dot-path fields to include (e.g. results.document.title,results.document.url). Results where all projected fields are missing appear as {}") cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print the request body without sending it") - cmd.Flags().BoolVar(&raw, "raw", false, "Output the full SDK response without cleansing") + cmd.Flags().BoolVar(&raw, "raw", false, "Output the full SDK response without cleansing (classic API responses only; platform responses are never cleansed)") cmd.Flags().IntVar(&opts.PageSize, "page-size", 10, "Number of results per page") cmd.Flags().IntVar(&opts.MaxSnippetSize, "max-snippet-size", 0, "Maximum size of snippets") cmd.Flags().IntVar(&opts.TimeoutMillis, "timeout", 30000, "Request timeout in milliseconds") @@ -213,3 +257,62 @@ Example: return cmd } + +// platformIgnoredFlags returns the explicitly-set search flags that have no +// platform API equivalent, so users learn their flag did nothing rather than +// silently getting unfiltered results. +func platformIgnoredFlags(cmd *cobra.Command) []string { + legacyOnly := []string{ + "max-snippet-size", + "disable-spellcheck", + "tab", + "response-hints", + "facet-bucket-size", + "disable-query-autocorrect", + "fetch-all-datasource-counts", + "query-overrides-facet-filters", + "return-llm-content", + } + var ignored []string + for _, name := range legacyOnly { + if cmd.Flags().Changed(name) { + ignored = append(ignored, "--"+name) + } + } + return ignored +} + +// runLegacyJSONSearch handles --json under GLEAN_LEGACY_APIS=1: the payload +// is parsed as the classic /rest/api/v1/search request shape and the +// response is cleansed unless --raw, exactly as before the platform +// migration. +func runLegacyJSONSearch(cmd *cobra.Command, jsonPayload string, dryRun bool, outputFormat string, raw bool) error { + var req components.SearchRequest + if err := json.Unmarshal([]byte(jsonPayload), &req); err != nil { + return fmt.Errorf("invalid --json payload: %w", err) + } + if dryRun { + return output.WriteJSON(cmd.OutOrStdout(), req) + } + sdk, err := gleanClient.NewFromConfig() + if err != nil { + return err + } + resp, err := sdk.Client.Search.Query(cmd.Context(), req, nil) + if err != nil { + return fmt.Errorf("search request failed: %w", err) + } + // Text output operates on the raw SDK struct directly; + // cleansing is redundant since the table selects its own fields. + if outputFormat == output.OutputText { + return output.WriteFormatted(cmd.OutOrStdout(), resp.SearchResponse, outputFormat, searchTextFn) + } + var result any = resp.SearchResponse + if !raw { + result, err = output.CleanseSearchResponse(result) + if err != nil { + return err + } + } + return output.WriteFormatted(cmd.OutOrStdout(), result, outputFormat, searchTextFn) +} diff --git a/cmd/search_test.go b/cmd/search_test.go index 05f5d95..ecabf59 100644 --- a/cmd/search_test.go +++ b/cmd/search_test.go @@ -5,13 +5,41 @@ import ( "encoding/json" "testing" + "github.com/gleanwork/glean-cli/internal/platform" "github.com/gleanwork/glean-cli/internal/testutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// searchResponse builds a minimal Glean SearchResponse JSON body with the given document titles. -func searchResponse(titles ...string) []byte { +const ( + platformSearchPath = "/api/search" + legacySearchPath = "/rest/api/v1/search" +) + +// platformSearchResponse builds a minimal platform search response body with +// the given result titles. +func platformSearchResponse(titles ...string) []byte { + type result struct { + URL string `json:"url"` + Title string `json:"title"` + Datasource string `json:"datasource"` + Snippets []string `json:"snippets,omitempty"` + } + rs := []result{} + for _, title := range titles { + rs = append(rs, result{URL: "https://docs.example.com", Title: title, Datasource: "gdrive"}) + } + b, _ := json.Marshal(map[string]any{ + "results": rs, + "has_more": false, + "next_cursor": nil, + "request_id": "req-test", + }) + return b +} + +// legacySearchResponse builds a minimal classic SearchResponse JSON body with the given document titles. +func legacySearchResponse(titles ...string) []byte { type doc struct { Title string `json:"title"` } @@ -28,8 +56,26 @@ func searchResponse(titles ...string) []byte { return b } -func TestSearchCommand_BasicQuery(t *testing.T) { - _, cleanup := testutils.SetupTestWithResponse(t, searchResponse("Vacation Policy", "Holiday Guide")) +// gateClosedResponse is the hidden-404 problem detail the platform stack +// returns when experimental endpoints are not enabled for the tenant. +func gateClosedResponse() testutils.MockResponse { + return testutils.MockResponse{ + StatusCode: 404, + ContentType: "application/problem+json", + Body: []byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"resource not found","code":"resource_not_found","request_id":"req-gate"}`), + } +} + +// usePlatformAPIs pins the test to platform-first behavior regardless of the +// developer's own GLEAN_LEGACY_APIS setting. +func usePlatformAPIs(t *testing.T) { + t.Helper() + t.Setenv(platform.EnvLegacy, "") +} + +func TestSearchCommand_BasicQuery_UsesPlatformAPI(t *testing.T) { + usePlatformAPIs(t) + mock, cleanup := testutils.SetupTestWithResponse(t, platformSearchResponse("Vacation Policy", "Holiday Guide")) defer cleanup() root := NewCmdRoot() @@ -39,6 +85,69 @@ func TestSearchCommand_BasicQuery(t *testing.T) { err := root.Execute() require.NoError(t, err) assert.Contains(t, buf.String(), "Vacation Policy") + + require.NotEmpty(t, mock.Requests) + assert.Equal(t, platformSearchPath, mock.Requests[0].URL.Path) +} + +func TestSearchCommand_PlatformOutputNotCleansed(t *testing.T) { + usePlatformAPIs(t) + _, cleanup := testutils.SetupTestWithResponse(t, platformSearchResponse("Doc")) + defer cleanup() + + root := NewCmdRoot() + buf := &bytes.Buffer{} + root.SetOut(buf) + root.SetArgs([]string{"search", "doc"}) + require.NoError(t, root.Execute()) + + // Platform responses are emitted as-is: snake_case fields survive. + assert.Contains(t, buf.String(), "request_id") + assert.Contains(t, buf.String(), "has_more") +} + +func TestSearchCommand_GateClosedFallsBackToLegacy(t *testing.T) { + usePlatformAPIs(t) + mock, cleanup := testutils.SetupTestWithResponse(t, nil) + defer cleanup() + mock.Routes = map[string]testutils.MockResponse{ + platformSearchPath: gateClosedResponse(), + legacySearchPath: {Body: legacySearchResponse("Legacy Doc")}, + } + + root := NewCmdRoot() + buf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + root.SetOut(buf) + root.SetErr(errBuf) + root.SetArgs([]string{"search", "doc"}) + require.NoError(t, root.Execute()) + + assert.Contains(t, buf.String(), "Legacy Doc") + assert.Contains(t, errBuf.String(), "falling back to the legacy API") + + require.Len(t, mock.Requests, 2, "platform attempt then legacy fallback") + assert.Equal(t, platformSearchPath, mock.Requests[0].URL.Path) + assert.Equal(t, legacySearchPath, mock.Requests[1].URL.Path) +} + +func TestSearchCommand_LegacyEnvSkipsPlatform(t *testing.T) { + t.Setenv(platform.EnvLegacy, "1") + mock, cleanup := testutils.SetupTestWithResponse(t, legacySearchResponse("Legacy Doc")) + defer cleanup() + + root := NewCmdRoot() + buf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + root.SetOut(buf) + root.SetErr(errBuf) + root.SetArgs([]string{"search", "doc"}) + require.NoError(t, root.Execute()) + + assert.Contains(t, buf.String(), "Legacy Doc") + assert.NotContains(t, errBuf.String(), "Warning") + require.Len(t, mock.Requests, 1) + assert.Equal(t, legacySearchPath, mock.Requests[0].URL.Path) } func TestSearchCommand_MissingQuery(t *testing.T) { @@ -52,6 +161,7 @@ func TestSearchCommand_MissingQuery(t *testing.T) { } func TestSearchCommand_DryRun(t *testing.T) { + usePlatformAPIs(t) // Dry-run should not require credentials — SDK init is deferred until after the dry-run check. root := NewCmdRoot() buf := &bytes.Buffer{} @@ -62,42 +172,106 @@ func TestSearchCommand_DryRun(t *testing.T) { var req map[string]any require.NoError(t, json.Unmarshal(buf.Bytes(), &req), "dry-run output must be valid JSON") assert.Equal(t, "test query", req["query"]) + assert.Equal(t, float64(10), req["page_size"], "dry-run shows the platform request shape") } -func TestSearchCommand_JSONPayload(t *testing.T) { - _, cleanup := testutils.SetupTestWithResponse(t, searchResponse("Engineering Docs")) +func TestSearchCommand_DryRun_LegacyEnv(t *testing.T) { + t.Setenv(platform.EnvLegacy, "1") + root := NewCmdRoot() + buf := &bytes.Buffer{} + root.SetOut(buf) + root.SetArgs([]string{"search", "--dry-run", "test query"}) + require.NoError(t, root.Execute()) + var req map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &req)) + assert.Equal(t, "test query", req["query"]) + assert.Contains(t, buf.String(), "pageSize", "legacy dry-run shows the classic camelCase shape") +} + +func TestSearchCommand_JSONPayload_Platform(t *testing.T) { + usePlatformAPIs(t) + mock, cleanup := testutils.SetupTestWithResponse(t, platformSearchResponse("Engineering Docs")) defer cleanup() root := NewCmdRoot() buf := &bytes.Buffer{} root.SetOut(buf) - root.SetArgs([]string{"search", "--json", `{"query":"engineering","pageSize":5}`}) + root.SetArgs([]string{"search", "--json", `{"query":"engineering","page_size":5}`}) err := root.Execute() require.NoError(t, err) assert.Contains(t, buf.String(), "Engineering Docs") + require.NotEmpty(t, mock.Requests) + assert.Equal(t, platformSearchPath, mock.Requests[0].URL.Path) +} + +func TestSearchCommand_JSONPayload_GateClosedErrors(t *testing.T) { + usePlatformAPIs(t) + mock, cleanup := testutils.SetupTestWithResponse(t, nil) + defer cleanup() + mock.Routes = map[string]testutils.MockResponse{ + platformSearchPath: gateClosedResponse(), + } + + root := NewCmdRoot() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"search", "--json", `{"query":"engineering"}`}) + err := root.Execute() + require.Error(t, err, "user-authored payloads must not silently fall back") + assert.Contains(t, err.Error(), platform.EnvLegacy) + require.Len(t, mock.Requests, 1, "no legacy retry for --json payloads") +} + +func TestSearchCommand_JSONPayload_LegacyEnv(t *testing.T) { + t.Setenv(platform.EnvLegacy, "1") + mock, cleanup := testutils.SetupTestWithResponse(t, legacySearchResponse("Engineering Docs")) + defer cleanup() + + root := NewCmdRoot() + buf := &bytes.Buffer{} + root.SetOut(buf) + root.SetArgs([]string{"search", "--json", `{"query":"engineering","pageSize":5}`}) + require.NoError(t, root.Execute()) + assert.Contains(t, buf.String(), "Engineering Docs") + require.NotEmpty(t, mock.Requests) + assert.Equal(t, legacySearchPath, mock.Requests[0].URL.Path) +} + +func TestSearchCommand_IgnoredFlagsNote(t *testing.T) { + usePlatformAPIs(t) + _, cleanup := testutils.SetupTestWithResponse(t, platformSearchResponse("Doc")) + defer cleanup() + + root := NewCmdRoot() + errBuf := &bytes.Buffer{} + root.SetOut(&bytes.Buffer{}) + root.SetErr(errBuf) + root.SetArgs([]string{"search", "--facet-bucket-size", "5", "--return-llm-content", "doc"}) + require.NoError(t, root.Execute()) + + assert.Contains(t, errBuf.String(), "flags ignored by platform search") + assert.Contains(t, errBuf.String(), "--facet-bucket-size") + assert.Contains(t, errBuf.String(), "--return-llm-content") } func TestSearchCommand_OutputText(t *testing.T) { + usePlatformAPIs(t) body, _ := json.Marshal(map[string]any{ "results": []map[string]any{ { - "document": map[string]any{ - "title": "Vacation Policy", - "datasource": "gdrive", - "url": "https://docs.example.com/vacation", - }, - "snippets": []map[string]any{ - {"text": "All employees are entitled to 20 days PTO"}, - }, + "title": "Vacation Policy", + "datasource": "gdrive", + "url": "https://docs.example.com/vacation", + "snippets": []string{"All employees are entitled to 20 days PTO"}, }, { - "document": map[string]any{ - "title": "Holiday Guide", - "datasource": "confluence", - "url": "https://wiki.example.com/holidays", - }, + "title": "Holiday Guide", + "datasource": "confluence", + "url": "https://wiki.example.com/holidays", }, }, + "has_more": false, + "request_id": "req-test", }) _, cleanup := testutils.SetupTestWithResponse(t, body) defer cleanup() @@ -121,7 +295,8 @@ func TestSearchCommand_OutputText(t *testing.T) { } func TestSearchCommand_OutputNDJSON(t *testing.T) { - _, cleanup := testutils.SetupTestWithResponse(t, searchResponse("Doc A", "Doc B")) + usePlatformAPIs(t) + _, cleanup := testutils.SetupTestWithResponse(t, platformSearchResponse("Doc A", "Doc B")) defer cleanup() root := NewCmdRoot() diff --git a/internal/testutils/mock_client.go b/internal/testutils/mock_client.go index 12dc12d..4ba76d6 100644 --- a/internal/testutils/mock_client.go +++ b/internal/testutils/mock_client.go @@ -12,6 +12,17 @@ import ( "github.com/gleanwork/glean-cli/internal/config" ) +// MockResponse is a canned response for a specific request path, used via +// MockTransport.Routes to give the platform and legacy endpoints different +// behavior (e.g. platform 404 → legacy 200 fallback tests). +type MockResponse struct { + // StatusCode defaults to 200 when zero + StatusCode int + Body []byte + // ContentType defaults to the request's Accept header (see Do) + ContentType string +} + // MockTransport implements http.RoundTripper (the Do method expected by glean.HTTPClient). // It returns a predefined response body for every request, making it easy to test // command output without making real network calls. @@ -24,6 +35,8 @@ type MockTransport struct { StatusCode int // ContentType defaults to "application/json" when empty ContentType string + // Routes overrides the response per request URL path when the path is present + Routes map[string]MockResponse // Requests records all requests received for inspection Requests []*http.Request } @@ -33,13 +46,19 @@ func (m *MockTransport) Do(req *http.Request) (*http.Response, error) { if m.Err != nil { return nil, m.Err } + body := m.Body statusCode := m.StatusCode + contentType := m.ContentType + if route, ok := m.Routes[req.URL.Path]; ok { + body = route.Body + statusCode = route.StatusCode + contentType = route.ContentType + } if statusCode == 0 { statusCode = 200 } // Mirror the Accept header the SDK sends so the SDK can parse its own response. // CreateStream sets Accept: text/plain; Create sets Accept: application/json. - contentType := m.ContentType if contentType == "" { if accept := req.Header.Get("Accept"); accept != "" { contentType = accept @@ -50,7 +69,7 @@ func (m *MockTransport) Do(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: statusCode, Header: http.Header{"Content-Type": []string{contentType}}, - Body: io.NopCloser(bytes.NewReader(m.Body)), + Body: io.NopCloser(bytes.NewReader(body)), }, nil }