diff --git a/.changeset/first-version.md b/.changeset/first-version.md index b35cc61..6c1f9b9 100644 --- a/.changeset/first-version.md +++ b/.changeset/first-version.md @@ -8,7 +8,7 @@ First version of `meili`, the Meilisearch CLI built for coding agents. - Documents: `meili get documents --index `, narrowed by `--ids` or `--filter`; `meili add documents --index --file ` and `meili update documents --index --file `, which read the documents from a file, from stdin with `--file -` or from `--data`; `meili delete documents --index (--ids | --filter | --all) --yes`. - Indexes: `meili get indexes [--id ]`; `meili add indexes --id [--primary-key ]`, `meili update indexes --id [--primary-key ] [--new-id ]`, which renames the index, and `meili delete indexes --id --yes`. - Tasks: `meili get tasks`, narrowed by `--ids`, `--statuses`, `--types`, `--index-ids`, `--batch-ids`, `--canceled-by` and the date filters of the route, paging with `--limit` and `--from` and following the `next` cursor with `--all`; `meili cancel tasks` and `meili delete tasks --yes`, which take the same filters and answer a task. -- Search: `meili search --index --q `, on `POST /indexes/{index_uid}/search`, with every non-experimental search parameter of the route as a flag, from `--filter` and `--sort` to `--hybrid` and `--vector`; `--data` or `--file` carries a whole search body, which a flag of the same field overrides. +- Search: `meili search --index --q `, on `POST /indexes/{index_uid}/search`, with every non-experimental search parameter of the route as a flag, from `--filter` and `--sort` to `--hybrid` and `--vector`; `--data` or `--file` carries a whole search body, which a flag of the same field overrides. `meili multi-search --queries ''`, on `POST /multi-search`, runs several queries in one request, each naming its index, answered side by side or merged into one ranked list with `--federation`. - Settings: `meili get settings --index `, which returns every setting with its current value or its default; `meili update settings --index `, with one flag per setting, from `--searchable-attributes` and `--stop-words` to `--typo-tolerance` and `--embedders`, where a value of `null` resets that one setting and `--data` or `--file` carries a whole settings body; `meili delete settings --index --yes`, which resets them all. - `meili schema` prints the index of the command tree as JSON, one entry per command with its routes, and `meili schema --command "get indexes"` prints one command in full, with its key actions and whether it returns a task. - Connection through `--project-url` and `--project-api-key`, or `MEILI_PROJECT_URL` and `MEILI_PROJECT_API_KEY`; `MEILI_MANAGEMENT_TOKEN` and `MEILI_MANAGEMENT_URL` for the Management API. diff --git a/docs/contract/commands.md b/docs/contract/commands.md index 5637c9c..3422e69 100644 --- a/docs/contract/commands.md +++ b/docs/contract/commands.md @@ -46,6 +46,7 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i | `PUT /indexes/{index_uid}/documents` | `meili update documents --index --file ` | | `POST …/documents/delete-batch`, `POST …/documents/delete`, `DELETE …/documents` | `meili delete documents --index (--ids \| --filter \| --all) --yes` | | `POST /indexes/{index_uid}/search` | `meili search --index --q ` | +| `POST /multi-search` | `meili multi-search --queries [--federation ]` | | `GET /indexes/{index_uid}/settings` | `meili get settings --index ` | | `PATCH /indexes/{index_uid}/settings` | `meili update settings --index -- ` | | `DELETE /indexes/{index_uid}/settings` | `meili delete settings --index --yes` | diff --git a/oclif.manifest.json b/oclif.manifest.json index ac57a38..ef5f670 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -4217,6 +4217,227 @@ "tasks.js" ] }, + "multi-search": { + "aliases": [], + "args": {}, + "description": "Run several search queries in one request, each naming its index in --queries, returned side by side or merged into one ranked list with --federation.", + "examples": [ + "<%= config.bin %> <%= command.id %> --queries '[{\"indexUid\":\"movies\",\"q\":\"nosferatu\"},{\"indexUid\":\"books\",\"q\":\"nosferatu\"}]'", + "<%= config.bin %> <%= command.id %> --queries '[{\"indexUid\":\"movies\",\"q\":\"nosferatu\"}]' --output json", + "<%= config.bin %> <%= command.id %> --queries '[{\"indexUid\":\"movies\",\"q\":\"horror\"},{\"indexUid\":\"books\",\"q\":\"horror\"}]' --federation '{\"limit\":5}'", + "cat queries.json | <%= config.bin %> <%= command.id %> --file -" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "queries": { + "description": "The search queries as a JSON array, each one an index to search and its search parameters: `[{\"indexUid\":\"movies\",\"q\":\"nosferatu\"}]`.", + "name": "queries", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "federation": { + "description": "Merge the hits of every query into one ranked list, as JSON: `{}` for the defaults, `{\"limit\":5}` to narrow it. Without it each query answers its own envelope.", + "name": "federation", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "exclusive": [ + "data" + ], + "name": "file", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "name": "data", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "multi-search", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "POST /multi-search" + ], + "keyActions": [ + "search" + ], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "multi-search.js" + ] + }, "schema": { "aliases": [], "args": {}, diff --git a/src/commands/multi-search.ts b/src/commands/multi-search.ts new file mode 100644 index 0000000..f1ed0e4 --- /dev/null +++ b/src/commands/multi-search.ts @@ -0,0 +1,48 @@ +import { Flags } from "@oclif/core"; +import type { MultiSearchParams } from "meilisearch"; +import { type Api, BaseCommand } from "../lib/base-command.js"; +import { readJsonObjectBody } from "../lib/input.js"; +import { asJson } from "../lib/values.js"; + +export default class MultiSearch extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["POST /multi-search"]; + + static keyActions = ["search"]; + + static override description = + "Run several search queries in one request, each naming its index in --queries, returned side by side or merged into one ranked list with --federation."; + + static override examples = [ + '<%= config.bin %> <%= command.id %> --queries \'[{"indexUid":"movies","q":"nosferatu"},{"indexUid":"books","q":"nosferatu"}]\'', + '<%= config.bin %> <%= command.id %> --queries \'[{"indexUid":"movies","q":"nosferatu"}]\' --output json', + '<%= config.bin %> <%= command.id %> --queries \'[{"indexUid":"movies","q":"horror"},{"indexUid":"books","q":"horror"}]\' --federation \'{"limit":5}\'', + "cat queries.json | <%= config.bin %> <%= command.id %> --file -", + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + queries: Flags.string({ + description: + 'The search queries as a JSON array, each one an index to search and its search parameters: `[{"indexUid":"movies","q":"nosferatu"}]`.', + helpValue: "", + }), + federation: Flags.string({ + description: + 'Merge the hits of every query into one ranked list, as JSON: `{}` for the defaults, `{"limit":5}` to narrow it. Without it each query answers its own envelope.', + helpValue: "", + }), + ...BaseCommand.bodyInputFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(MultiSearch); + // The body of --file or --data first, then the flags, which override a field of it (rule 8). + const params = { ...readJsonObjectBody(flags) }; + if (flags.queries !== undefined) params.queries = asJson(flags.queries); + if (flags.federation !== undefined) params.federation = asJson(flags.federation); + + await this.output(await this.meilisearch.multiSearch(params as MultiSearchParams)); + } +} diff --git a/test/commands/__snapshots__/multi-search.test.ts.snap b/test/commands/__snapshots__/multi-search.test.ts.snap new file mode 100644 index 0000000..7095fb5 --- /dev/null +++ b/test/commands/__snapshots__/multi-search.test.ts.snap @@ -0,0 +1,65 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili multi-search > passes the API error of a missing index through, which the route answers 400 1`] = ` +{ + "exitCode": 1, + "stderr": "{"error":{"code":"index_not_found","type":"invalid_request","message":"Inside \`.queries[0]\`: Index \`missing\` not found.","link":"https://docs.meilisearch.com/errors#index_not_found"}} +", + "stdout": "", +} +`; + +exports[`meili multi-search > prints its help without any network call 1`] = ` +"Run several search queries in one request, each naming its index in --queries, returned side by side or merged into one ranked list with --federation. + +USAGE + $ meili multi-search [-o ] [--debug] [--project-url ] + [--project-api-key ] [--project ] [--queries ] + [--federation ] [--file | --data ] + +FLAGS + -o, --output= json (default): one document, the API body as it + is, indented in a terminal, compact when piped. + ndjson: one item per line, list commands only, + streams with --all. + --data= Inline JSON body. + --debug Print every HTTP request and response on stderr, + secrets redacted. + --federation= Merge the hits of every query into one ranked + list, as JSON: \`{}\` for the defaults, + \`{"limit":5}\` to narrow it. Without it each query + answers its own envelope. + --file= Read the body from a file. \`-\` reads from stdin. + --project= A project of the config file. Cannot be combined + with --project-url. Required when the file has + several projects. + --project-api-key= Project API key. \`-\` reads it from stdin. + --project-url= Project URL, the Meilisearch instance to talk to. + --queries= The search queries as a JSON array, each one an + index to search and its search parameters: + \`[{"indexUid":"movies","q":"nosferatu"}]\`. + +DESCRIPTION + Run several search queries in one request, each naming its index in --queries, + returned side by side or merged into one ranked list with --federation. + +EXAMPLES + $ meili multi-search --queries '[{"indexUid":"movies","q":"nosferatu"},{"indexUid":"books","q":"nosferatu"}]' + + $ meili multi-search --queries '[{"indexUid":"movies","q":"nosferatu"}]' --output json + + $ meili multi-search --queries '[{"indexUid":"movies","q":"horror"},{"indexUid":"books","q":"horror"}]' --federation '{"limit":5}' + + cat queries.json | meili multi-search --file - + +" +`; + +exports[`meili multi-search > prints the API envelope as it is in JSON 1`] = ` +{ + "exitCode": 0, + "stderr": "", + "stdout": "{"results":[{"indexUid":"movies","hits":[{"id":1,"title":"Nosferatu"}],"query":"nosferatu","processingTimeMs":1,"limit":20,"offset":0,"estimatedTotalHits":1},{"indexUid":"books","hits":[{"id":"dracula","title":"Dracula"}],"query":"nosferatu","processingTimeMs":1,"limit":20,"offset":0,"estimatedTotalHits":1}]} +", +} +`; diff --git a/test/commands/multi-search.test.ts b/test/commands/multi-search.test.ts new file mode 100644 index 0000000..5c5ff25 --- /dev/null +++ b/test/commands/multi-search.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import MultiSearch from "../../src/commands/multi-search.js"; +import { MEILISEARCH_URL, meilisearchHandlers } from "../helpers/meilisearch-handlers.js"; +import { recordRequests, server, useMockServer } from "../helpers/msw.js"; +import { runCli, runCommand } from "../helpers/run-command.js"; + +useMockServer(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL }; +const queries = '[{"indexUid":"movies","q":"nosferatu"},{"indexUid":"books","q":"nosferatu"}]'; + +/** The envelope the route answers without a `federation`: one result per query. */ +const perQuery = { + results: [ + { + indexUid: "movies", + hits: [{ id: 1, title: "Nosferatu" }], + query: "nosferatu", + processingTimeMs: 1, + limit: 20, + offset: 0, + estimatedTotalHits: 1, + }, + { + indexUid: "books", + hits: [{ id: "dracula", title: "Dracula" }], + query: "nosferatu", + processingTimeMs: 1, + limit: 20, + offset: 0, + estimatedTotalHits: 1, + }, + ], +}; + +/** The body the CLI sent to POST /multi-search, request by request. */ +async function bodiesOf(argv: string[]): Promise { + const requests = await recordRequests(async () => { + await runCommand(MultiSearch, argv, { env }); + }); + return requests.map((request) => JSON.parse(request.body)); +} + +describe("meili multi-search", () => { + it("prints its help without any network call", async () => { + const result = await runCli(["multi-search", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatchSnapshot(); + }); + + it("prints the API envelope as it is in JSON", async () => { + server.use(meilisearchHandlers.multiSearch(perQuery)); + const result = await runCommand(MultiSearch, ["--queries", queries, "--output", "json"], { + env, + }); + expect(result).toMatchSnapshot(); + }); + + it("sends the two body fields as the JSON they spell, `null` included", async () => { + server.use(meilisearchHandlers.multiSearch(perQuery)); + const sent = [ + { indexUid: "movies", q: "nosferatu" }, + { indexUid: "books", q: "nosferatu" }, + ]; + expect(await bodiesOf(["--queries", queries, "--federation", '{"limit":5}'])).toEqual([ + { queries: sent, federation: { limit: 5 } }, + ]); + expect(await bodiesOf(["--queries", queries, "--federation", "null"])).toEqual([ + { queries: sent, federation: null }, + ]); + }); + + it("takes the body from --data, which a flag of the same field overrides", async () => { + server.use(meilisearchHandlers.multiSearch(perQuery)); + expect( + await bodiesOf(["--data", '{"queries":[{"indexUid":"movies"}],"federation":{}}']), + ).toEqual([{ queries: [{ indexUid: "movies" }], federation: {} }]); + expect( + await bodiesOf([ + "--data", + '{"queries":[{"indexUid":"movies"}],"federation":{}}', + "--queries", + '[{"indexUid":"books"}]', + ]), + ).toEqual([{ queries: [{ indexUid: "books" }], federation: {} }]); + }); + + it("sends a value that does not convert as the text it was given, for the API to refuse", async () => { + server.use(meilisearchHandlers.multiSearch(perQuery)); + expect(await bodiesOf(["--queries", "movies"])).toEqual([{ queries: "movies" }]); + }); + + it("refuses --output ndjson: the response is hits, not a list envelope", async () => { + server.use(meilisearchHandlers.multiSearch(perQuery)); + const result = await runCommand(MultiSearch, ["--queries", queries, "--output", "ndjson"], { + env, + }); + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("cli_usage"); + }); + + it("passes the API error of a missing index through, which the route answers 400", async () => { + server.use( + meilisearchHandlers.apiError("/multi-search", 400, { + message: "Inside `.queries[0]`: Index `missing` not found.", + code: "index_not_found", + type: "invalid_request", + link: "https://docs.meilisearch.com/errors#index_not_found", + }), + ); + const result = await runCommand( + MultiSearch, + ["--queries", '[{"indexUid":"missing","q":"nosferatu"}]', "--output", "json"], + { env }, + ); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/test/contract/__snapshots__/commands.test.ts.snap b/test/contract/__snapshots__/commands.test.ts.snap index ac3eb4f..6cdcd8d 100644 --- a/test/contract/__snapshots__/commands.test.ts.snap +++ b/test/contract/__snapshots__/commands.test.ts.snap @@ -1182,6 +1182,70 @@ The file holds API keys and decides where every later command goes, for every pr "GET /tasks", ], }, + { + "api": "meilisearch", + "description": "Run several search queries in one request, each naming its index in --queries, returned side by side or merged into one ranked list with --federation.", + "examples": [ + "meili multi-search --queries '[{"indexUid":"movies","q":"nosferatu"},{"indexUid":"books","q":"nosferatu"}]'", + "meili multi-search --queries '[{"indexUid":"movies","q":"nosferatu"}]' --output json", + "meili multi-search --queries '[{"indexUid":"movies","q":"horror"},{"indexUid":"books","q":"horror"}]' --federation '{"limit":5}'", + "cat queries.json | meili multi-search --file -", + ], + "flags": [ + { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "name": "project-url", + "type": "option", + }, + { + "description": "Project API key. \`-\` reads it from stdin.", + "helpValue": "", + "name": "project-api-key", + "type": "option", + }, + { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "name": "project", + "type": "option", + }, + { + "description": "The search queries as a JSON array, each one an index to search and its search parameters: \`[{"indexUid":"movies","q":"nosferatu"}]\`.", + "helpValue": "", + "name": "queries", + "type": "option", + }, + { + "description": "Merge the hits of every query into one ranked list, as JSON: \`{}\` for the defaults, \`{"limit":5}\` to narrow it. Without it each query answers its own envelope.", + "helpValue": "", + "name": "federation", + "type": "option", + }, + { + "description": "Read the body from a file. \`-\` reads from stdin.", + "exclusive": [ + "data", + ], + "helpValue": "", + "name": "file", + "type": "option", + }, + { + "description": "Inline JSON body.", + "helpValue": "", + "name": "data", + "type": "option", + }, + ], + "id": "multi-search", + "keyActions": [ + "search", + ], + "routes": [ + "POST /multi-search", + ], + }, { "api": "none", "description": "Print one line per command, or one command in full with --command, from the oclif manifest.", @@ -1925,6 +1989,13 @@ The file holds API keys and decides where every later command goes, for every pr "GET /tasks", ], }, + { + "description": "Run several search queries in one request, each naming its index in --queries, returned side by side or merged into one ranked list with --federation.", + "id": "multi-search", + "routes": [ + "POST /multi-search", + ], + }, { "description": "Print one line per command, or one command in full with --command, from the oclif manifest.", "id": "schema", diff --git a/test/helpers/meilisearch-handlers.ts b/test/helpers/meilisearch-handlers.ts index 2f30998..5eda475 100644 --- a/test/helpers/meilisearch-handlers.ts +++ b/test/helpers/meilisearch-handlers.ts @@ -130,6 +130,14 @@ export const meilisearchHandlers = { }); }), + /** + * `POST /multi-search` answering `response`, whatever shape it is given: the + * route answers one envelope per query, or the merged hits of every query + * when the body carries a `federation`, and the CLI prints either as it is. + */ + multiSearch: (response: RecordAny) => + http.post(`${MEILISEARCH_URL}/multi-search`, () => HttpResponse.json(response)), + /** * `GET /tasks` paging over `all` like the API does: `limit` defaults to 20, * the newest uid first, `from` names the uid the page starts at, and the diff --git a/test/integration/meilisearch/multi-search.test.ts b/test/integration/meilisearch/multi-search.test.ts new file mode 100644 index 0000000..f46233a --- /dev/null +++ b/test/integration/meilisearch/multi-search.test.ts @@ -0,0 +1,124 @@ +import { randomUUID } from "node:crypto"; +import { Meilisearch, type MultiSearchResponse, type SearchResponse } from "meilisearch"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { runBinary } from "../../helpers/run-binary.js"; +import { MASTER_KEY, MEILISEARCH_URL, meilisearchAvailable } from "./setup.js"; + +const meilisearchUp = await meilisearchAvailable(); + +const env = { MEILI_PROJECT_URL: MEILISEARCH_URL, MEILI_PROJECT_API_KEY: MASTER_KEY }; +const suffix = randomUUID().slice(0, 8); +const movies = `meili-cli-test-multi-search-movies-${suffix}`; +const books = `meili-cli-test-multi-search-books-${suffix}`; + +describe.skipIf(!meilisearchUp)("meili multi-search against a real Meilisearch", () => { + const client = new Meilisearch({ host: MEILISEARCH_URL, apiKey: MASTER_KEY }); + + beforeAll(async () => { + await Promise.all([ + client + .index(movies) + .addDocuments( + [ + { id: 1, title: "Nosferatu" }, + { id: 2, title: "Solaris" }, + ], + { primaryKey: "id" }, + ) + .waitTask(), + client + .index(books) + .addDocuments([{ id: 1, title: "Nosferatu, the novel" }], { primaryKey: "id" }) + .waitTask(), + ]); + }); + + afterAll(async () => { + await Promise.all([movies, books].map((uid) => client.deleteIndex(uid).waitTask())); + }); + + it("answers one envelope per query, in the order of --queries", async () => { + const result = await runBinary( + [ + "multi-search", + "--queries", + JSON.stringify([ + { indexUid: movies, q: "nosferatu" }, + { indexUid: books, q: "nosferatu" }, + ]), + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + const body = JSON.parse(result.stdout) as MultiSearchResponse; + expect(body.results.map((one) => one.indexUid)).toEqual([movies, books]); + expect(body.results.map((one) => one.hits.length)).toEqual([1, 1]); + }); + + it("merges the hits of every query with --federation", async () => { + const result = await runBinary( + [ + "multi-search", + "--queries", + JSON.stringify([ + { indexUid: movies, q: "nosferatu" }, + { indexUid: books, q: "nosferatu" }, + ]), + "--federation", + "{}", + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(0); + const body = JSON.parse(result.stdout) as SearchResponse; + expect(body.hits).toHaveLength(2); + expect(body.hits.map((hit) => hit._federation?.indexUid)).toEqual([movies, books]); + }); + + it("sends the body of --data, which --queries overrides", async () => { + const result = await runBinary( + [ + "multi-search", + "--data", + JSON.stringify({ queries: [{ indexUid: books, q: "nosferatu" }] }), + "--queries", + JSON.stringify([{ indexUid: movies, q: "solaris" }]), + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(0); + const body = JSON.parse(result.stdout) as MultiSearchResponse; + expect(body.results).toHaveLength(1); + expect(body.results[0]?.indexUid).toBe(movies); + }); + + it("lets the API name the missing queries when neither --queries nor a body is given", async () => { + const result = await runBinary(["multi-search", "--output", "json"], env); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.message).toBe("Missing field `queries`"); + }); + + it("passes the error of an index that does not exist through, which the route answers 400", async () => { + const result = await runBinary( + [ + "multi-search", + "--queries", + JSON.stringify([{ indexUid: `${movies}-missing`, q: "nosferatu" }]), + "--output", + "json", + ], + env, + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr).error.code).toBe("index_not_found"); + }); +});