diff --git a/src/http-requests.ts b/src/http-requests.ts index 2e3ff400e..e8e8d9782 100644 --- a/src/http-requests.ts +++ b/src/http-requests.ts @@ -13,7 +13,7 @@ import { MeiliSearchRequestError, MeiliSearchRequestTimeOutError, } from "./errors/index.js"; -import { addProtocolIfNotPresent, addTrailingSlash } from "./utils.js"; +import { addProtocolIfNotPresent } from "./utils.js"; /** Append a set of key value pairs to a {@link URLSearchParams} object. */ function appendRecordToURLSearchParams( @@ -34,36 +34,32 @@ function appendRecordToURLSearchParams( } } +const AGENT_HEADER_KEY = "X-Meilisearch-Client"; +const CONTENT_TYPE_KEY = "Content-Type"; +const AUTHORIZATION_KEY = "Authorization"; +const PACAKGE_AGENT = `Meilisearch JavaScript (v${PACKAGE_VERSION})`; + /** * Creates a new Headers object from a {@link HeadersInit} and adds various - * properties to it, some from {@link Config}. - * - * @returns A new Headers object + * properties to it, as long as they're not already provided by the user. */ function getHeaders(config: Config, headersInit?: HeadersInit): Headers { - const agentHeader = "X-Meilisearch-Client"; - const packageAgent = `Meilisearch JavaScript (v${PACKAGE_VERSION})`; - const contentType = "Content-Type"; - const authorization = "Authorization"; - const headers = new Headers(headersInit); - // do not override if user provided the header - if (config.apiKey && !headers.has(authorization)) { - headers.set(authorization, `Bearer ${config.apiKey}`); + if (config.apiKey && !headers.has(AUTHORIZATION_KEY)) { + headers.set(AUTHORIZATION_KEY, `Bearer ${config.apiKey}`); } - if (!headers.has(contentType)) { - headers.set(contentType, "application/json"); + if (!headers.has(CONTENT_TYPE_KEY)) { + headers.set(CONTENT_TYPE_KEY, "application/json"); } // Creates the custom user agent with information on the package used. if (config.clientAgents !== undefined) { - const clients = config.clientAgents.concat(packageAgent); - - headers.set(agentHeader, clients.join(" ; ")); + const agents = config.clientAgents.concat(PACAKGE_AGENT); + headers.set(AGENT_HEADER_KEY, agents.join(" ; ")); } else { - headers.set(agentHeader, packageAgent); + headers.set(AGENT_HEADER_KEY, PACAKGE_AGENT); } return headers; @@ -85,19 +81,23 @@ const TIMEOUT_ID = {}; * function that clears the timeout */ function getTimeoutFn( - requestInit: RequestInit, + init: RequestInit, ms: number, ): () => (() => void) | void { - const { signal } = requestInit; + const { signal } = init; const ac = new AbortController(); + init.signal = ac.signal; + if (signal != null) { let acSignalFn: (() => void) | null = null; if (signal.aborted) { ac.abort(signal.reason); } else { - const fn = () => ac.abort(signal.reason); + const fn = () => { + ac.abort(signal.reason); + }; signal.addEventListener("abort", fn, { once: true }); @@ -110,7 +110,9 @@ function getTimeoutFn( return; } - const to = setTimeout(() => ac.abort(TIMEOUT_ID), ms); + const to = setTimeout(() => { + ac.abort(TIMEOUT_ID); + }, ms); const fn = () => { clearTimeout(to); @@ -128,10 +130,10 @@ function getTimeoutFn( }; } - requestInit.signal = ac.signal; - return () => { - const to = setTimeout(() => ac.abort(TIMEOUT_ID), ms); + const to = setTimeout(() => { + ac.abort(TIMEOUT_ID); + }, ms); return () => clearTimeout(to); }; } @@ -144,10 +146,10 @@ export class HttpRequests { #requestTimeout?: Config["timeout"]; constructor(config: Config) { - const host = addTrailingSlash(addProtocolIfNotPresent(config.host)); + const host = addProtocolIfNotPresent(config.host); try { - this.#url = new URL(host); + this.#url = new URL(host.endsWith("/") ? host : host + "/"); } catch (error) { throw new MeiliSearchError("The provided host is not valid", { cause: error, @@ -177,8 +179,8 @@ export class HttpRequests { const headers = new Headers(extraHeaders); - if (contentType !== undefined && !headers.has("Content-Type")) { - headers.set("Content-Type", contentType); + if (contentType !== undefined && !headers.has(CONTENT_TYPE_KEY)) { + headers.set(CONTENT_TYPE_KEY, contentType); } for (const [key, val] of this.#requestInit.headers) { diff --git a/src/task.ts b/src/task.ts index 52637e18b..78d948e9e 100644 --- a/src/task.ts +++ b/src/task.ts @@ -127,7 +127,7 @@ export class TaskClient { } } } catch (error) { - throw Object.is((error as Error).cause, TIMEOUT_ID) + throw Object.is((error as Error)?.cause, TIMEOUT_ID) ? new MeiliSearchTaskTimeOutError(taskUid, timeout) : error; } diff --git a/src/types/types.ts b/src/types/types.ts index 91d29f4b5..6763b1383 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -45,12 +45,7 @@ export type HttpRequestsRequestInit = Omit & { /** Main configuration object for the meilisearch client. */ export type Config = { - /** - * The base URL for reaching a meilisearch instance. - * - * @remarks - * Protocol and trailing slash can be omitted. - */ + /** The base URL for reaching a meilisearch instance. */ host: string; /** * API key for interacting with a meilisearch instance. @@ -59,8 +54,8 @@ export type Config = { */ apiKey?: string; /** - * Custom strings that will be concatted to the "X-Meilisearch-Client" header - * on each request. + * Custom strings that will be concatenated to the "X-Meilisearch-Client" + * header on each request. */ clientAgents?: string[]; /** Base request options that may override the default ones. */ @@ -69,12 +64,19 @@ export type Config = { * Custom function that can be provided in place of {@link fetch}. * * @remarks - * API response errors will have to be handled manually with this as well. + * API response errors have to be handled manually. * @deprecated This will be removed in a future version. See * {@link https://github.com/meilisearch/meilisearch-js/issues/1824 | issue}. */ httpClient?: (...args: Parameters) => Promise; - /** Timeout in milliseconds for each HTTP request. */ + /** + * Timeout in milliseconds for each HTTP request. + * + * @remarks + * This uses {@link setTimeout}, which is not guaranteed to respect the + * provided milliseconds accurately. + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#reasons_for_delays_longer_than_specified} + */ timeout?: number; defaultWaitOptions?: WaitOptions; }; diff --git a/src/utils.ts b/src/utils.ts index e748e1f10..7ba207b6e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,18 +2,20 @@ async function sleep(ms: number): Promise { return await new Promise((resolve) => setTimeout(resolve, ms)); } +let warningDispatched = false; function addProtocolIfNotPresent(host: string): string { - if (!(host.startsWith("https://") || host.startsWith("http://"))) { - return `http://${host}`; + if (/^https?:\/\//.test(host)) { + return host; } - return host; -} -function addTrailingSlash(url: string): string { - if (!url.endsWith("/")) { - url += "/"; + if (!warningDispatched) { + console.warn( + `DEPRECATION WARNING: missing protocol in provided host ${host} will no longer be supported in the future`, + ); + warningDispatched = true; } - return url; + + return `http://${host}`; } -export { sleep, addProtocolIfNotPresent, addTrailingSlash }; +export { sleep, addProtocolIfNotPresent }; diff --git a/tests/client.test.ts b/tests/client.test.ts deleted file mode 100644 index c1b5b5926..000000000 --- a/tests/client.test.ts +++ /dev/null @@ -1,902 +0,0 @@ -import { - afterAll, - expect, - test, - describe, - beforeEach, - vi, - type MockInstance, - beforeAll, -} from "vitest"; -import type { Health, Version, Stats, IndexSwap } from "../src/index.js"; -import { ErrorStatusCode, MeiliSearchRequestError } from "../src/index.js"; -import { PACKAGE_VERSION } from "../src/package-version.js"; -import { - clearAllIndexes, - getKey, - getClient, - config, - MeiliSearch, - BAD_HOST, - HOST, - assert, -} from "./utils/meilisearch-test-utils.js"; - -const indexNoPk = { - uid: "movies_test", -}; -const indexPk = { - uid: "movies_test2", - primaryKey: "id", -}; - -const index = { - uid: "movies_test", -}; - -const index2 = { - uid: "movies_test2", -}; - -afterAll(() => { - return clearAllIndexes(config); -}); - -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on client instance", ({ permission }) => { - beforeEach(() => { - return clearAllIndexes(config); - }); - - test(`${permission} key: Create client with api key`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - }); - const health = await client.isHealthy(); - expect(health).toBe(true); - }); - - describe("Header tests", () => { - let fetchSpy: MockInstance; - - beforeAll(() => { - fetchSpy = vi.spyOn(globalThis, "fetch"); - }); - - afterAll(() => fetchSpy.mockRestore()); - - test(`${permission} key: Create client with custom headers (object)`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - requestInit: { - headers: { - "Hello-There!": "General Kenobi", - }, - }, - }); - - await client.multiSearch( - { queries: [] }, - { headers: { "Jane-Doe": "John Doe" } }, - ); - - assert.isDefined(fetchSpy.mock.lastCall); - const [, requestInit] = fetchSpy.mock.lastCall; - - assert.isDefined(requestInit?.headers); - assert.instanceOf(requestInit.headers, Headers); - - const headers = requestInit.headers; - - assert.strictEqual(headers.get("Hello-There!"), "General Kenobi"); - assert.strictEqual(headers.get("Jane-Doe"), "John Doe"); - }); - - test(`${permission} key: Create client with custom headers (array)`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - requestInit: { - headers: [["Hello-There!", "General Kenobi"]], - }, - }); - - assert.isTrue(await client.isHealthy()); - - assert.isDefined(fetchSpy.mock.lastCall); - const [, requestInit] = fetchSpy.mock.lastCall; - - assert.isDefined(requestInit?.headers); - assert.instanceOf(requestInit.headers, Headers); - assert.strictEqual( - requestInit.headers.get("Hello-There!"), - "General Kenobi", - ); - }); - - test(`${permission} key: Create client with custom headers (Headers)`, async () => { - const key = await getKey(permission); - const headers = new Headers(); - headers.set("Hello-There!", "General Kenobi"); - const client = new MeiliSearch({ - ...config, - apiKey: key, - requestInit: { headers }, - }); - - assert.isTrue(await client.isHealthy()); - - assert.isDefined(fetchSpy.mock.lastCall); - const [, requestInit] = fetchSpy.mock.lastCall; - - assert.isDefined(requestInit?.headers); - assert.instanceOf(requestInit.headers, Headers); - assert.strictEqual( - requestInit.headers.get("Hello-There!"), - "General Kenobi", - ); - }); - }); - - test(`${permission} key: No double slash when on host with domain and path and trailing slash`, async () => { - const key = await getKey(permission); - const customHost = `${BAD_HOST}/api/`; - const client = new MeiliSearch({ - host: customHost, - apiKey: key, - }); - - await assert.rejects( - client.health(), - MeiliSearchRequestError, - `Request to ${BAD_HOST}/api/health has failed`, - ); - }); - - test(`${permission} key: No double slash when on host with domain and path and no trailing slash`, async () => { - const key = await getKey(permission); - const customHost = `${BAD_HOST}/api`; - const client = new MeiliSearch({ - host: customHost, - apiKey: key, - }); - - await assert.rejects( - client.health(), - MeiliSearchRequestError, - `Request to ${BAD_HOST}/api/health has failed`, - ); - }); - - test(`${permission} key: host with double slash should keep double slash`, async () => { - const key = await getKey(permission); - const customHost = `${BAD_HOST}//`; - const client = new MeiliSearch({ - host: customHost, - apiKey: key, - }); - - await assert.rejects( - client.health(), - MeiliSearchRequestError, - `Request to ${BAD_HOST}//health has failed`, - ); - }); - - test(`${permission} key: host with one slash should not double slash`, async () => { - const key = await getKey(permission); - const customHost = `${BAD_HOST}/`; - const client = new MeiliSearch({ - host: customHost, - apiKey: key, - }); - - await assert.rejects( - client.health(), - MeiliSearchRequestError, - `Request to ${BAD_HOST}/health has failed`, - ); - }); - - test(`${permission} key: bad host raise CommunicationError`, async () => { - const client = new MeiliSearch({ host: "http://localhost:9345" }); - await assert.rejects(client.health(), MeiliSearchRequestError); - }); - - test(`${permission} key: host without HTTP should not throw Invalid URL Error`, () => { - const strippedHost = HOST.replace("http://", ""); - expect(() => { - new MeiliSearch({ host: strippedHost }); - }).not.toThrow("The provided host is not valid."); - }); - - test(`${permission} key: host without HTTP and port should not throw Invalid URL Error`, () => { - const strippedHost = HOST.replace("http://", "").replace(":7700", ""); - expect(() => { - new MeiliSearch({ host: strippedHost }); - }).not.toThrow("The provided host is not valid."); - }); - - test(`${permission} key: Empty string host should throw an error`, () => { - expect(() => { - new MeiliSearch({ host: "" }); - }).toThrow("The provided host is not valid"); - }); -}); - -describe.each([{ permission: "Master" }, { permission: "Admin" }])( - "Test on client w/ master and admin key", - ({ permission }) => { - beforeEach(() => { - return clearAllIndexes(config); - }); - - test(`${permission} key: Create client with custom headers`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - requestInit: { - headers: { - "Hello-There!": "General Kenobi", - }, - }, - }); - expect(client.config.requestInit?.headers).toStrictEqual({ - "Hello-There!": "General Kenobi", - }); - const health = await client.isHealthy(); - - expect(health).toBe(true); - - await client.createIndex("test").waitTask(); - - const { results } = await client.getIndexes(); - - expect(results.length).toBe(1); - }); - - test(`${permission} key: Create client with custom http client`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - async httpClient(...params: Parameters) { - const result = await fetch(...params); - return result.json() as Promise; - }, - }); - const health = await client.isHealthy(); - - expect(health).toBe(true); - - await client.createIndex("test").waitTask(); - - const { results } = await client.getIndexes(); - - expect(results.length).toBe(1); - - const index = await client.getIndex("test"); - - await index.addDocuments([{ id: 1, title: "index_2" }]).waitTask(); - - const { results: documents } = await index.getDocuments(); - expect(documents.length).toBe(1); - }); - - describe("Header tests", () => { - let fetchSpy: MockInstance; - - beforeAll(() => { - fetchSpy = vi.spyOn(globalThis, "fetch"); - }); - - afterAll(() => fetchSpy.mockRestore()); - - test(`${permission} key: Create client with no custom client agents`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - requestInit: { - headers: {}, - }, - }); - - assert.isTrue(await client.isHealthy()); - - assert.isDefined(fetchSpy.mock.lastCall); - const [, requestInit] = fetchSpy.mock.lastCall; - - assert.isDefined(requestInit?.headers); - assert.instanceOf(requestInit.headers, Headers); - assert.strictEqual( - requestInit.headers.get("X-Meilisearch-Client"), - `Meilisearch JavaScript (v${PACKAGE_VERSION})`, - ); - }); - - test(`${permission} key: Create client with empty custom client agents`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - clientAgents: [], - }); - - assert.isTrue(await client.isHealthy()); - - assert.isDefined(fetchSpy.mock.lastCall); - const [, requestInit] = fetchSpy.mock.lastCall; - - assert.isDefined(requestInit?.headers); - assert.instanceOf(requestInit.headers, Headers); - assert.strictEqual( - requestInit.headers.get("X-Meilisearch-Client"), - `Meilisearch JavaScript (v${PACKAGE_VERSION})`, - ); - }); - - test(`${permission} key: Create client with custom client agents`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - clientAgents: ["random plugin 1", "random plugin 2"], - }); - - assert.isTrue(await client.isHealthy()); - - assert.isDefined(fetchSpy.mock.lastCall); - const [, requestInit] = fetchSpy.mock.lastCall; - - assert.isDefined(requestInit?.headers); - assert.instanceOf(requestInit.headers, Headers); - assert.strictEqual( - requestInit.headers.get("X-Meilisearch-Client"), - `random plugin 1 ; random plugin 2 ; Meilisearch JavaScript (v${PACKAGE_VERSION})`, - ); - }); - }); - - describe("Test on indexes methods", () => { - test(`${permission} key: create with no primary key`, async () => { - const client = await getClient(permission); - await client.createIndex(indexNoPk.uid).waitTask(); - - const newIndex = await client.getIndex(indexNoPk.uid); - expect(newIndex).toHaveProperty("uid", indexNoPk.uid); - expect(newIndex).toHaveProperty("primaryKey", null); - - const rawIndex = await client.index(indexNoPk.uid).getRawInfo(); - expect(rawIndex).toHaveProperty("uid", indexNoPk.uid); - expect(rawIndex).toHaveProperty("primaryKey", null); - expect(rawIndex).toHaveProperty("createdAt", expect.any(String)); - expect(rawIndex).toHaveProperty("updatedAt", expect.any(String)); - - const response = await client.getIndex(indexNoPk.uid); - expect(response.primaryKey).toBe(null); - expect(response.uid).toBe(indexNoPk.uid); - }); - - test(`${permission} key: create with primary key`, async () => { - const client = await getClient(permission); - await client - .createIndex(indexPk.uid, { - primaryKey: indexPk.primaryKey, - }) - .waitTask(); - - const newIndex = await client.getIndex(indexPk.uid); - - expect(newIndex).toHaveProperty("uid", indexPk.uid); - expect(newIndex).toHaveProperty("primaryKey", indexPk.primaryKey); - - const rawIndex = await client.index(indexPk.uid).getRawInfo(); - expect(rawIndex).toHaveProperty("primaryKey", indexPk.primaryKey); - expect(rawIndex).toHaveProperty("createdAt", expect.any(String)); - expect(rawIndex).toHaveProperty("updatedAt", expect.any(String)); - - const response = await client.getIndex(indexPk.uid); - expect(response.primaryKey).toBe(indexPk.primaryKey); - expect(response.uid).toBe(indexPk.uid); - }); - - test(`${permission} key: get all indexes when not empty`, async () => { - const client = await getClient(permission); - - await client.createIndex(indexPk.uid).waitTask(); - - const { results } = await client.getRawIndexes(); - const indexes = results.map((index) => index.uid); - expect(indexes).toEqual(expect.arrayContaining([indexPk.uid])); - expect(indexes.length).toEqual(1); - }); - - test(`${permission} key: Get index that exists`, async () => { - const client = await getClient(permission); - - await client.createIndex(indexPk.uid).waitTask(); - - const response = await client.getIndex(indexPk.uid); - - expect(response).toHaveProperty("uid", indexPk.uid); - }); - - test(`${permission} key: Get index that does not exist`, async () => { - const client = await getClient(permission); - - await expect(client.getIndex("does_not_exist")).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.INDEX_NOT_FOUND, - ); - }); - - test(`${permission} key: update primary key`, async () => { - const client = await getClient(permission); - await client.createIndex(indexPk.uid).waitTask(); - await client - .updateIndex(indexPk.uid, { - primaryKey: "newPrimaryKey", - }) - .waitTask(); - - const index = await client.getIndex(indexPk.uid); - - expect(index).toHaveProperty("uid", indexPk.uid); - expect(index).toHaveProperty("primaryKey", "newPrimaryKey"); - }); - - test(`${permission} key: update primary key that already exists`, async () => { - const client = await getClient(permission); - await client - .createIndex(indexPk.uid, { - primaryKey: indexPk.primaryKey, - }) - .waitTask(); - await client - .updateIndex(indexPk.uid, { - primaryKey: "newPrimaryKey", - }) - .waitTask(); - - const index = await client.getIndex(indexPk.uid); - - expect(index).toHaveProperty("uid", indexPk.uid); - expect(index).toHaveProperty("primaryKey", "newPrimaryKey"); - }); - - test(`${permission} key: delete index`, async () => { - const client = await getClient(permission); - await client.createIndex(indexNoPk.uid).waitTask(); - - await client.deleteIndex(indexNoPk.uid).waitTask(); - const { results } = await client.getIndexes(); - - expect(results).toHaveLength(0); - }); - - test(`${permission} key: create index with already existing uid should fail`, async () => { - const client = await getClient(permission); - await client.createIndex(indexPk.uid).waitTask(); - - const task = await client.createIndex(indexPk.uid).waitTask(); - - expect(task.status).toBe("failed"); - }); - - test(`${permission} key: delete index with uid that does not exist should fail`, async () => { - const client = await getClient(permission); - const index = client.index(indexNoPk.uid); - const task = await index.delete().waitTask(); - - expect(task.status).toEqual("failed"); - }); - - test(`${permission} key: fetch deleted index should fail`, async () => { - const client = await getClient(permission); - const index = client.index(indexPk.uid); - await expect(index.getRawInfo()).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.INDEX_NOT_FOUND, - ); - }); - - test(`${permission} key: Swap two indexes`, async () => { - const client = await getClient(permission); - await client - .index(index.uid) - .addDocuments([{ id: 1, title: `index_1` }]); - await client - .index(index2.uid) - .addDocuments([{ id: 1, title: "index_2" }]) - .waitTask(); - const swaps: IndexSwap[] = [{ indexes: [index.uid, index2.uid] }]; - - const resolvedTask = await client.swapIndexes(swaps).waitTask(); - const docIndex1 = await client.index(index.uid).getDocument(1); - const docIndex2 = await client.index(index2.uid).getDocument(1); - - expect(docIndex1.title).toEqual("index_2"); - expect(docIndex2.title).toEqual("index_1"); - expect(resolvedTask.type).toEqual("indexSwap"); - expect(resolvedTask.details!.swaps).toEqual(swaps); - }); - - test(`${permission} key: Swap two indexes with one that does not exist`, async () => { - const client = await getClient(permission); - - await client - .index(index2.uid) - .addDocuments([{ id: 1, title: "index_2" }]) - .waitTask(); - - const swaps: IndexSwap[] = [ - { indexes: ["does_not_exist", index2.uid] }, - ]; - - const resolvedTask = await client.swapIndexes(swaps).waitTask(); - - expect(resolvedTask.type).toEqual("indexSwap"); - expect(resolvedTask.error?.code).toEqual( - ErrorStatusCode.INDEX_NOT_FOUND, - ); - expect(resolvedTask.details!.swaps).toEqual(swaps); - }); - - // Should be fixed by rc1 - test(`${permission} key: Swap two one index with itself`, async () => { - const client = await getClient(permission); - - const swaps: IndexSwap[] = [{ indexes: [index.uid, index.uid] }]; - - await expect(client.swapIndexes(swaps)).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.INVALID_SWAP_DUPLICATE_INDEX_FOUND, - ); - }); - }); - - describe("Test on base routes", () => { - test(`${permission} key: get health`, async () => { - const client = await getClient(permission); - const response: Health = await client.health(); - expect(response).toHaveProperty( - "status", - expect.stringMatching("available"), - ); - }); - - test(`${permission} key: is server healthy`, async () => { - const client = await getClient(permission); - const response: boolean = await client.isHealthy(); - expect(response).toBe(true); - }); - - test(`${permission} key: is healthy return false on bad host`, async () => { - const client = new MeiliSearch({ host: "http://localhost:9345" }); - const response: boolean = await client.isHealthy(); - expect(response).toBe(false); - }); - - test(`${permission} key: get version`, async () => { - const client = await getClient(permission); - const response: Version = await client.getVersion(); - expect(response).toHaveProperty("commitSha", expect.any(String)); - expect(response).toHaveProperty("commitDate", expect.any(String)); - expect(response).toHaveProperty("pkgVersion", expect.any(String)); - }); - - test(`${permission} key: get /stats information`, async () => { - const client = await getClient(permission); - const response: Stats = await client.getStats(); - expect(response).toHaveProperty("databaseSize", expect.any(Number)); - expect(response).toHaveProperty("usedDatabaseSize", expect.any(Number)); - expect(response).toHaveProperty("lastUpdate"); // TODO: Could be null, find out why - expect(response).toHaveProperty("indexes", expect.any(Object)); - }); - }); - }, -); - -describe.each([{ permission: "Search" }])( - "Test on misc client methods w/ search apikey", - ({ permission }) => { - beforeEach(() => { - return clearAllIndexes(config); - }); - - describe("Test on indexes methods", () => { - test(`${permission} key: try to get all indexes and be denied`, async () => { - const client = await getClient(permission); - await expect(client.getIndexes()).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.INVALID_API_KEY, - ); - }); - - test(`${permission} key: try to create Index with primary key and be denied`, async () => { - const client = await getClient(permission); - await expect( - client.createIndex(indexPk.uid, { - primaryKey: indexPk.primaryKey, - }), - ).rejects.toHaveProperty("cause.code", ErrorStatusCode.INVALID_API_KEY); - }); - - test(`${permission} key: try to create Index with NO primary key and be denied`, async () => { - const client = await getClient(permission); - await expect(client.createIndex(indexNoPk.uid)).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.INVALID_API_KEY, - ); - }); - - test(`${permission} key: try to delete index and be denied`, async () => { - const client = await getClient(permission); - await expect(client.deleteIndex(indexPk.uid)).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.INVALID_API_KEY, - ); - }); - - test(`${permission} key: try to update index and be denied`, async () => { - const client = await getClient(permission); - await expect( - client.updateIndex(indexPk.uid, { - primaryKey: indexPk.primaryKey, - }), - ).rejects.toHaveProperty("cause.code", ErrorStatusCode.INVALID_API_KEY); - }); - }); - - describe("Test on misc client methods", () => { - test(`${permission} key: get health`, async () => { - const client = await getClient(permission); - const response: Health = await client.health(); - expect(response).toHaveProperty( - "status", - expect.stringMatching("available"), - ); - }); - - test(`${permission} key: try to get version and be denied`, async () => { - const client = await getClient(permission); - await expect(client.getVersion()).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.INVALID_API_KEY, - ); - }); - - test(`${permission} key: try to get /stats information and be denied`, async () => { - const client = await getClient(permission); - await expect(client.getStats()).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.INVALID_API_KEY, - ); - }); - }); - }, -); - -describe.each([{ permission: "No" }])( - "Test on misc client methods w/ no apiKey client", - ({ permission }) => { - beforeEach(() => { - return clearAllIndexes(config); - }); - - describe("Test on indexes methods", () => { - test(`${permission} key: try to get all indexes and be denied`, async () => { - const client = await getClient(permission); - await expect(client.getIndexes()).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); - - test(`${permission} key: try to create Index with primary key and be denied`, async () => { - const client = await getClient(permission); - await expect( - client.createIndex(indexPk.uid, { - primaryKey: indexPk.primaryKey, - }), - ).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); - - test(`${permission} key: try to create Index with NO primary key and be denied`, async () => { - const client = await getClient(permission); - await expect(client.createIndex(indexNoPk.uid)).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); - - test(`${permission} key: try to delete index and be denied`, async () => { - const client = await getClient(permission); - await expect(client.deleteIndex(indexPk.uid)).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); - - test(`${permission} key: try to update index and be denied`, async () => { - const client = await getClient(permission); - await expect( - client.updateIndex(indexPk.uid, { - primaryKey: indexPk.primaryKey, - }), - ).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); - }); - - describe("Test on misc client methods", () => { - test(`${permission} key: get health`, async () => { - const client = await getClient(permission); - const response: Health = await client.health(); - expect(response).toHaveProperty( - "status", - expect.stringMatching("available"), - ); - }); - - test(`${permission} key: try to get version and be denied`, async () => { - const client = await getClient(permission); - await expect(client.getVersion()).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); - - test(`${permission} key: try to get /stats information and be denied`, async () => { - const client = await getClient(permission); - await expect(client.getStats()).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); - }); - }, -); - -describe.each([ - { host: BAD_HOST, trailing: false }, - { host: `${BAD_HOST}/api`, trailing: false }, - { host: `${BAD_HOST}/trailing/`, trailing: true }, -])("Tests on url construction", ({ host, trailing }) => { - test(`getIndex route`, async () => { - const route = `indexes/${indexPk.uid}`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.getIndex(indexPk.uid)).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`createIndex route`, async () => { - const route = `indexes`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.createIndex(indexPk.uid)).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`updateIndex route`, async () => { - const route = `indexes/${indexPk.uid}`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.updateIndex(indexPk.uid)).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`deleteIndex route`, async () => { - const route = `indexes/${indexPk.uid}`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.deleteIndex(indexPk.uid)).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`get indexes route`, async () => { - const route = `indexes`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.getIndexes()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`getKeys route`, async () => { - const route = `keys`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.getKeys()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`health route`, async () => { - const route = `health`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.health()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`stats route`, async () => { - const route = `stats`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.getStats()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`version route`, async () => { - const route = `version`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.getVersion()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); -}); - -describe.each([{ permission: "Master" }])( - "Test network methods", - ({ permission }) => { - const instanceName = "instance_1"; - - test(`${permission} key: Update and get network settings`, async () => { - const client = await getClient(permission); - - const instances = { - [instanceName]: { - url: "http://instance-1:7700", - searchApiKey: "search-key-1", - }, - }; - - await client.updateNetwork({ self: instanceName, remotes: instances }); - const response = await client.getNetwork(); - expect(response).toHaveProperty("self", instanceName); - expect(response).toHaveProperty("remotes"); - expect(response.remotes).toHaveProperty("instance_1"); - expect(response.remotes["instance_1"]).toHaveProperty( - "url", - instances[instanceName].url, - ); - expect(response.remotes["instance_1"]).toHaveProperty( - "searchApiKey", - instances[instanceName].searchApiKey, - ); - }); - }, -); diff --git a/tests/health.test.ts b/tests/health.test.ts new file mode 100644 index 000000000..5054cac5a --- /dev/null +++ b/tests/health.test.ts @@ -0,0 +1,10 @@ +import { test } from "vitest"; +import { assert, getClient } from "./utils/meilisearch-test-utils.js"; + +const ms = await getClient("Master"); + +test(`${ms.health.name} method`, async () => { + const health = await ms.health(); + assert.strictEqual(Object.keys(health).length, 1); + assert.strictEqual(health.status, "available"); +}); diff --git a/tests/meilisearch.test.ts b/tests/meilisearch.test.ts new file mode 100644 index 000000000..57ce12b95 --- /dev/null +++ b/tests/meilisearch.test.ts @@ -0,0 +1,190 @@ +import { + afterAll, + beforeAll, + describe, + test, + vi, + type MockInstance, +} from "vitest"; +import { + MeiliSearch, + MeiliSearchRequestTimeOutError, + MeiliSearchRequestError, +} from "../src/index.js"; +import { assert, HOST } from "./utils/meilisearch-test-utils.js"; + +describe("abort", () => { + let spy: MockInstance; + beforeAll(() => { + spy = vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { + assert.isDefined(init); + const signal = init.signal; + assert.isDefined(signal); + assert.isNotNull(signal); + + return new Promise((_resolve, reject) => { + if (signal.aborted) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(signal.reason as unknown); + } + + signal.onabort = function () { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(signal.reason); + signal.removeEventListener("abort", this.onabort!); + }; + }); + }); + }); + + afterAll(() => { + spy.mockReset(); + }); + + test.concurrent("with global timeout", async () => { + const timeout = 1; + const ms = new MeiliSearch({ host: HOST, timeout }); + + const { cause } = await assert.rejects( + ms.health(), + MeiliSearchRequestError, + ); + assert.instanceOf(cause, MeiliSearchRequestTimeOutError); + assert.strictEqual(cause.cause.timeout, timeout); + }); + + test.concurrent("with signal", async () => { + const ms = new MeiliSearch({ host: HOST }); + const reason = Symbol(""); + + const { cause } = await assert.rejects( + ms.multiSearch({ queries: [] }, { signal: AbortSignal.abort(reason) }), + MeiliSearchRequestError, + ); + assert.strictEqual(cause, reason); + }); + + test.concurrent("with signal with a timeout", async () => { + const ms = new MeiliSearch({ host: HOST }); + + const { cause } = await assert.rejects( + ms.multiSearch({ queries: [] }, { signal: AbortSignal.timeout(5) }), + MeiliSearchRequestError, + ); + + assert.strictEqual( + String(cause), + "TimeoutError: The operation was aborted due to timeout", + ); + }); + + test.concurrent.for([ + [2, 1], + [1, 2], + ] as const)( + "with global timeout of %ims and signal timeout of %ims", + async ([timeout, signalTimeout]) => { + const ms = new MeiliSearch({ host: HOST, timeout }); + + const { cause } = await assert.rejects( + ms.multiSearch( + { queries: [] }, + { signal: AbortSignal.timeout(signalTimeout) }, + ), + MeiliSearchRequestError, + ); + + if (timeout > signalTimeout) { + assert.strictEqual( + String(cause), + "TimeoutError: The operation was aborted due to timeout", + ); + } else { + assert.instanceOf(cause, MeiliSearchRequestTimeOutError); + assert.strictEqual(cause.cause.timeout, timeout); + } + }, + ); + + test.concurrent( + "with global timeout and immediately aborted signal", + async () => { + const ms = new MeiliSearch({ host: HOST, timeout: 1 }); + const reason = Symbol(""); + + const { cause } = await assert.rejects( + ms.multiSearch({ queries: [] }, { signal: AbortSignal.abort(reason) }), + MeiliSearchRequestError, + ); + + assert.strictEqual(cause, reason); + }, + ); +}); + +test("headers with API key, clientAgents, global headers, and custom headers", async () => { + const spy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(() => Promise.resolve(new Response())); + + const apiKey = "secrète"; + const clientAgents = ["TEST"]; + const globalHeaders = { my: "feather", not: "helper", extra: "header" }; + + const ms = new MeiliSearch({ + host: HOST, + apiKey, + clientAgents, + requestInit: { headers: globalHeaders }, + }); + + const customHeaders = { my: "header", not: "yours" }; + await ms.multiSearch({ queries: [] }, { headers: customHeaders }); + + const { calls } = spy.mock; + assert.lengthOf(calls, 1); + + const headers = calls[0][1]?.headers; + assert.isDefined(headers); + assert.instanceOf(headers, Headers); + + const xMeilisearchClientKey = "x-meilisearch-client"; + const xMeilisearchClient = headers.get(xMeilisearchClientKey); + headers.delete(xMeilisearchClientKey); + + assert.isNotNull(xMeilisearchClient); + assert.sameMembers( + xMeilisearchClient.split(" ; ").slice(0, -1), + clientAgents, + ); + + const authorizationKey = "authorization"; + const authorization = headers.get(authorizationKey); + headers.delete(authorizationKey); + + assert.strictEqual(authorization, `Bearer ${apiKey}`); + + // note how they overwrite eachother, top priority being the custom headers + assert.deepEqual(Object.fromEntries(headers.entries()), { + "content-type": "application/json", + ...globalHeaders, + ...customHeaders, + }); + + spy.mockReset(); +}); + +test.concurrent("custom http client", async () => { + const httpClient = vi.fn((..._params: Parameters) => + Promise.resolve(new Response()), + ); + + const ms = new MeiliSearch({ host: HOST, httpClient }); + await ms.health(); + + assert.lengthOf(httpClient.mock.calls, 1); + const input = httpClient.mock.calls[0][0]; + + assert.instanceOf(input, URL); + assert(input.href.startsWith(HOST)); +}); diff --git a/tests/network.test.ts b/tests/network.test.ts new file mode 100644 index 000000000..120e99059 --- /dev/null +++ b/tests/network.test.ts @@ -0,0 +1,48 @@ +import { test, afterAll } from "vitest"; +import { assert, getClient } from "./utils/meilisearch-test-utils.js"; +import type { Remote } from "../src/index.js"; + +const ms = await getClient("Master"); + +afterAll(async () => { + await ms.updateNetwork({ + remotes: { + // TODO: Better types for Network + // @ts-expect-error This should be accepted + soi: null, + }, + }); +}); + +test(`${ms.updateNetwork.name} and ${ms.getNetwork.name} method`, async () => { + const network = { + self: "soi", + remotes: { + soi: { + url: "https://france-visas.gouv.fr/", + searchApiKey: "hemmelighed", + }, + }, + }; + + function validateRemotes(remotes: Record) { + for (const [key, val] of Object.entries(remotes)) { + if (key !== "soi") { + assert.lengthOf(Object.keys(val), 2); + assert.typeOf(val.url, "string"); + assert( + typeof val.searchApiKey === "string" || val.searchApiKey === null, + ); + delete remotes[key]; + } + } + } + + const updateResponse = await ms.updateNetwork(network); + validateRemotes(updateResponse.remotes); + assert.deepEqual(updateResponse, network); + + const getResponse = await ms.getNetwork(); + validateRemotes(getResponse.remotes); + assert.deepEqual(getResponse, network); +}); diff --git a/tests/stats.test.ts b/tests/stats.test.ts new file mode 100644 index 000000000..2ef10120f --- /dev/null +++ b/tests/stats.test.ts @@ -0,0 +1,37 @@ +import { test } from "vitest"; +import { assert, getClient } from "./utils/meilisearch-test-utils.js"; + +const ms = await getClient("Master"); + +test(`${ms.getStats.name} method`, async () => { + const stats = await ms.getStats(); + assert.strictEqual(Object.keys(stats).length, 4); + const { databaseSize, usedDatabaseSize, lastUpdate, indexes } = stats; + assert.typeOf(databaseSize, "number"); + assert.typeOf(usedDatabaseSize, "number"); + assert(typeof lastUpdate === "string" || lastUpdate === null); + + for (const indexStats of Object.values(indexes)) { + assert.lengthOf(Object.keys(indexStats), 7); + const { + numberOfDocuments, + isIndexing, + fieldDistribution, + numberOfEmbeddedDocuments, + numberOfEmbeddings, + rawDocumentDbSize, + avgDocumentSize, + } = indexStats; + + assert.typeOf(numberOfDocuments, "number"); + assert.typeOf(isIndexing, "boolean"); + assert.typeOf(numberOfEmbeddedDocuments, "number"); + assert.typeOf(numberOfEmbeddings, "number"); + assert.typeOf(rawDocumentDbSize, "number"); + assert.typeOf(avgDocumentSize, "number"); + + for (const val of Object.values(fieldDistribution)) { + assert.typeOf(val, "number"); + } + } +}); diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 000000000..2e6d05fbd --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,13 @@ +import { test } from "vitest"; +import { assert, getClient } from "./utils/meilisearch-test-utils.js"; + +const ms = await getClient("Master"); + +test(`${ms.getVersion.name} method`, async () => { + const version = await ms.getVersion(); + assert.strictEqual(Object.keys(version).length, 3); + const { commitDate, commitSha, pkgVersion } = version; + for (const v of [commitDate, commitSha, pkgVersion]) { + assert.typeOf(v, "string"); + } +});