From 5e97cf80914bd624b9534ed3d8f86733d5b9bb7c Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Mon, 21 Apr 2025 12:11:35 +0300 Subject: [PATCH 01/10] Progress --- src/http-requests.ts | 60 +-- src/task.ts | 2 +- src/types/types.ts | 22 +- src/utils.ts | 20 +- tests/client.test.ts | 902 -------------------------------------- tests/health.test.ts | 10 + tests/meilisearch.test.ts | 193 ++++++++ tests/network.test.ts | 48 ++ tests/stats.test.ts | 37 ++ tests/version.test.ts | 13 + 10 files changed, 356 insertions(+), 951 deletions(-) delete mode 100644 tests/client.test.ts create mode 100644 tests/health.test.ts create mode 100644 tests/meilisearch.test.ts create mode 100644 tests/network.test.ts create mode 100644 tests/stats.test.ts create mode 100644 tests/version.test.ts 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..8dd9a47b8 --- /dev/null +++ b/tests/meilisearch.test.ts @@ -0,0 +1,193 @@ +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)); +}); + +// TODO: Describe how this PR depends on improve indexes PR +// TODO: Error tests 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"); + } +}); From e3c20a34e7e31a3f7ee0747e752b719cd7d95e5f Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Fri, 25 Apr 2025 10:36:22 +0300 Subject: [PATCH 02/10] Misc --- tests/meilisearch.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/meilisearch.test.ts b/tests/meilisearch.test.ts index 8dd9a47b8..57ce12b95 100644 --- a/tests/meilisearch.test.ts +++ b/tests/meilisearch.test.ts @@ -188,6 +188,3 @@ test.concurrent("custom http client", async () => { assert.instanceOf(input, URL); assert(input.href.startsWith(HOST)); }); - -// TODO: Describe how this PR depends on improve indexes PR -// TODO: Error tests From ad8db0a4b3a5e9bfacf702fe49e9802e99dde5dd Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Sun, 11 May 2025 14:40:01 +0300 Subject: [PATCH 03/10] Properly restore mocks --- tests/meilisearch.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/meilisearch.test.ts b/tests/meilisearch.test.ts index 57ce12b95..814820a90 100644 --- a/tests/meilisearch.test.ts +++ b/tests/meilisearch.test.ts @@ -38,7 +38,7 @@ describe("abort", () => { }); afterAll(() => { - spy.mockReset(); + spy.mockRestore(); }); test.concurrent("with global timeout", async () => { @@ -171,7 +171,7 @@ test("headers with API key, clientAgents, global headers, and custom headers", a ...customHeaders, }); - spy.mockReset(); + spy.mockRestore(); }); test.concurrent("custom http client", async () => { From 7a024504027f861b7c87f887711588eca6cf8256 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Sun, 11 May 2025 21:10:09 +0300 Subject: [PATCH 04/10] Add error tests --- tests/errors.test.ts | 63 ++++++++++++++++++++++++++++++--------- tests/meilisearch.test.ts | 2 +- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/tests/errors.test.ts b/tests/errors.test.ts index f9596f98a..6d554fa12 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -1,19 +1,54 @@ -import { test, describe, beforeEach, vi } from "vitest"; -import { MeiliSearch, assert } from "./utils/meilisearch-test-utils.js"; -import { MeiliSearchRequestError } from "../src/index.js"; +import { afterEach, test, vi, afterAll } from "vitest"; +import { assert } from "./utils/meilisearch-test-utils.js"; +import { + MeiliSearch, + MeiliSearchApiError, + MeiliSearchError, + MeiliSearchRequestError, + type MeiliSearchErrorResponse, +} from "../src/index.js"; -const mockedFetch = vi.fn(); -globalThis.fetch = mockedFetch; +const spy = vi.spyOn(globalThis, "fetch"); -describe("Test on updates", () => { - beforeEach(() => { - mockedFetch.mockReset(); - }); +afterAll(() => { + spy.mockRestore(); +}); + +afterEach(() => { + spy.mockReset(); +}); + +test(`${MeiliSearchError.name}`, () => { + assert.throws( + () => new MeiliSearch({ host: "http:// invalid URL" }), + MeiliSearchError, + "The provided host is not valid", + ); +}); + +test(`${MeiliSearchRequestError.name}`, async () => { + const simulatedError = new TypeError("simulated network error"); + spy.mockImplementation(() => Promise.reject(simulatedError)); + + const ms = new MeiliSearch({ host: "https://politi.dk/en/" }); + const error = await assert.rejects(ms.health(), MeiliSearchRequestError); + assert.deepEqual(error.cause, simulatedError); +}); - test(`Throw MeilisearchRequestError when thrown error is not MeiliSearchApiError`, async () => { - mockedFetch.mockRejectedValue(new Error("fake error message")); +test(`${MeiliSearchApiError.name}`, async () => { + const simulatedCause: MeiliSearchErrorResponse = { + message: "message", + code: "code", + type: "type", + link: "link", + }; + spy.mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(simulatedCause), { status: 400 }), + ), + ); - const client = new MeiliSearch({ host: "http://localhost:9345" }); - await assert.rejects(client.health(), MeiliSearchRequestError); - }); + const ms = new MeiliSearch({ host: "https://polisen.se/en/" }); + const error = await assert.rejects(ms.health(), MeiliSearchApiError); + assert.deepEqual(error.cause, simulatedCause); }); diff --git a/tests/meilisearch.test.ts b/tests/meilisearch.test.ts index 814820a90..d6a066df1 100644 --- a/tests/meilisearch.test.ts +++ b/tests/meilisearch.test.ts @@ -164,7 +164,7 @@ test("headers with API key, clientAgents, global headers, and custom headers", a assert.strictEqual(authorization, `Bearer ${apiKey}`); - // note how they overwrite eachother, top priority being the custom headers + // note how they overwrite each other, top priority being the custom headers assert.deepEqual(Object.fromEntries(headers.entries()), { "content-type": "application/json", ...globalHeaders, From 837aafb55e6ae40205fe029af7585770fe9fe18c Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Sun, 11 May 2025 21:13:34 +0300 Subject: [PATCH 05/10] Misc comments --- tests/errors.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 6d554fa12..d758069a6 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -52,3 +52,6 @@ test(`${MeiliSearchApiError.name}`, async () => { const error = await assert.rejects(ms.health(), MeiliSearchApiError); assert.deepEqual(error.cause, simulatedCause); }); + +// TODO: Mention other errors, and how they are tested in another file perhaps +// Also maybe move these tests into meilisearch.test.ts From 1cf023a9821b9c33c701fcc616dfa8996c978b58 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Mon, 12 May 2025 10:16:15 +0300 Subject: [PATCH 06/10] Refactor error tests, move them to meilisearch tests --- tests/errors.test.ts | 57 --------------------- tests/meilisearch.test.ts | 103 ++++++++++++++++++++++++++++++-------- 2 files changed, 81 insertions(+), 79 deletions(-) delete mode 100644 tests/errors.test.ts diff --git a/tests/errors.test.ts b/tests/errors.test.ts deleted file mode 100644 index d758069a6..000000000 --- a/tests/errors.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { afterEach, test, vi, afterAll } from "vitest"; -import { assert } from "./utils/meilisearch-test-utils.js"; -import { - MeiliSearch, - MeiliSearchApiError, - MeiliSearchError, - MeiliSearchRequestError, - type MeiliSearchErrorResponse, -} from "../src/index.js"; - -const spy = vi.spyOn(globalThis, "fetch"); - -afterAll(() => { - spy.mockRestore(); -}); - -afterEach(() => { - spy.mockReset(); -}); - -test(`${MeiliSearchError.name}`, () => { - assert.throws( - () => new MeiliSearch({ host: "http:// invalid URL" }), - MeiliSearchError, - "The provided host is not valid", - ); -}); - -test(`${MeiliSearchRequestError.name}`, async () => { - const simulatedError = new TypeError("simulated network error"); - spy.mockImplementation(() => Promise.reject(simulatedError)); - - const ms = new MeiliSearch({ host: "https://politi.dk/en/" }); - const error = await assert.rejects(ms.health(), MeiliSearchRequestError); - assert.deepEqual(error.cause, simulatedError); -}); - -test(`${MeiliSearchApiError.name}`, async () => { - const simulatedCause: MeiliSearchErrorResponse = { - message: "message", - code: "code", - type: "type", - link: "link", - }; - spy.mockImplementation(() => - Promise.resolve( - new Response(JSON.stringify(simulatedCause), { status: 400 }), - ), - ); - - const ms = new MeiliSearch({ host: "https://polisen.se/en/" }); - const error = await assert.rejects(ms.health(), MeiliSearchApiError); - assert.deepEqual(error.cause, simulatedCause); -}); - -// TODO: Mention other errors, and how they are tested in another file perhaps -// Also maybe move these tests into meilisearch.test.ts diff --git a/tests/meilisearch.test.ts b/tests/meilisearch.test.ts index d6a066df1..55cfa7332 100644 --- a/tests/meilisearch.test.ts +++ b/tests/meilisearch.test.ts @@ -1,5 +1,6 @@ import { afterAll, + afterEach, beforeAll, describe, test, @@ -10,6 +11,9 @@ import { MeiliSearch, MeiliSearchRequestTimeOutError, MeiliSearchRequestError, + MeiliSearchError, + MeiliSearchApiError, + type MeiliSearchErrorResponse, } from "../src/index.js"; import { assert, HOST } from "./utils/meilisearch-test-utils.js"; @@ -45,35 +49,32 @@ describe("abort", () => { 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); + const error = await assert.rejects(ms.health(), MeiliSearchRequestError); + assert.instanceOf(error.cause, MeiliSearchRequestTimeOutError); + assert.strictEqual(error.cause.cause.timeout, timeout); }); test.concurrent("with signal", async () => { const ms = new MeiliSearch({ host: HOST }); const reason = Symbol(""); - const { cause } = await assert.rejects( + const error = await assert.rejects( ms.multiSearch({ queries: [] }, { signal: AbortSignal.abort(reason) }), MeiliSearchRequestError, ); - assert.strictEqual(cause, reason); + assert.strictEqual(error.cause, reason); }); test.concurrent("with signal with a timeout", async () => { const ms = new MeiliSearch({ host: HOST }); - const { cause } = await assert.rejects( + const error = await assert.rejects( ms.multiSearch({ queries: [] }, { signal: AbortSignal.timeout(5) }), MeiliSearchRequestError, ); assert.strictEqual( - String(cause), + String(error.cause), "TimeoutError: The operation was aborted due to timeout", ); }); @@ -86,7 +87,7 @@ describe("abort", () => { async ([timeout, signalTimeout]) => { const ms = new MeiliSearch({ host: HOST, timeout }); - const { cause } = await assert.rejects( + const error = await assert.rejects( ms.multiSearch( { queries: [] }, { signal: AbortSignal.timeout(signalTimeout) }, @@ -96,12 +97,12 @@ describe("abort", () => { if (timeout > signalTimeout) { assert.strictEqual( - String(cause), + String(error.cause), "TimeoutError: The operation was aborted due to timeout", ); } else { - assert.instanceOf(cause, MeiliSearchRequestTimeOutError); - assert.strictEqual(cause.cause.timeout, timeout); + assert.instanceOf(error.cause, MeiliSearchRequestTimeOutError); + assert.strictEqual(error.cause.cause.timeout, timeout); } }, ); @@ -112,20 +113,31 @@ describe("abort", () => { const ms = new MeiliSearch({ host: HOST, timeout: 1 }); const reason = Symbol(""); - const { cause } = await assert.rejects( + const error = await assert.rejects( ms.multiSearch({ queries: [] }, { signal: AbortSignal.abort(reason) }), MeiliSearchRequestError, ); - assert.strictEqual(cause, reason); + assert.strictEqual(error.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())); + using spy = (() => { + const spy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(() => Promise.resolve(new Response())); + + return { + get value() { + return spy; + }, + [Symbol.dispose]() { + spy.mockRestore(); + }, + }; + })(); const apiKey = "secrète"; const clientAgents = ["TEST"]; @@ -141,7 +153,7 @@ test("headers with API key, clientAgents, global headers, and custom headers", a const customHeaders = { my: "header", not: "yours" }; await ms.multiSearch({ queries: [] }, { headers: customHeaders }); - const { calls } = spy.mock; + const { calls } = spy.value.mock; assert.lengthOf(calls, 1); const headers = calls[0][1]?.headers; @@ -170,8 +182,6 @@ test("headers with API key, clientAgents, global headers, and custom headers", a ...globalHeaders, ...customHeaders, }); - - spy.mockRestore(); }); test.concurrent("custom http client", async () => { @@ -188,3 +198,52 @@ test.concurrent("custom http client", async () => { assert.instanceOf(input, URL); assert(input.href.startsWith(HOST)); }); + +describe("other errors", () => { + const spy = vi.spyOn(globalThis, "fetch"); + + afterAll(() => { + spy.mockRestore(); + }); + + afterEach(() => { + spy.mockReset(); + }); + + test(`${MeiliSearchError.name}`, () => { + assert.throws( + () => new MeiliSearch({ host: "http:// invalid URL" }), + MeiliSearchError, + "The provided host is not valid", + ); + }); + + test(`${MeiliSearchRequestError.name}`, async () => { + const simulatedError = new TypeError("simulated network error"); + spy.mockImplementation(() => Promise.reject(simulatedError)); + + const ms = new MeiliSearch({ host: "https://politi.dk/en/" }); + const error = await assert.rejects(ms.health(), MeiliSearchRequestError); + assert.deepEqual(error.cause, simulatedError); + }); + + test(`${MeiliSearchApiError.name}`, async () => { + const simulatedCause: MeiliSearchErrorResponse = { + message: "message", + code: "code", + type: "type", + link: "link", + }; + spy.mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(simulatedCause), { status: 400 }), + ), + ); + + const ms = new MeiliSearch({ host: "https://polisen.se/en/" }); + const error = await assert.rejects(ms.health(), MeiliSearchApiError); + assert.deepEqual(error.cause, simulatedCause); + }); + + // MeiliSearchTaskTimeOutError is tested by tasks-and-batches tests +}); From ace14306f356aa2e8fe0ff79c965fbcacd51d788 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Mon, 12 May 2025 10:17:44 +0300 Subject: [PATCH 07/10] Rename test --- tests/meilisearch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/meilisearch.test.ts b/tests/meilisearch.test.ts index 55cfa7332..1f5de7c32 100644 --- a/tests/meilisearch.test.ts +++ b/tests/meilisearch.test.ts @@ -199,7 +199,7 @@ test.concurrent("custom http client", async () => { assert(input.href.startsWith(HOST)); }); -describe("other errors", () => { +describe("errors", () => { const spy = vi.spyOn(globalThis, "fetch"); afterAll(() => { From dd4b14768a009528cef0d713509f8380eea40bd8 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Mon, 12 May 2025 10:19:13 +0300 Subject: [PATCH 08/10] Misc --- tests/meilisearch.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/meilisearch.test.ts b/tests/meilisearch.test.ts index 1f5de7c32..47dcb6f99 100644 --- a/tests/meilisearch.test.ts +++ b/tests/meilisearch.test.ts @@ -200,7 +200,10 @@ test.concurrent("custom http client", async () => { }); describe("errors", () => { - const spy = vi.spyOn(globalThis, "fetch"); + let spy: MockInstance; + beforeAll(() => { + spy = vi.spyOn(globalThis, "fetch"); + }); afterAll(() => { spy.mockRestore(); From 1c1dfbc6fcae0c0c2db945c505457facf1ab19aa Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Mon, 12 May 2025 10:45:56 +0300 Subject: [PATCH 09/10] Improve error tests --- tests/meilisearch.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/meilisearch.test.ts b/tests/meilisearch.test.ts index 47dcb6f99..ec2eb9d3b 100644 --- a/tests/meilisearch.test.ts +++ b/tests/meilisearch.test.ts @@ -227,6 +227,7 @@ describe("errors", () => { const ms = new MeiliSearch({ host: "https://politi.dk/en/" }); const error = await assert.rejects(ms.health(), MeiliSearchRequestError); + assert.typeOf(error.message, "string"); assert.deepEqual(error.cause, simulatedError); }); @@ -245,7 +246,9 @@ describe("errors", () => { const ms = new MeiliSearch({ host: "https://polisen.se/en/" }); const error = await assert.rejects(ms.health(), MeiliSearchApiError); + assert.typeOf(error.message, "string"); assert.deepEqual(error.cause, simulatedCause); + assert.instanceOf(error.response, Response); }); // MeiliSearchTaskTimeOutError is tested by tasks-and-batches tests From e75b4d2abb236b8b47849968474aec2a46bfa2f6 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Mon, 19 May 2025 13:50:43 +0300 Subject: [PATCH 10/10] Fix typo --- src/http-requests.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/http-requests.ts b/src/http-requests.ts index e8e8d9782..8256c8562 100644 --- a/src/http-requests.ts +++ b/src/http-requests.ts @@ -37,7 +37,7 @@ 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})`; +const PACKAGE_AGENT = `Meilisearch JavaScript (v${PACKAGE_VERSION})`; /** * Creates a new Headers object from a {@link HeadersInit} and adds various @@ -56,10 +56,10 @@ function getHeaders(config: Config, headersInit?: HeadersInit): Headers { // Creates the custom user agent with information on the package used. if (config.clientAgents !== undefined) { - const agents = config.clientAgents.concat(PACAKGE_AGENT); + const agents = config.clientAgents.concat(PACKAGE_AGENT); headers.set(AGENT_HEADER_KEY, agents.join(" ; ")); } else { - headers.set(AGENT_HEADER_KEY, PACAKGE_AGENT); + headers.set(AGENT_HEADER_KEY, PACKAGE_AGENT); } return headers;