diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 9271498bd..cb9835ebb 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -773,3 +773,29 @@ distinct_attribute_guide_filterable_1: |- client.index('products').updateFilterableAttributes(['product_id', 'sku', 'url']) distinct_attribute_guide_distinct_parameter_1: |- client.index('products').search('white shirt', { distinct: 'sku' }) +multi_search_federated_1: |- + client.multiSearch({ + federation: {}, + queries: [ + { + indexUid: 'movies', + q: 'batman', + limit: 5, + }, + { + indexUid: 'comics', + q: 'batman', + limit: 5, + }, + ] + }) +search_parameter_reference_locales_1: |- + client.index('INDEX_NAME').search('進撃の巨人', { locales: ['jpn'] }) +get_localized_attribute_settings_1: |- + client.index('INDEX_NAME').getLocalizedAttributes() +update_localized_attribute_settings_1: |- + client.index('INDEX_NAME').updateLocalizedAttributes([ + { attributePatterns: ['jpn'], locales: ['*_ja'] }, + ];) +reset_localized_attribute_settings_1: |- + client.index('INDEX_NAME').resetLocalizedAttributes() diff --git a/package.json b/package.json index 3365faa58..2e07c57d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meilisearch", - "version": "0.41.0", + "version": "0.42.0", "description": "The Meilisearch JS client for Node.js and the browser.", "keywords": [ "meilisearch", diff --git a/src/clients/client.ts b/src/clients/client.ts index ac031601a..520cdf687 100644 --- a/src/clients/client.ts +++ b/src/clients/client.ts @@ -5,8 +5,6 @@ * Copyright: 2019, MeiliSearch */ -'use strict'; - import { Index } from '../indexes'; import { KeyCreation, @@ -34,6 +32,8 @@ import { DeleteTasksQuery, MultiSearchParams, MultiSearchResponse, + SearchResponse, + FederatedMultiSearchParams, } from '../types'; import { HttpRequests } from '../http-requests'; import { TaskClient, Task } from '../task'; @@ -216,10 +216,18 @@ class Client { * @param config - Additional request configuration options * @returns Promise containing the search responses */ - async multiSearch = Record>( - queries?: MultiSearchParams, + multiSearch = Record>( + queries: MultiSearchParams, + config?: Partial, + ): Promise>; + multiSearch = Record>( + queries: FederatedMultiSearchParams, + config?: Partial, + ): Promise>; + async multiSearch = Record>( + queries: MultiSearchParams | FederatedMultiSearchParams, config?: Partial, - ): Promise> { + ): Promise | SearchResponse> { const url = `multi-search`; return await this.httpRequest.post(url, queries, undefined, config); diff --git a/src/indexes.ts b/src/indexes.ts index 85c30bf62..af7f36f78 100644 --- a/src/indexes.ts +++ b/src/indexes.ts @@ -54,6 +54,8 @@ import { Embedders, SearchCutoffMs, SearchSimilarDocumentsParams, + LocalizedAttributes, + UpdateDocumentsByFunctionOptions, } from './types'; import { removeUndefinedFromObject } from './utils'; import { HttpRequests } from './http-requests'; @@ -629,6 +631,27 @@ class Index = Record> { return task; } + /** + * This is an EXPERIMENTAL feature, which may break without a major version. + * It's available after Meilisearch v1.10. + * + * More info about the feature: + * https://github.com/orgs/meilisearch/discussions/762 More info about + * experimental features in general: + * https://www.meilisearch.com/docs/reference/api/experimental-features + * + * @param options - Object containing the function string and related options + * @returns Promise containing an EnqueuedTask + */ + async updateDocumentsByFunction( + options: UpdateDocumentsByFunctionOptions, + ): Promise { + const url = `indexes/${this.uid}/documents/edit`; + const task = await this.httpRequest.post(url, options); + + return new EnqueuedTask(task); + } + /// /// SETTINGS /// @@ -1393,6 +1416,47 @@ class Index = Record> { return new EnqueuedTask(task); } + + /// + /// LOCALIZED ATTRIBUTES SETTINGS + /// + + /** + * Get the localized attributes settings. + * + * @returns Promise containing object of localized attributes settings + */ + async getLocalizedAttributes(): Promise { + const url = `indexes/${this.uid}/settings/localized-attributes`; + return await this.httpRequest.get(url); + } + + /** + * Update the localized attributes settings. + * + * @param localizedAttributes - Localized attributes object + * @returns Promise containing an EnqueuedTask + */ + async updateLocalizedAttributes( + localizedAttributes: LocalizedAttributes, + ): Promise { + const url = `indexes/${this.uid}/settings/localized-attributes`; + const task = await this.httpRequest.put(url, localizedAttributes); + + return new EnqueuedTask(task); + } + + /** + * Reset the localized attributes settings. + * + * @returns Promise containing an EnqueuedTask + */ + async resetLocalizedAttributes(): Promise { + const url = `indexes/${this.uid}/settings/localized-attributes`; + const task = await this.httpRequest.delete(url); + + return new EnqueuedTask(task); + } } export { Index }; diff --git a/src/package-version.ts b/src/package-version.ts index ba934c551..ab0980700 100644 --- a/src/package-version.ts +++ b/src/package-version.ts @@ -1 +1 @@ -export const PACKAGE_VERSION = '0.41.0'; +export const PACKAGE_VERSION = '0.42.0'; diff --git a/src/types/types.ts b/src/types/types.ts index 7406b88c0..768c429da 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -108,6 +108,9 @@ export type HybridSearch = { semanticRatio?: number; }; +// https://www.meilisearch.com/docs/reference/api/settings#localized-attributes +export type Locale = string; + export type SearchParams = Query & Pagination & Highlight & @@ -130,6 +133,7 @@ export type SearchParams = Query & hybrid?: HybridSearch; distinct?: string; retrieveVectors?: boolean; + locales?: Locale[]; }; // Search parameters for searches made with the GET method @@ -152,13 +156,24 @@ export type SearchRequestGET = Pagination & rankingScoreThreshold?: number; distinct?: string; retrieveVectors?: boolean; + locales?: Locale[]; }; +export type FederationOptions = { weight: number }; +export type MultiSearchFederation = { limit?: number; offset?: number }; + export type MultiSearchQuery = SearchParams & { indexUid: string }; +export type MultiSearchQueryWithFederation = MultiSearchQuery & { + federationOptions?: FederationOptions; +}; export type MultiSearchParams = { queries: MultiSearchQuery[]; }; +export type FederatedMultiSearchParams = { + federation: MultiSearchFederation; + queries: MultiSearchQueryWithFederation[]; +}; export type CategoriesDistribution = { [category: string]: number; @@ -170,13 +185,6 @@ export type MatchesPosition = Partial< Record> >; -export type Hit> = T & { - _formatted?: Partial; - _matchesPosition?: MatchesPosition; - _rankingScore?: number; - _rankingScoreDetails?: RankingScoreDetails; -}; - export type RankingScoreDetails = { words?: { order: number; @@ -208,6 +216,20 @@ export type RankingScoreDetails = { [key: string]: Record | undefined; }; +export type FederationDetails = { + indexUid: string; + queriesPosition: number; + weightedRankingScore: number; +}; + +export type Hit> = T & { + _formatted?: Partial; + _matchesPosition?: MatchesPosition; + _rankingScore?: number; + _rankingScoreDetails?: RankingScoreDetails; + _federation?: FederationDetails; +}; + export type Hits> = Array>; export type FacetStat = { min: number; max: number }; @@ -326,6 +348,12 @@ export type DocumentsDeletionQuery = { export type DocumentsIds = string[] | number[]; +export type UpdateDocumentsByFunctionOptions = { + function: string; + filter?: string | string[]; + context?: Record; +}; + /* ** Settings */ @@ -366,6 +394,7 @@ export type OpenAiEmbedder = { documentTemplate?: string; dimensions?: number; distribution?: Distribution; + url?: string; }; export type HuggingFaceEmbedder = { @@ -388,12 +417,10 @@ export type RestEmbedder = { apiKey?: string; dimensions?: number; documentTemplate?: string; - inputField?: string[] | null; - inputType?: 'text' | 'textArray'; - query?: Record | null; - pathToEmbeddings?: string[] | null; - embeddingObject?: string[] | null; distribution?: Distribution; + request: Record; + response: Record; + headers?: Record; }; export type OllamaEmbedder = { @@ -403,6 +430,7 @@ export type OllamaEmbedder = { model?: string; documentTemplate?: string; distribution?: Distribution; + dimensions?: number; }; export type Embedder = @@ -428,6 +456,13 @@ export type PaginationSettings = { export type SearchCutoffMs = number | null; +export type LocalizedAttribute = { + attributePatterns: string[]; + locales: Locale[]; +}; + +export type LocalizedAttributes = LocalizedAttribute[] | null; + export type Settings = { filterableAttributes?: FilterableAttributes; distinctAttribute?: DistinctAttribute; @@ -446,6 +481,7 @@ export type Settings = { proximityPrecision?: ProximityPrecision; embedders?: Embedders; searchCutoffMs?: SearchCutoffMs; + localizedAttributes?: LocalizedAttributes; }; /* @@ -677,9 +713,9 @@ export interface FetchError extends Error { export type MeiliSearchErrorResponse = { message: string; - // @TODO: Could be typed, but will it be kept updated? https://www.meilisearch.com/docs/reference/errors/error_codes + // https://www.meilisearch.com/docs/reference/errors/error_codes code: string; - // @TODO: Could be typed https://www.meilisearch.com/docs/reference/errors/overview#errors + // https://www.meilisearch.com/docs/reference/errors/overview#errors type: string; link: string; }; @@ -992,6 +1028,10 @@ export const ErrorStatusCode = { /** @see https://www.meilisearch.com/docs/reference/errors/error_codes#invalid_settings_search_cutoff_ms */ INVALID_SETTINGS_SEARCH_CUTOFF_MS: 'invalid_settings_search_cutoff_ms', + /** @see https://www.meilisearch.com/docs/reference/errors/error_codes#invalid_settings_search_cutoff_ms */ + INVALID_SETTINGS_LOCALIZED_ATTRIBUTES: + 'invalid_settings_localized_attributes', + /** @see https://www.meilisearch.com/docs/reference/errors/error_codes#invalid_task_before_enqueued_at */ INVALID_TASK_BEFORE_ENQUEUED_AT: 'invalid_task_before_enqueued_at', diff --git a/tests/__snapshots__/settings.test.ts.snap b/tests/__snapshots__/settings.test.ts.snap index 0f359e2dd..6e35dca91 100644 --- a/tests/__snapshots__/settings.test.ts.snap +++ b/tests/__snapshots__/settings.test.ts.snap @@ -14,6 +14,7 @@ exports[`Test on settings Admin key: Get default settings of an index 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -61,6 +62,7 @@ exports[`Test on settings Admin key: Get default settings of empty index with pr }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -108,6 +110,7 @@ exports[`Test on settings Admin key: Reset embedders settings 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -155,6 +158,7 @@ exports[`Test on settings Admin key: Reset settings 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -202,6 +206,7 @@ exports[`Test on settings Admin key: Reset settings of empty index 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -257,6 +262,7 @@ exports[`Test on settings Admin key: Update embedders settings 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -304,6 +310,7 @@ exports[`Test on settings Admin key: Update searchableAttributes settings on emp }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -351,6 +358,7 @@ exports[`Test on settings Admin key: Update searchableAttributes settings on emp }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -403,6 +411,7 @@ exports[`Test on settings Admin key: Update settings 1`] = ` "filterableAttributes": [ "title", ], + "localizedAttributes": null, "nonSeparatorTokens": [ "&sep", "/", @@ -466,6 +475,7 @@ exports[`Test on settings Admin key: Update settings on empty index with primary }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -511,6 +521,7 @@ exports[`Test on settings Admin key: Update settings with all null values 1`] = }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -558,6 +569,7 @@ exports[`Test on settings Master key: Get default settings of an index 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -605,6 +617,7 @@ exports[`Test on settings Master key: Get default settings of empty index with p }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -652,6 +665,7 @@ exports[`Test on settings Master key: Reset embedders settings 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -699,6 +713,7 @@ exports[`Test on settings Master key: Reset settings 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -746,6 +761,7 @@ exports[`Test on settings Master key: Reset settings of empty index 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -801,6 +817,7 @@ exports[`Test on settings Master key: Update embedders settings 1`] = ` }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -848,6 +865,7 @@ exports[`Test on settings Master key: Update searchableAttributes settings on em }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -895,6 +913,7 @@ exports[`Test on settings Master key: Update searchableAttributes settings on em }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -947,6 +966,7 @@ exports[`Test on settings Master key: Update settings 1`] = ` "filterableAttributes": [ "title", ], + "localizedAttributes": null, "nonSeparatorTokens": [ "&sep", "/", @@ -1010,6 +1030,7 @@ exports[`Test on settings Master key: Update settings on empty index with primar }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, @@ -1055,6 +1076,7 @@ exports[`Test on settings Master key: Update settings with all null values 1`] = }, }, "filterableAttributes": [], + "localizedAttributes": null, "nonSeparatorTokens": [], "pagination": { "maxTotalHits": 1000, diff --git a/tests/documents.test.ts b/tests/documents.test.ts index 8cb232e26..ac52be52c 100644 --- a/tests/documents.test.ts +++ b/tests/documents.test.ts @@ -144,7 +144,12 @@ describe('Documents tests', () => { test(`${permission} key: Get documents with filters`, async () => { const client = await getClient(permission); - await client.index(indexPk.uid).updateFilterableAttributes(['id']); + + const { taskUid: updateFilterableAttributesTaskUid } = await client + .index(indexPk.uid) + .updateFilterableAttributes(['id']); + await client.waitForTask(updateFilterableAttributesTaskUid); + const { taskUid } = await client .index(indexPk.uid) .addDocuments(dataset); @@ -780,6 +785,42 @@ Hint: It might not be working because maybe you're not up to date with the Meili expect(index.primaryKey).toEqual(null); expect(task.status).toEqual('failed'); }); + + test(`${permission} key: test updateDocumentsByFunction`, async () => { + const client = await getClient(permission); + const index = client.index<(typeof dataset)[number]>(indexPk.uid); + const adminKey = await getKey('Admin'); + + const { taskUid: updateFilterableAttributesTaskUid } = + await index.updateFilterableAttributes(['id']); + await client.waitForTask(updateFilterableAttributesTaskUid); + + await fetch(`${HOST}/experimental-features`, { + body: JSON.stringify({ editDocumentsByFunction: true }), + headers: { + Authorization: `Bearer ${adminKey}`, + 'Content-Type': 'application/json', + }, + method: 'PATCH', + }); + + const { taskUid: addDocumentsTaskUid } = + await index.addDocuments(dataset); + await index.waitForTask(addDocumentsTaskUid); + + const { taskUid: updateDocumentsByFunctionTaskUid } = + await index.updateDocumentsByFunction({ + context: { ctx: 'Harry' }, + filter: 'id = 4', + function: 'doc.comment = `Yer a wizard, ${context.ctx}!`', + }); + + await client.waitForTask(updateDocumentsByFunctionTaskUid); + + const doc = await index.getDocument(4); + + expect(doc).toHaveProperty('comment', 'Yer a wizard, Harry!'); + }); }, ); @@ -831,6 +872,24 @@ Hint: It might not be working because maybe you're not up to date with the Meili client.index(indexPk.uid).deleteAllDocuments(), ).rejects.toHaveProperty('cause.code', ErrorStatusCode.INVALID_API_KEY); }); + + test(`${permission} key: Try updateDocumentsByFunction and be denied`, async () => { + const client = await getClient(permission); + const adminKey = await getKey('Admin'); + + await fetch(`${HOST}/experimental-features`, { + body: JSON.stringify({ editDocumentsByFunction: true }), + headers: { + Authorization: `Bearer ${adminKey}`, + 'Content-Type': 'application/json', + }, + method: 'PATCH', + }); + + await expect( + client.index(indexPk.uid).updateDocumentsByFunction({ function: '' }), + ).rejects.toHaveProperty('cause.code', ErrorStatusCode.INVALID_API_KEY); + }); }, ); @@ -900,6 +959,27 @@ Hint: It might not be working because maybe you're not up to date with the Meili ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, ); }); + + test(`${permission} key: Try updateDocumentsByFunction and be denied`, async () => { + const client = await getClient(permission); + const adminKey = await getKey('Admin'); + + await fetch(`${HOST}/experimental-features`, { + body: JSON.stringify({ editDocumentsByFunction: true }), + headers: { + Authorization: `Bearer ${adminKey}`, + 'Content-Type': 'application/json', + }, + method: 'PATCH', + }); + + await expect( + client.index(indexPk.uid).updateDocumentsByFunction({ function: '' }), + ).rejects.toHaveProperty( + 'cause.code', + ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, + ); + }); }, ); @@ -991,5 +1071,28 @@ Hint: It might not be working because maybe you're not up to date with the Meili `Request to ${strippedHost}/${route} has failed`, ); }); + + test(`Test updateDocumentsByFunction route`, async () => { + const route = `indexes/${indexPk.uid}/documents/edit`; + const client = new MeiliSearch({ host }); + const strippedHost = trailing ? host.slice(0, -1) : host; + const adminKey = await getKey('Admin'); + + await fetch(`${HOST}/experimental-features`, { + body: JSON.stringify({ editDocumentsByFunction: true }), + headers: { + Authorization: `Bearer ${adminKey}`, + 'Content-Type': 'application/json', + }, + method: 'PATCH', + }); + + await expect( + client.index(indexPk.uid).updateDocumentsByFunction({ function: '' }), + ).rejects.toHaveProperty( + 'message', + `Request to ${strippedHost}/${route} has failed`, + ); + }); }); }); diff --git a/tests/embedders.test.ts b/tests/embedders.test.ts index 73d58745c..68d927d9c 100644 --- a/tests/embedders.test.ts +++ b/tests/embedders.test.ts @@ -118,6 +118,7 @@ describe.each([{ permission: 'Master' }, { permission: 'Admin' }])( mean: 0.7, sigma: 0.3, }, + url: 'https://api.openai.com/v1/embeddings', }, }; const task: EnqueuedTask = await client @@ -169,17 +170,25 @@ describe.each([{ permission: 'Master' }, { permission: 'Admin' }])( dimensions: 1536, documentTemplate: "A movie titled '{{doc.title}}' whose description starts with {{doc.overview|truncatewords: 20}}", - inputField: ['input'], - inputType: 'textArray', - query: { - model: 'text-embedding-ada-002', - }, - pathToEmbeddings: ['data'], - embeddingObject: ['embedding'], distribution: { mean: 0.7, sigma: 0.3, }, + request: { + model: 'text-embedding-3-small', + input: ['{{text}}', '{{..}}'], + }, + response: { + data: [ + { + embedding: '{{embedding}}', + }, + '{{..}}', + ], + }, + headers: { + 'Custom-Header': 'CustomValue', + }, }, }; const task: EnqueuedTask = await client @@ -197,7 +206,7 @@ describe.each([{ permission: 'Master' }, { permission: 'Admin' }])( }); }); - test.skip(`${permission} key: Update embedders with 'ollama' source`, async () => { + test(`${permission} key: Update embedders with 'ollama' source`, async () => { const client = await getClient(permission); const newEmbedder: Embedders = { default: { @@ -210,6 +219,7 @@ describe.each([{ permission: 'Master' }, { permission: 'Admin' }])( mean: 0.7, sigma: 0.3, }, + dimensions: 512, }, }; const task: EnqueuedTask = await client diff --git a/tests/localized_attributes.test.ts b/tests/localized_attributes.test.ts new file mode 100644 index 000000000..42dcd380a --- /dev/null +++ b/tests/localized_attributes.test.ts @@ -0,0 +1,212 @@ +import { ErrorStatusCode, type LocalizedAttributes } from '../src/types'; +import { + clearAllIndexes, + config, + BAD_HOST, + MeiliSearch, + getClient, + dataset, +} from './utils/meilisearch-test-utils'; + +const index = { + uid: 'movies_test', +}; + +const DEFAULT_LOCALIZED_ATTRIBUTES = null; + +jest.setTimeout(100 * 1000); + +afterAll(() => { + return clearAllIndexes(config); +}); + +describe.each([{ permission: 'Master' }, { permission: 'Admin' }])( + 'Test on localizedAttributes', + ({ permission }) => { + beforeEach(async () => { + await clearAllIndexes(config); + const client = await getClient('Master'); + const { taskUid } = await client.index(index.uid).addDocuments(dataset); + await client.waitForTask(taskUid); + }); + + test(`${permission} key: Get default localizedAttributes settings`, async () => { + const client = await getClient(permission); + const response = await client.index(index.uid).getLocalizedAttributes(); + + expect(response).toEqual(DEFAULT_LOCALIZED_ATTRIBUTES); + }); + + test(`${permission} key: Update localizedAttributes to valid value`, async () => { + const client = await getClient(permission); + const newLocalizedAttributes: LocalizedAttributes = [ + { attributePatterns: ['title'], locales: ['eng'] }, + ]; + const task = await client + .index(index.uid) + .updateLocalizedAttributes(newLocalizedAttributes); + await client.waitForTask(task.taskUid); + + const response = await client.index(index.uid).getLocalizedAttributes(); + + expect(response).toEqual(newLocalizedAttributes); + }); + + test(`${permission} key: Update localizedAttributes to null`, async () => { + const client = await getClient(permission); + const newLocalizedAttributes = null; + const task = await client + .index(index.uid) + .updateLocalizedAttributes(newLocalizedAttributes); + await client.index(index.uid).waitForTask(task.taskUid); + + const response = await client.index(index.uid).getLocalizedAttributes(); + + expect(response).toEqual(DEFAULT_LOCALIZED_ATTRIBUTES); + }); + + test(`${permission} key: Update localizedAttributes with invalid value`, async () => { + const client = await getClient(permission); + const newLocalizedAttributes = 'hello' as any; // bad localizedAttributes value + + await expect( + client + .index(index.uid) + .updateLocalizedAttributes(newLocalizedAttributes), + ).rejects.toHaveProperty( + 'cause.code', + ErrorStatusCode.INVALID_SETTINGS_LOCALIZED_ATTRIBUTES, + ); + }); + + test(`${permission} key: Reset localizedAttributes`, async () => { + const client = await getClient(permission); + const newLocalizedAttributes: LocalizedAttributes = []; + const updateTask = await client + .index(index.uid) + .updateLocalizedAttributes(newLocalizedAttributes); + await client.waitForTask(updateTask.taskUid); + const task = await client.index(index.uid).resetLocalizedAttributes(); + await client.waitForTask(task.taskUid); + + const response = await client.index(index.uid).getLocalizedAttributes(); + + expect(response).toEqual(DEFAULT_LOCALIZED_ATTRIBUTES); + }); + }, +); + +describe.each([{ permission: 'Search' }])( + 'Test on localizedAttributes', + ({ permission }) => { + beforeEach(async () => { + const client = await getClient('Master'); + const { taskUid } = await client.createIndex(index.uid); + await client.waitForTask(taskUid); + }); + + test(`${permission} key: try to get localizedAttributes and be denied`, async () => { + const client = await getClient(permission); + await expect( + client.index(index.uid).getLocalizedAttributes(), + ).rejects.toHaveProperty('cause.code', ErrorStatusCode.INVALID_API_KEY); + }); + + test(`${permission} key: try to update localizedAttributes and be denied`, async () => { + const client = await getClient(permission); + await expect( + client.index(index.uid).updateLocalizedAttributes([]), + ).rejects.toHaveProperty('cause.code', ErrorStatusCode.INVALID_API_KEY); + }); + + test(`${permission} key: try to reset localizedAttributes and be denied`, async () => { + const client = await getClient(permission); + await expect( + client.index(index.uid).resetLocalizedAttributes(), + ).rejects.toHaveProperty('cause.code', ErrorStatusCode.INVALID_API_KEY); + }); + }, +); + +describe.each([{ permission: 'No' }])( + 'Test on localizedAttributes', + ({ permission }) => { + beforeAll(async () => { + const client = await getClient('Master'); + const { taskUid } = await client.createIndex(index.uid); + await client.waitForTask(taskUid); + }); + + test(`${permission} key: try to get localizedAttributes and be denied`, async () => { + const client = await getClient(permission); + await expect( + client.index(index.uid).getLocalizedAttributes(), + ).rejects.toHaveProperty( + 'cause.code', + ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, + ); + }); + + test(`${permission} key: try to update localizedAttributes and be denied`, async () => { + const client = await getClient(permission); + await expect( + client.index(index.uid).updateLocalizedAttributes([]), + ).rejects.toHaveProperty( + 'cause.code', + ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, + ); + }); + + test(`${permission} key: try to reset localizedAttributes and be denied`, async () => { + const client = await getClient(permission); + await expect( + client.index(index.uid).resetLocalizedAttributes(), + ).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(`Test getLocalizedAttributes route`, async () => { + const route = `indexes/${index.uid}/settings/localized-attributes`; + const client = new MeiliSearch({ host }); + const strippedHost = trailing ? host.slice(0, -1) : host; + await expect( + client.index(index.uid).getLocalizedAttributes(), + ).rejects.toHaveProperty( + 'message', + `Request to ${strippedHost}/${route} has failed`, + ); + }); + + test(`Test updateLocalizedAttributes route`, async () => { + const route = `indexes/${index.uid}/settings/localized-attributes`; + const client = new MeiliSearch({ host }); + const strippedHost = trailing ? host.slice(0, -1) : host; + await expect( + client.index(index.uid).updateLocalizedAttributes(null), + ).rejects.toHaveProperty( + 'message', + `Request to ${strippedHost}/${route} has failed`, + ); + }); + + test(`Test resetLocalizedAttributes route`, async () => { + const route = `indexes/${index.uid}/settings/localized-attributes`; + const client = new MeiliSearch({ host }); + const strippedHost = trailing ? host.slice(0, -1) : host; + await expect( + client.index(index.uid).resetLocalizedAttributes(), + ).rejects.toHaveProperty( + 'message', + `Request to ${strippedHost}/${route} has failed`, + ); + }); +}); diff --git a/tests/search.test.ts b/tests/search.test.ts index 57aca68e5..e1d66f64f 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -143,6 +143,48 @@ describe.each([ expect(response.results[0].hits[0].title).toEqual('Le Petit Prince'); }); + test(`${permission} key: Multi index search with federation`, async () => { + const client = await getClient(permission); + + const response1 = await client.multiSearch< + Books | { id: number; asd: string } + >({ + federation: {}, + queries: [ + { indexUid: index.uid, q: '456', attributesToSearchOn: ['id'] }, + { + indexUid: index.uid, + q: '1344', + federationOptions: { weight: 0.9 }, + attributesToSearchOn: ['id'], + }, + ], + }); + + expect(response1).toHaveProperty('hits'); + expect(Array.isArray(response1.hits)).toBe(true); + expect(response1.hits.length).toEqual(2); + expect(response1.hits[0].id).toEqual(456); + + const response2 = await client.multiSearch({ + federation: {}, + queries: [ + { + indexUid: index.uid, + q: '456', + federationOptions: { weight: 0.9 }, + attributesToSearchOn: ['id'], + }, + { indexUid: index.uid, q: '1344', attributesToSearchOn: ['id'] }, + ], + }); + + expect(response2).toHaveProperty('hits'); + expect(Array.isArray(response2.hits)).toBe(true); + expect(response2.hits.length).toEqual(2); + expect(response2.hits[0].id).toEqual(1344); + }); + test(`${permission} key: Basic search`, async () => { const client = await getClient(permission); const response = await client.index(index.uid).search('prince', {}); @@ -982,6 +1024,24 @@ describe.each([ expect(response.hits[0]).not.toHaveProperty('_vectors'); }); + test(`${permission} key: Search with locales`, async () => { + const client = await getClient(permission); + const masterClient = await getClient('Master'); + + const { taskUid } = await masterClient + .index(index.uid) + .updateLocalizedAttributes([ + { attributePatterns: ['title', 'comment'], locales: ['fra', 'eng'] }, + ]); + await masterClient.waitForTask(taskUid); + + const searchResponse = await client.index(index.uid).search('french', { + locales: ['fra', 'eng'], + }); + + expect(searchResponse.hits.length).toEqual(2); + }); + test(`${permission} key: Try to search on deleted index and fail`, async () => { const client = await getClient(permission); const masterClient = await getClient('Master'); diff --git a/tests/searchCutoffMs.ts b/tests/search_cutoff_ms.test.ts similarity index 100% rename from tests/searchCutoffMs.ts rename to tests/search_cutoff_ms.test.ts diff --git a/tests/utils/meilisearch-test-utils.ts b/tests/utils/meilisearch-test-utils.ts index d1313328c..e9d06c747 100644 --- a/tests/utils/meilisearch-test-utils.ts +++ b/tests/utils/meilisearch-test-utils.ts @@ -80,7 +80,7 @@ const clearAllIndexes = async (config: Config): Promise => { const { results } = await client.getRawIndexes(); const indexes = results.map((elem) => elem.uid); - const taskIds = []; + const taskIds: number[] = []; for (const indexUid of indexes) { const { taskUid } = await client.index(indexUid).delete(); taskIds.push(taskUid); @@ -144,7 +144,7 @@ const datasetWithNests = [ { id: 7, title: "The Hitchhiker's Guide to the Galaxy" }, ]; -const dataset = [ +const dataset: Array<{ id: number; title: string; comment?: string }> = [ { id: 123, title: 'Pride and Prejudice', comment: 'A great book' }, { id: 456, title: 'Le Petit Prince', comment: 'A french book' }, { id: 2, title: 'Le Rouge et le Noir', comment: 'Another french book' },