From b882cb6b96d1e07fd458322e0eb45aab75c3c7ec Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Sat, 27 Sep 2025 08:27:48 +0300 Subject: [PATCH 1/3] Add sharding feature, test --- src/meilisearch.ts | 8 +- src/types/index.ts | 3 +- src/types/network.ts | 17 +++ src/types/shared.ts | 14 +- src/types/task-and-batch.ts | 252 ++++++++++++++++++++++++++++++++++++ src/types/types.ts | 28 +--- tests/client.test.ts | 36 +++--- 7 files changed, 298 insertions(+), 60 deletions(-) create mode 100644 src/types/network.ts create mode 100644 src/types/task-and-batch.ts diff --git a/src/meilisearch.ts b/src/meilisearch.ts index b2e7d7527..142d5a0b9 100644 --- a/src/meilisearch.ts +++ b/src/meilisearch.ts @@ -34,6 +34,7 @@ import type { ResultsWrapper, WebhookCreatePayload, WebhookUpdatePayload, + UpdatableNetwork, } from "./types/index.js"; import { ErrorStatusCode } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; @@ -383,11 +384,8 @@ export class MeiliSearch { * * @experimental */ - async updateNetwork(network: Partial): Promise { - return await this.httpRequest.patch({ - path: "network", - body: network, - }); + async updateNetwork(options: UpdatableNetwork): Promise { + return await this.httpRequest.patch({ path: "network", body: options }); } /// diff --git a/src/types/index.ts b/src/types/index.ts index 6d744c635..f7fdf080d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ export * from "./experimental-features.js"; -export * from "./task_and_batch.js"; +export * from "./network.js"; +export * from "./task-and-batch.js"; export * from "./token.js"; export * from "./types.js"; export * from "./webhooks.js"; diff --git a/src/types/network.ts b/src/types/network.ts new file mode 100644 index 000000000..778471dcb --- /dev/null +++ b/src/types/network.ts @@ -0,0 +1,17 @@ +import type { DeepPartial } from "./shared.js"; + +/** {@link https://www.meilisearch.com/docs/reference/api/network#the-remote-object} */ +export type Remote = { + url: string; + searchApiKey?: string | null; + writeApiKey?: string | null; +}; + +/** {@link https://www.meilisearch.com/docs/reference/api/network#the-network-object} */ +export type Network = { + self?: string | null; + remotes?: Record; + sharding?: boolean; +}; + +export type UpdatableNetwork = DeepPartial; diff --git a/src/types/shared.ts b/src/types/shared.ts index 42c558785..60673d3f4 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -1,5 +1,3 @@ -import type { RecordAny } from "./types.js"; - export type CursorResults = { results: T[]; limit: number; @@ -8,14 +6,10 @@ export type CursorResults = { total: number; }; -export type NonNullableDeepRecordValues = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [P in keyof T]: T[P] extends any[] - ? Array> - : T[P] extends RecordAny - ? NonNullableDeepRecordValues - : NonNullable; -}; +/** Deeply map every property of a record to itself making it partial. */ +export type DeepPartial = T extends object + ? { [TKey in keyof T]?: DeepPartial } + : T; // taken from https://stackoverflow.com/a/65642944 export type PascalToCamelCase = Uncapitalize; diff --git a/src/types/task-and-batch.ts b/src/types/task-and-batch.ts new file mode 100644 index 000000000..26892adc4 --- /dev/null +++ b/src/types/task-and-batch.ts @@ -0,0 +1,252 @@ +import type { RecordAny, Settings } from "./types.js"; +import type { + PascalToCamelCase, + SafeOmit, + OptionStarOr, + OptionStarOrList, +} from "./shared.js"; +import type { MeiliSearchErrorResponse } from "./types.js"; + +/** Options for awaiting an {@link EnqueuedTask}. */ +export type WaitOptions = { + /** + * Milliseconds until timeout error will be thrown for each awaited task. A + * value of less than `1` disables it. + * + * @defaultValue `5000` + */ + timeout?: number; + /** + * Task polling interval in milliseconds. A value of less than `1` disables + * it. + * + * @defaultValue `50` + */ + interval?: number; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/tasks#status} + * + * @see `meilisearch_types::tasks::Status` + */ +export type TaskStatus = PascalToCamelCase< + "Enqueued" | "Processing" | "Succeeded" | "Failed" | "Canceled" +>; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/tasks#type} + * + * @see `meilisearch_types::tasks::Kind` + */ +export type TaskType = PascalToCamelCase< + | "DocumentAdditionOrUpdate" + | "DocumentEdition" + | "DocumentDeletion" + | "SettingsUpdate" + | "IndexCreation" + | "IndexDeletion" + | "IndexUpdate" + | "IndexSwap" + | "TaskCancelation" + | "TaskDeletion" + | "DumpCreation" + | "SnapshotCreation" + | "UpgradeDatabase" +>; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/tasks#query-parameters} + * + * @see `meilisearch::routes::tasks::TasksFilterQuery` + */ +export type TasksOrBatchesQuery = Partial<{ + limit: number; + from: number | null; + reverse: boolean | null; + batchUids: OptionStarOrList; + uids: OptionStarOrList; + canceledBy: OptionStarOrList; + types: OptionStarOrList; + statuses: OptionStarOrList; + indexUids: OptionStarOrList; + afterEnqueuedAt: OptionStarOr; + beforeEnqueuedAt: OptionStarOr; + afterStartedAt: OptionStarOr; + beforeStartedAt: OptionStarOr; + afterFinishedAt: OptionStarOr; + beforeFinishedAt: OptionStarOr; +}>; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/tasks#query-parameters-1} + * {@link https://www.meilisearch.com/docs/reference/api/tasks#query-parameters-2} + * + * @see `meilisearch::routes::tasks::TaskDeletionOrCancelationQuery` + */ +export type DeleteOrCancelTasksQuery = SafeOmit< + TasksOrBatchesQuery, + "limit" | "from" | "reverse" +>; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/tasks#summarized-task-object} + * + * @see `meilisearch::routes::SummarizedTaskView` + */ +export type EnqueuedTask = { + taskUid: number; + indexUid: string | null; + status: TaskStatus; + type: TaskType; + enqueuedAt: string; +}; + +/** Either a number or an {@link EnqueuedTask}. */ +export type TaskUidOrEnqueuedTask = EnqueuedTask["taskUid"] | EnqueuedTask; + +/** {@link https://www.meilisearch.com/docs/reference/api/tasks#indexswap} */ +export type IndexSwap = { + indexes: [string, string]; + rename: boolean; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/tasks#details} + * + * @see `meilisearch_types::task_view::DetailsView` + */ +export type TaskDetails = Settings & + Partial<{ + receivedDocuments: number; + indexedDocuments: number; + editedDocuments: number; + primaryKey: string; + providedIds: number; + deletedDocuments: number; + matchedTasks: number; + canceledTasks: number; + deletedTasks: number; + originalFilter: string | null; + dumpUid: string | null; + context: Record | null; + function: string; + swaps: IndexSwap[]; + upgradeFrom: string; + upgradeTo: string; + }>; + +/** {@link https://www.meilisearch.com/docs/reference/api/tasks#network} */ +type Origin = { remoteName: string; taskUid: number }; + +/** {@link https://www.meilisearch.com/docs/reference/api/tasks#network} */ +type NetworkOrigin = { origin: Origin }; + +/** {@link https://www.meilisearch.com/docs/reference/api/tasks#network} */ +type RemoteTask = { taskUid?: number; error: MeiliSearchErrorResponse | null }; + +/** {@link https://www.meilisearch.com/docs/reference/api/tasks#network} */ +type NetworkRemoteTasks = { remoteTasks: Record }; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/tasks#task-object} + * + * @see `meilisearch_types::task_view::TaskView` + */ +export type Task = SafeOmit & { + uid: number; + batchUid: number | null; + canceledBy: number | null; + details?: TaskDetails; + error: MeiliSearchErrorResponse | null; + duration: string | null; + startedAt: string | null; + finishedAt: string | null; + /** {@link https://www.meilisearch.com/docs/reference/api/tasks#network} */ + network?: NetworkOrigin | NetworkRemoteTasks; +}; + +/** + * A {@link Promise} resolving to an {@link EnqueuedTask} with an extra function + * that returns a Promise that resolves to a {@link Task}. + */ +export type EnqueuedTaskPromise = Promise & { + /** + * Function that, through polling, awaits the {@link EnqueuedTask} resolved by + * {@link EnqueuedTaskPromise}. + */ + waitTask: (waitOptions?: WaitOptions) => Promise; +}; + +type Results = { + results: T[]; + total: number; + limit: number; + from: number | null; + next: number | null; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/tasks#response} + * + * @see `meilisearch::routes::tasks::AllTasks` + */ +export type TasksResults = Results; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/batches#steps} + * + * @see `milli::progress::ProgressStepView` + */ +export type BatchProgressStep = { + currentStep: string; + finished: number; + total: number; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/batches#progress} + * + * @see `milli::progress::ProgressView` + */ +export type BatchProgress = { + steps: BatchProgressStep[]; + percentage: number; +}; + +/** {@link https://www.meilisearch.com/docs/reference/api/batches#stats} */ +type BatchStats = { + totalNbTasks: number; + status: Record; + types: Record; + indexUids: Record; + /** {@link https://www.meilisearch.com/docs/reference/api/batches#progresstrace} */ + progressTrace?: RecordAny; + /** {@link https://www.meilisearch.com/docs/reference/api/batches#writechannelcongestion} */ + writeChannelCongestion?: RecordAny; + /** {@link https://www.meilisearch.com/docs/reference/api/batches#internaldatabasesizes} */ + internalDatabaseSizes?: RecordAny; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/batches#batch-object} + * + * @see `meilisearch_types::batch_view::BatchView` + */ +export type Batch = { + uid: number; + progress: BatchProgress | null; + details: TaskDetails; + stats: BatchStats; + duration: string | null; + startedAt: string; + finishedAt: string | null; + // batcherStoppedBecause: unknown; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/batches#response} + * + * @see `meilisearch::routes::batches::AllBatches` + */ +export type BatchesResults = Results; diff --git a/src/types/types.ts b/src/types/types.ts index 3d57fb6d9..d47e61901 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,7 +4,7 @@ // Definitions: https://github.com/meilisearch/meilisearch-js // TypeScript Version: ^5.8.2 -import type { WaitOptions } from "./task_and_batch.js"; +import type { WaitOptions } from "./task-and-batch.js"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type RecordAny = Record; @@ -310,26 +310,6 @@ export type FederatedMultiSearchParams = { queries: MultiSearchQueryWithFederation[]; }; -/** - * {@link https://www.meilisearch.com/docs/reference/api/network#the-remote-object} - * - * @see `meilisearch_types::features::Remote` at {@link https://github.com/meilisearch/meilisearch} - */ -export type Remote = { - url: string; - searchApiKey: string | null; -}; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/network#the-network-object} - * - * @see `meilisearch_types::features::Network` at {@link https://github.com/meilisearch/meilisearch} - */ -export type Network = { - self: string | null; - remotes: Record; -}; - export type CategoriesDistribution = { [category: string]: number; }; @@ -842,12 +822,6 @@ export type Version = { ** ERROR HANDLER */ -export interface FetchError extends Error { - type: string; - errno: string; - code: string; -} - export type MeiliSearchErrorResponse = { message: string; // https://www.meilisearch.com/docs/reference/errors/error_codes diff --git a/tests/client.test.ts b/tests/client.test.ts index a426d5515..a1544dcfd 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -8,7 +8,13 @@ import { type MockInstance, beforeAll, } from "vitest"; -import type { Health, Version, Stats, IndexSwap } from "../src/index.js"; +import type { + Health, + Version, + Stats, + IndexSwap, + UpdatableNetwork, +} from "../src/index.js"; import { ErrorStatusCode, MeiliSearchRequestError } from "../src/index.js"; import pkg from "../package.json" with { type: "json" }; import { @@ -887,26 +893,22 @@ describe.each([{ permission: "Master" }])( 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", + const options: UpdatableNetwork = { + self: instanceName, + remotes: { + [instanceName]: { + url: "http://instance-1:7700", + searchApiKey: "search-key-1", + writeApiKey: "write-key-1", + }, }, + sharding: true, }; - await client.updateNetwork({ self: instanceName, remotes: instances }); + await client.updateNetwork(options); 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, - ); + + assert.deepEqual(response, options); }); }, ); From 2becefbdaa988838b797cb2f74769c1bae0a5a4b Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Sat, 27 Sep 2025 08:30:56 +0300 Subject: [PATCH 2/3] Fix rename --- src/types/task_and_batch.ts | 238 ------------------------------------ 1 file changed, 238 deletions(-) delete mode 100644 src/types/task_and_batch.ts diff --git a/src/types/task_and_batch.ts b/src/types/task_and_batch.ts deleted file mode 100644 index 0c30212f6..000000000 --- a/src/types/task_and_batch.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { RecordAny, Settings } from "./types.js"; -import type { - PascalToCamelCase, - SafeOmit, - OptionStarOr, - OptionStarOrList, -} from "./shared.js"; -import type { MeiliSearchErrorResponse } from "./types.js"; - -/** Options for awaiting an {@link EnqueuedTask}. */ -export type WaitOptions = { - /** - * Milliseconds until timeout error will be thrown for each awaited task. A - * value of less than `1` disables it. - * - * @defaultValue `5000` - */ - timeout?: number; - /** - * Task polling interval in milliseconds. A value of less than `1` disables - * it. - * - * @defaultValue `50` - */ - interval?: number; -}; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/tasks#status} - * - * @see `meilisearch_types::tasks::Status` - */ -export type TaskStatus = PascalToCamelCase< - "Enqueued" | "Processing" | "Succeeded" | "Failed" | "Canceled" ->; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/tasks#type} - * - * @see `meilisearch_types::tasks::Kind` - */ -export type TaskType = PascalToCamelCase< - | "DocumentAdditionOrUpdate" - | "DocumentEdition" - | "DocumentDeletion" - | "SettingsUpdate" - | "IndexCreation" - | "IndexDeletion" - | "IndexUpdate" - | "IndexSwap" - | "TaskCancelation" - | "TaskDeletion" - | "DumpCreation" - | "SnapshotCreation" - | "UpgradeDatabase" ->; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/tasks#query-parameters} - * - * @see `meilisearch::routes::tasks::TasksFilterQuery` - */ -export type TasksOrBatchesQuery = Partial<{ - limit: number; - from: number | null; - reverse: boolean | null; - batchUids: OptionStarOrList; - uids: OptionStarOrList; - canceledBy: OptionStarOrList; - types: OptionStarOrList; - statuses: OptionStarOrList; - indexUids: OptionStarOrList; - afterEnqueuedAt: OptionStarOr; - beforeEnqueuedAt: OptionStarOr; - afterStartedAt: OptionStarOr; - beforeStartedAt: OptionStarOr; - afterFinishedAt: OptionStarOr; - beforeFinishedAt: OptionStarOr; -}>; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/tasks#query-parameters-1} - * {@link https://www.meilisearch.com/docs/reference/api/tasks#query-parameters-2} - * - * @see `meilisearch::routes::tasks::TaskDeletionOrCancelationQuery` - */ -export type DeleteOrCancelTasksQuery = SafeOmit< - TasksOrBatchesQuery, - "limit" | "from" | "reverse" ->; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/tasks#summarized-task-object} - * - * @see `meilisearch::routes::SummarizedTaskView` - */ -export type EnqueuedTask = { - taskUid: number; - indexUid: string | null; - status: TaskStatus; - type: TaskType; - enqueuedAt: string; -}; - -/** Either a number or an {@link EnqueuedTask}. */ -export type TaskUidOrEnqueuedTask = EnqueuedTask["taskUid"] | EnqueuedTask; - -/** {@link https://www.meilisearch.com/docs/reference/api/tasks#indexswap} */ -export type IndexSwap = { - indexes: [string, string]; - rename: boolean; -}; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/tasks#details} - * - * @see `meilisearch_types::task_view::DetailsView` - */ -export type TaskDetails = Settings & - Partial<{ - receivedDocuments: number; - indexedDocuments: number; - editedDocuments: number; - primaryKey: string; - providedIds: number; - deletedDocuments: number; - matchedTasks: number; - canceledTasks: number; - deletedTasks: number; - originalFilter: string | null; - dumpUid: string | null; - context: Record | null; - function: string; - swaps: IndexSwap[]; - upgradeFrom: string; - upgradeTo: string; - }>; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/tasks#task-object} - * - * @see `meilisearch_types::task_view::TaskView` - */ -export type Task = SafeOmit & { - uid: number; - batchUid: number | null; - canceledBy: number | null; - details?: TaskDetails; - error: MeiliSearchErrorResponse | null; - duration: string | null; - startedAt: string | null; - finishedAt: string | null; -}; - -/** - * A {@link Promise} resolving to an {@link EnqueuedTask} with an extra function - * that returns a Promise that resolves to a {@link Task}. - */ -export type EnqueuedTaskPromise = Promise & { - /** - * Function that, through polling, awaits the {@link EnqueuedTask} resolved by - * {@link EnqueuedTaskPromise}. - */ - waitTask: (waitOptions?: WaitOptions) => Promise; -}; - -type Results = { - results: T[]; - total: number; - limit: number; - from: number | null; - next: number | null; -}; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/tasks#response} - * - * @see `meilisearch::routes::tasks::AllTasks` - */ -export type TasksResults = Results; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/batches#steps} - * - * @see `milli::progress::ProgressStepView` - */ -export type BatchProgressStep = { - currentStep: string; - finished: number; - total: number; -}; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/batches#progress} - * - * @see `milli::progress::ProgressView` - */ -export type BatchProgress = { - steps: BatchProgressStep[]; - percentage: number; -}; - -/** {@link https://www.meilisearch.com/docs/reference/api/batches#stats} */ -type BatchStats = { - totalNbTasks: number; - status: Record; - types: Record; - indexUids: Record; - /** {@link https://www.meilisearch.com/docs/reference/api/batches#progresstrace} */ - progressTrace?: RecordAny; - /** {@link https://www.meilisearch.com/docs/reference/api/batches#writechannelcongestion} */ - writeChannelCongestion?: RecordAny; - /** {@link https://www.meilisearch.com/docs/reference/api/batches#internaldatabasesizes} */ - internalDatabaseSizes?: RecordAny; -}; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/batches#batch-object} - * - * @see `meilisearch_types::batch_view::BatchView` - */ -export type Batch = { - uid: number; - progress: BatchProgress | null; - details: TaskDetails; - stats: BatchStats; - duration: string | null; - startedAt: string; - finishedAt: string | null; - // batcherStoppedBecause: unknown; -}; - -/** - * {@link https://www.meilisearch.com/docs/reference/api/batches#response} - * - * @see `meilisearch::routes::batches::AllBatches` - */ -export type BatchesResults = Results; From b6a39a55e50f4675eb930e235b4d4763a9cb2be5 Mon Sep 17 00:00:00 2001 From: "F. Levi" <55688616+flevi29@users.noreply.github.com> Date: Sat, 27 Sep 2025 09:54:25 +0300 Subject: [PATCH 3/3] Fix tasks test, improve utils --- tests/tasks-and-batches.test.ts | 21 +- tests/utils/assert.ts | 18 ++ tests/utils/assertions/error.ts | 12 + tests/utils/assertions/promise.ts | 43 ++++ tests/utils/assertions/tasks-and-batches.ts | 229 ++++++++++++++++++++ tests/utils/meilisearch-test-utils.ts | 153 +------------ tests/utils/object.ts | 8 + tests/utils/tasks-and-batches.ts | 164 -------------- 8 files changed, 325 insertions(+), 323 deletions(-) create mode 100644 tests/utils/assert.ts create mode 100644 tests/utils/assertions/error.ts create mode 100644 tests/utils/assertions/promise.ts create mode 100644 tests/utils/assertions/tasks-and-batches.ts create mode 100644 tests/utils/object.ts delete mode 100644 tests/utils/tasks-and-batches.ts diff --git a/tests/tasks-and-batches.test.ts b/tests/tasks-and-batches.test.ts index 0fb303272..b80a61c86 100644 --- a/tests/tasks-and-batches.test.ts +++ b/tests/tasks-and-batches.test.ts @@ -1,12 +1,15 @@ import { randomUUID } from "node:crypto"; import { beforeAll, describe, test, vi } from "vitest"; import type { TasksOrBatchesQuery } from "../src/types/index.js"; -import { getClient, objectEntries } from "./utils/meilisearch-test-utils.js"; import { + getClient, + objectEntries, assert, - possibleTaskTypes, +} from "./utils/meilisearch-test-utils.js"; +import { possibleTaskStatuses, -} from "./utils/tasks-and-batches.js"; + possibleTaskTypes, +} from "./utils/assertions/tasks-and-batches.js"; const INDEX_UID = randomUUID(); const ms = await getClient("Master"); @@ -188,11 +191,11 @@ describe.for(objectEntries(testValuesRecord))("%s", ([key, testValues]) => { test.for(testValues)( `${ms.tasks.getTasks.name} method%s`, async ([, value]) => { - const { results, ...r } = await ms.tasks.getTasks({ [key]: value }); + const tasksResults = await ms.tasks.getTasks({ [key]: value }); - assert.isResult(r); + assert.isTasksOrBatchesResults(tasksResults); - for (const task of results) { + for (const task of tasksResults.results) { assert.isTask(task); } }, @@ -201,11 +204,11 @@ describe.for(objectEntries(testValuesRecord))("%s", ([key, testValues]) => { test.for(testValues)( `${ms.batches.getBatches.name} method%s`, async ([, value]) => { - const { results, ...r } = await ms.batches.getBatches({ [key]: value }); + const batchesResults = await ms.batches.getBatches({ [key]: value }); - assert.isResult(r); + assert.isTasksOrBatchesResults(batchesResults); - for (const batch of results) { + for (const batch of batchesResults.results) { assert.isBatch(batch); } }, diff --git a/tests/utils/assert.ts b/tests/utils/assert.ts new file mode 100644 index 000000000..5ea99960b --- /dev/null +++ b/tests/utils/assert.ts @@ -0,0 +1,18 @@ +import { assert } from "vitest"; +import { errorAssertions } from "./assertions/error.js"; +import { promiseAssertions } from "./assertions/promise.js"; +import { tasksAndBatchesAssertions } from "./assertions/tasks-and-batches.js"; + +const source = { + ...errorAssertions, + ...promiseAssertions, + ...tasksAndBatchesAssertions, +}; + +const customAssert: typeof assert & typeof source = Object.assign( + assert, + source, +); + +// needs to be named assert to satisfy Vitest ESLint plugin in tests +export { customAssert as assert }; diff --git a/tests/utils/assertions/error.ts b/tests/utils/assertions/error.ts new file mode 100644 index 000000000..a17047494 --- /dev/null +++ b/tests/utils/assertions/error.ts @@ -0,0 +1,12 @@ +import { assert } from "vitest"; +import type { MeiliSearchErrorResponse } from "../../../src/index.js"; + +export const errorAssertions = { + isErrorResponse(error: MeiliSearchErrorResponse) { + assert.lengthOf(Object.keys(error), 4); + const { message, code, type, link } = error; + for (const val of Object.values({ message, code, type, link })) { + assert.typeOf(val, "string"); + } + }, +}; diff --git a/tests/utils/assertions/promise.ts b/tests/utils/assertions/promise.ts new file mode 100644 index 000000000..e60fc7e22 --- /dev/null +++ b/tests/utils/assertions/promise.ts @@ -0,0 +1,43 @@ +import { assert } from "vitest"; + +const NOT_RESOLVED = Symbol(""); +const RESOLVED = Symbol(""); + +export const promiseAssertions = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async rejects( + promise: Promise, + errorConstructor: T, + errMsgMatcher?: RegExp | string, + ): Promise> { + let resolvedValue; + + try { + resolvedValue = await promise; + } catch (error) { + assert.instanceOf(error, errorConstructor); + + if (errMsgMatcher !== undefined) { + const { message } = error as Error; + if (typeof errMsgMatcher === "string") { + assert.strictEqual(message, errMsgMatcher); + } else { + assert.match(message, errMsgMatcher); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return error as InstanceType; + } + + assert.fail(resolvedValue, NOT_RESOLVED, "expected value to not resolve"); + }, + + async resolves(promise: Promise): Promise { + try { + await promise; + } catch (error) { + assert.fail(error, RESOLVED, "expected value to not reject"); + } + }, +}; diff --git a/tests/utils/assertions/tasks-and-batches.ts b/tests/utils/assertions/tasks-and-batches.ts new file mode 100644 index 000000000..bcf213716 --- /dev/null +++ b/tests/utils/assertions/tasks-and-batches.ts @@ -0,0 +1,229 @@ +import type { + TasksResults, + Batch, + TaskType, + TaskStatus, + EnqueuedTask, + BatchesResults, + Task, +} from "../../../src/types/index.js"; +import { assert } from "vitest"; +import { objectKeys } from "../object.js"; +import { errorAssertions } from "./error.js"; + +export const possibleTaskStatuses = objectKeys({ + enqueued: null, + processing: null, + succeeded: null, + failed: null, + canceled: null, +}); + +export const possibleTaskTypes = objectKeys({ + documentAdditionOrUpdate: null, + documentEdition: null, + documentDeletion: null, + settingsUpdate: null, + indexCreation: null, + indexDeletion: null, + indexUpdate: null, + indexSwap: null, + taskCancelation: null, + taskDeletion: null, + dumpCreation: null, + snapshotCreation: null, + upgradeDatabase: null, +}); + +export const tasksAndBatchesAssertions = { + isEnqueuedTask(enqueuedTask: EnqueuedTask) { + assert.lengthOf(Object.keys(enqueuedTask), 5); + + const { taskUid, indexUid, status, type, enqueuedAt } = enqueuedTask; + + assert.typeOf(taskUid, "number"); + assert( + indexUid === null || typeof indexUid === "string", + `expected ${indexUid} to be null or string`, + ); + assert.oneOf(status, possibleTaskStatuses); + assert.oneOf(type, possibleTaskTypes); + assert.typeOf(enqueuedAt, "string"); + }, + + isBatch(batch: Batch) { + assert.lengthOf(Object.keys(batch), 8); + + const { uid, progress, details, stats, duration, startedAt, finishedAt } = + batch; + + assert.typeOf(uid, "number"); + assert( + typeof progress === "object", + "expected progress to be of type object or null", + ); + + if (progress !== null) { + assert.lengthOf(Object.keys(progress), 2); + const { steps, percentage } = progress; + + for (const step of steps) { + assert.lengthOf(Object.keys(step), 3); + + const { currentStep, finished, total } = step; + + assert.typeOf(currentStep, "string"); + assert.typeOf(finished, "number"); + assert.typeOf(total, "number"); + } + + assert.typeOf(percentage, "number"); + } + + assert.typeOf(details, "object"); + + const { length } = Object.keys(stats); + + assert.isAtLeast(length, 4); + assert.isAtMost(length, 7); + + const { + totalNbTasks, + status, + types, + indexUids, + progressTrace, + writeChannelCongestion, + internalDatabaseSizes, + } = stats; + + assert.typeOf(totalNbTasks, "number"); + + for (const [key, val] of Object.entries(status)) { + assert.oneOf(key, possibleTaskStatuses); + assert.typeOf(val, "number"); + } + + for (const [key, val] of Object.entries(types)) { + assert.oneOf(key, possibleTaskTypes); + assert.typeOf(val, "number"); + } + + for (const val of Object.values(indexUids)) { + assert.typeOf(val, "number"); + } + + assert( + progressTrace === undefined || + (progressTrace !== null && typeof progressTrace === "object"), + "expected progressTrace to be undefined or an object", + ); + + assert( + writeChannelCongestion === undefined || + (writeChannelCongestion !== null && + typeof writeChannelCongestion === "object"), + "expected writeChannelCongestion to be undefined or an object", + ); + + assert( + internalDatabaseSizes === undefined || + (internalDatabaseSizes !== null && + typeof internalDatabaseSizes === "object"), + "expected internalDatabaseSizes to be undefined or an object", + ); + + assert( + duration === null || typeof duration === "string", + "expected duration to be null or string", + ); + + assert.typeOf(startedAt, "string"); + + assert( + finishedAt === null || typeof finishedAt === "string", + "expected finishedAt to be null or string", + ); + }, + + isTasksOrBatchesResults(value: TasksResults | BatchesResults) { + assert.lengthOf(Object.keys(value), 5); + + const { results, total, limit, from, next } = value; + + // it's up to individual tests to assert the exact type of each element + assert.isArray(results); + + assert.typeOf(total, "number"); + assert.typeOf(limit, "number"); + + if (from !== null) { + assert.typeOf(from, "number"); + } + + if (next !== null) { + assert.typeOf(next, "number"); + } + }, + + isTask(task: Task) { + const { length } = Object.keys(task); + + assert.isAtLeast(length, 11); + assert.isAtMost(length, 13); + + const { + indexUid, + status, + type, + enqueuedAt, + uid, + batchUid, + canceledBy, + details, + error, + duration, + startedAt, + finishedAt, + network, + } = task; + + assert(indexUid === null || typeof indexUid === "string"); + + assert.oneOf(status, possibleTaskStatuses); + + assert.oneOf(type, possibleTaskTypes); + + assert.typeOf(enqueuedAt, "string"); + assert.typeOf(uid, "number"); + assert(batchUid === null || typeof batchUid === "number"); + assert(canceledBy === null || typeof canceledBy === "number"); + + // it's up to individual tests to assert the exact shape of this property + assert( + details === undefined || + (details !== null && typeof details === "object"), + ); + + assert(typeof error === "object"); + if (error !== null) { + errorAssertions.isErrorResponse(error); + } + + assert(duration === null || typeof duration === "string"); + assert(startedAt === null || typeof startedAt === "string"); + assert(finishedAt === null || typeof finishedAt === "string"); + + // it's up to individual tests to assert the exact shape of this property + assert( + network === undefined || + (network !== null && typeof network === "object"), + ); + }, + + isTaskSuccessful(task: Task) { + this.isTask(task); + assert.isNull(task.error); + assert.strictEqual(task.status, "succeeded"); + }, +}; diff --git a/tests/utils/meilisearch-test-utils.ts b/tests/utils/meilisearch-test-utils.ts index 234e502d5..1a2121580 100644 --- a/tests/utils/meilisearch-test-utils.ts +++ b/tests/utils/meilisearch-test-utils.ts @@ -1,12 +1,4 @@ -import { assert as vitestAssert } from "vitest"; -import { MeiliSearch, Index } from "../../src/index.js"; -import type { - Config, - TaskType, - MeiliSearchErrorResponse, - TaskStatus, - Task, -} from "../../src/index.js"; +import { type Config, MeiliSearch, Index } from "../../src/index.js"; // testing const MASTER_KEY = "masterKey"; @@ -103,136 +95,6 @@ function decode64(buff: string) { return Buffer.from(buff, "base64").toString(); } -const NOT_RESOLVED = Symbol(""); -const RESOLVED = Symbol(""); - -const source = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async rejects( - promise: Promise, - errorConstructor: T, - errMsgMatcher?: RegExp | string, - ): Promise> { - let resolvedValue; - - try { - resolvedValue = await promise; - } catch (error) { - vitestAssert.instanceOf(error, errorConstructor); - - if (errMsgMatcher !== undefined) { - const { message } = error as Error; - if (typeof errMsgMatcher === "string") { - vitestAssert.strictEqual(message, errMsgMatcher); - } else { - vitestAssert.match(message, errMsgMatcher); - } - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return error as InstanceType; - } - - vitestAssert.fail( - resolvedValue, - NOT_RESOLVED, - "expected value to not resolve", - ); - }, - async resolves(promise: Promise): Promise { - try { - await promise; - } catch (error) { - vitestAssert.fail(error, RESOLVED, "expected value to not reject"); - } - }, - isErrorResponse(error: MeiliSearchErrorResponse) { - vitestAssert.lengthOf(Object.keys(error), 4); - const { message, code, type, link } = error; - for (const val of Object.values({ message, code, type, link })) { - vitestAssert.typeOf(val, "string"); - } - }, - isTask(task: Task) { - const { length } = Object.keys(task); - vitestAssert(length >= 11 && length <= 12); - const { - indexUid, - status, - type, - enqueuedAt, - uid, - batchUid, - canceledBy, - details, - error, - duration, - startedAt, - finishedAt, - } = task; - - vitestAssert(indexUid === null || typeof indexUid === "string"); - - vitestAssert.oneOf( - status, - objectKeys({ - enqueued: null, - processing: null, - succeeded: null, - failed: null, - canceled: null, - }), - ); - - vitestAssert.oneOf( - type, - objectKeys({ - documentAdditionOrUpdate: null, - documentEdition: null, - documentDeletion: null, - settingsUpdate: null, - indexCreation: null, - indexDeletion: null, - indexUpdate: null, - indexSwap: null, - taskCancelation: null, - taskDeletion: null, - dumpCreation: null, - snapshotCreation: null, - upgradeDatabase: null, - }), - ); - - vitestAssert.typeOf(enqueuedAt, "string"); - vitestAssert.typeOf(uid, "number"); - vitestAssert(batchUid === null || typeof batchUid === "number"); - vitestAssert(canceledBy === null || typeof canceledBy === "number"); - - vitestAssert( - details === undefined || - (details !== null && typeof details === "object"), - ); - - vitestAssert(typeof error === "object"); - if (error !== null) { - this.isErrorResponse(error); - } - - vitestAssert(duration === null || typeof duration === "string"); - vitestAssert(startedAt === null || typeof startedAt === "string"); - vitestAssert(finishedAt === null || typeof finishedAt === "string"); - }, - isTaskSuccessful(task: Task) { - this.isTask(task); - vitestAssert.isNull(task.error); - vitestAssert.strictEqual(task.status, "succeeded"); - }, -}; -export const assert: typeof vitestAssert & typeof source = Object.assign( - vitestAssert, - source, -); - const datasetWithNests = [ { id: 1, @@ -344,18 +206,9 @@ export type Book = { author: string; }; -function objectKeys(o: { [TKey in T]: null }): T[] { - return Object.keys(o) as T[]; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const objectEntries = Object.entries as >( - o: T, -) => [key: keyof T, val: T[keyof T]][]; - +export * from "./assert.js"; +export * from "./object.js"; export { - objectEntries, - objectKeys, clearAllIndexes, config, masterClient, diff --git a/tests/utils/object.ts b/tests/utils/object.ts new file mode 100644 index 000000000..e77f5a0cd --- /dev/null +++ b/tests/utils/object.ts @@ -0,0 +1,8 @@ +export function objectKeys(o: { [TKey in T]: null }): T[] { + return Object.keys(o) as T[]; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const objectEntries = Object.entries as >( + o: T, +) => [key: keyof T, val: T[keyof T]][]; diff --git a/tests/utils/tasks-and-batches.ts b/tests/utils/tasks-and-batches.ts deleted file mode 100644 index 79a56ae7c..000000000 --- a/tests/utils/tasks-and-batches.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { assert as extAssert, objectKeys } from "./meilisearch-test-utils.js"; -import type { - TasksResults, - Batch, - TaskType, - TaskStatus, - EnqueuedTask, -} from "../../src/types/index.js"; -import type { SafeOmit } from "../../src/types/shared.js"; - -export const possibleTaskStatuses = objectKeys({ - enqueued: null, - processing: null, - succeeded: null, - failed: null, - canceled: null, -}); - -export const possibleTaskTypes = objectKeys({ - documentAdditionOrUpdate: null, - documentEdition: null, - documentDeletion: null, - settingsUpdate: null, - indexCreation: null, - indexDeletion: null, - indexUpdate: null, - indexSwap: null, - taskCancelation: null, - taskDeletion: null, - dumpCreation: null, - snapshotCreation: null, - upgradeDatabase: null, -}); - -const customAssertions = { - isEnqueuedTask(enqueuedTask: EnqueuedTask) { - extAssert.lengthOf(Object.keys(enqueuedTask), 5); - - const { taskUid, indexUid, status, type, enqueuedAt } = enqueuedTask; - - extAssert.typeOf(taskUid, "number"); - extAssert( - indexUid === null || typeof indexUid === "string", - `expected ${indexUid} to be null or string`, - ); - extAssert.oneOf(status, possibleTaskStatuses); - extAssert.oneOf(type, possibleTaskTypes); - extAssert.typeOf(enqueuedAt, "string"); - }, - - isBatch(batch: Batch) { - extAssert.lengthOf(Object.keys(batch), 8); - - const { uid, progress, details, stats, duration, startedAt, finishedAt } = - batch; - - extAssert.typeOf(uid, "number"); - extAssert( - typeof progress === "object", - "expected progress to be of type object or null", - ); - - if (progress !== null) { - extAssert.lengthOf(Object.keys(progress), 2); - const { steps, percentage } = progress; - - for (const step of steps) { - extAssert.lengthOf(Object.keys(step), 3); - - const { currentStep, finished, total } = step; - - extAssert.typeOf(currentStep, "string"); - extAssert.typeOf(finished, "number"); - extAssert.typeOf(total, "number"); - } - - extAssert.typeOf(percentage, "number"); - } - - extAssert.typeOf(details, "object"); - - const { length } = Object.keys(stats); - - extAssert.isAtLeast(length, 4); - extAssert.isAtMost(length, 7); - - const { - totalNbTasks, - status, - types, - indexUids, - progressTrace, - writeChannelCongestion, - internalDatabaseSizes, - } = stats; - - extAssert.typeOf(totalNbTasks, "number"); - - for (const [key, val] of Object.entries(status)) { - extAssert.oneOf(key, possibleTaskStatuses); - extAssert.typeOf(val, "number"); - } - - for (const [key, val] of Object.entries(types)) { - extAssert.oneOf(key, possibleTaskTypes); - extAssert.typeOf(val, "number"); - } - - for (const val of Object.values(indexUids)) { - extAssert.typeOf(val, "number"); - } - - extAssert( - progressTrace === undefined || - (progressTrace !== null && typeof progressTrace === "object"), - "expected progressTrace to be undefined or an object", - ); - - extAssert( - writeChannelCongestion === undefined || - (writeChannelCongestion !== null && - typeof writeChannelCongestion === "object"), - "expected writeChannelCongestion to be undefined or an object", - ); - - extAssert( - internalDatabaseSizes === undefined || - (internalDatabaseSizes !== null && - typeof internalDatabaseSizes === "object"), - "expected internalDatabaseSizes to be undefined or an object", - ); - - extAssert( - duration === null || typeof duration === "string", - "expected duration to be null or string", - ); - - extAssert.typeOf(startedAt, "string"); - - extAssert( - finishedAt === null || typeof finishedAt === "string", - "expected finishedAt to be null or string", - ); - }, - - isResult(value: SafeOmit) { - extAssert.lengthOf(Object.keys(value), 4); - extAssert.typeOf(value.total, "number"); - extAssert.typeOf(value.limit, "number"); - extAssert( - value.from === null || typeof value.from === "number", - "expected from to be null or number", - ); - extAssert( - value.next === null || typeof value.next === "number", - "expected next to be null or number", - ); - }, -}; - -export const assert: typeof extAssert & typeof customAssertions = Object.assign( - extAssert, - customAssertions, -);