diff --git a/.code-samples.meilisearch.yaml b/.code-samples.meilisearch.yaml index 5d4788ece..0e72e8d6f 100644 --- a/.code-samples.meilisearch.yaml +++ b/.code-samples.meilisearch.yaml @@ -14,13 +14,15 @@ date_guide_index_1: |- date_guide_filterable_attributes_1: |- client.index('games').updateFilterableAttributes(['release_timestamp']) date_guide_filter_1: |- - client.index('games').search('', { + client.index('games').search({ + q: '', filter: 'release_timestamp >= 1514761200 AND release_timestamp < 1672527600' }) date_guide_sortable_attributes_1: |- client.index('games').updateSortableAttributes(['release_timestamp']) date_guide_sort_1: |- - client.index('games').search('', { + client.index('games').search({ + q: '', sort: ['release_timestamp:desc'], }) get_one_index_1: |- @@ -78,9 +80,9 @@ delete_documents_by_filter_1: |- filter: 'genres = action OR genres = adventure' }) search_post_1: |- - client.index('movies').search('American ninja') + client.index('movies').search({ q: 'American ninja' }) search_get_1: |- - client.index('movies').searchGet('American ninja') + client.index('movies').searchGet({ q: 'American ninja' }) multi_search_1: |- client.multiSearch({ queries: [ { @@ -302,81 +304,99 @@ field_properties_guide_displayed_1: |- ] ) filtering_guide_1: |- - client.index('movie_ratings').search('Avengers', { + client.index('movie_ratings').search({ + q: 'Avengers', filter: 'release_date > 795484800' }) filtering_guide_2: |- - client.index('movie_ratings').search('Batman', { + client.index('movie_ratings').search({ + q: 'Batman', filter: 'release_date > 795484800 AND (director = "Tim Burton" OR director = "Christopher Nolan")' }) filtering_guide_3: |- - client.index('movie_ratings').search('Planet of the Apes', { + client.index('movie_ratings').search({ + q: 'Planet of the Apes', filter: "release_date > 1577884550 AND (NOT director = \"Tim Burton\")" }) filtering_guide_nested_1: |- - client.index('movie_ratings').search('thriller', { + client.index('movie_ratings').search({ + q: 'thriller', filter: 'rating.users >= 90' }) search_parameter_guide_query_1: |- - client.index('movies').search('shifu') + client.index('movies').search({ q: 'shifu' }) search_parameter_guide_offset_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', offset: 1 }) search_parameter_guide_limit_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', limit: 2 }) search_parameter_guide_retrieve_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', attributesToRetrieve: ['overview', 'title'] }) search_parameter_guide_crop_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', attributesToCrop: ['overview'], cropLength: 5 }) search_parameter_guide_crop_marker_1: |- - client.index('movies').search('shifu', { + client.index('movies').search({ + q: 'shifu', attributesToCrop: ['overview'], cropMarker: '[…]' }) search_parameter_guide_highlight_1: |- - client.index('movies').search('winter feast', { + client.index('movies').search({ + q: 'winter feast', attributesToHighlight: ['overview'] }) search_parameter_guide_highlight_tag_1: |- - client.index('movies').search('winter feast', { + client.index('movies').search({ + q: 'winter feast', attributesToHighlight: ['overview'], highlightPreTag: '', highlightPostTag: '' }) search_parameter_guide_show_matches_position_1: |- - client.index('movies').search('winter feast', { + client.index('movies').search({ + q: 'winter feast', showMatchesPosition: true }) search_parameter_guide_matching_strategy_1: |- - client.index('movies').search('big fat liar', { + client.index('movies').search({ + q: 'big fat liar', matchingStrategy: 'last' }) search_parameter_guide_matching_strategy_2: |- - client.index('movies').search('big fat liar', { + client.index('movies').search({ + q: 'big fat liar', matchingStrategy: 'all' }) search_parameter_guide_hitsperpage_1: |- - client.index('movies').search('', { + client.index('movies').search({ + q: '', hitsPerPage: 15 }) search_parameter_guide_page_1: |- - client.index('movies').search('', { + client.index('movies').search({ + q: '', page: 2 }) search_parameter_guide_show_ranking_score_1: |- - client.index('movies').search('dragon', { + client.index('movies').search({ + q: 'dragon', showRankingScore: true }) search_parameter_guide_attributes_to_search_on_1: |- - client.index('movies').search('adventure', { + client.index('movies').search({ + q: 'adventure', attributesToSearchOn: ['overview'] }) typo_tolerance_guide_1: |- @@ -439,7 +459,7 @@ getting_started_add_documents: |- client.index('movies').addDocuments(movies) .then((res) => console.log(res)) getting_started_search: |- - client.index('movies').search('botman').then((res) => console.log(res)) + client.index('movies').search({ q: 'botman' }).then((res) => console.log(res)) getting_started_update_ranking_rules: |- client.index('movies').updateRankingRules([ 'exactness', @@ -480,11 +500,14 @@ getting_started_configure_settings: |- sortableAttributes: ['mass', '_geo'] }) getting_started_geo_radius: |- - client.index('meteorites').search('', { filter: '_geoRadius(46.9480, 7.4474, 210000)' }) + client.index('meteorites').search({ + q: '', filter: '_geoRadius(46.9480, 7.4474, 210000)' }) getting_started_geo_point: |- - client.index('meteorites').search('', { sort: ['_geoPoint(48.8583701, 2.2922926):asc'] }) + client.index('meteorites').search({ + q: '', sort: ['_geoPoint(48.8583701, 2.2922926):asc'] }) getting_started_sorting: |- - client.index('meteorites').search('', { + client.index('meteorites').search({ + q: '', sort: ['mass:asc'], filter: 'mass < 200' }) @@ -502,7 +525,8 @@ getting_started_typo_tolerance: |- } }) getting_started_filtering: |- - client.index('meteorites').search('', { filter: 'mass < 200' }) + client.index('meteorites').search({ + q: '', filter: 'mass < 200' }) getting_started_pagination: |- client.index('movies').updatePagination({ maxTotalHits: 500 }) get_filterable_attributes_1: |- @@ -529,20 +553,21 @@ filtering_update_settings_1: |- ]) faceted_search_walkthrough_filter_1: |- client.index('movies') - .search('thriller', { + .search({ + q: 'thriller', filter: [['genres = Horror', 'genres = Mystery'], 'director = "Jordan Peele"'] }) faceted_search_update_settings_1: |- client.index('movie_ratings').updateFilterableAttributes(['genres', 'rating', 'language']) faceted_search_1: |- - client.index('books').search('classic', { facets: ['genres', 'rating', 'language'] }) + client.index('books').search({ q: 'classic', facets: ['genres', 'rating', 'language'] }) post_dump_1: |- client.createDump() create_snapshot_1: |- client.createSnapshot() phrase_search_1: |- client.index('movies') - .search('"african american" horror') + .search({ q: '"african american" horror' }) sorting_guide_update_sortable_attributes_1: |- client.index('books').updateSortableAttributes([ 'author', @@ -558,15 +583,18 @@ sorting_guide_update_ranking_rules_1: |- 'exactness' ]) sorting_guide_sort_parameter_1: |- - client.index('books').search('science fiction', { + client.index('books').search({ + q: 'science fiction', sort: ['price:asc'], }) sorting_guide_sort_parameter_2: |- - client.index('books').search('butler', { + client.index('books').search({ + q: 'butler', sort: ['author:desc'], }) sorting_guide_sort_nested_1: |- - client.index('books').search('science fiction', { + client.index('books').search({ + q: 'science fiction', 'sort': ['rating.users:asc'], }) get_sortable_attributes_1: |- @@ -604,7 +632,8 @@ update_dictionary_1: |- reset_dictionary_1: |- client.index('books').resetDictionary() search_parameter_guide_sort_1: |- - client.index('books').search('science fiction', { + client.index('books').search({ + q: 'science fiction', sort: ['price:asc'], }) get_separator_tokens_1: |- @@ -632,22 +661,25 @@ update_search_cutoff_1: |- reset_search_cutoff_1: |- client.index('movies').resetSearchCutoffMs() search_parameter_guide_facet_stats_1: |- - client.index('movie_ratings').search('Batman', { facets: ['genres', 'rating'] }) + client.index('movie_ratings').search({ q: 'Batman', facets: ['genres', 'rating'] }) geosearch_guide_filter_settings_1: |- client.index('restaurants') .updateFilterableAttributes([ '_geo' ]) geosearch_guide_filter_usage_1: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', filter: ['_geoRadius(45.472735, 9.184019, 2000)'], }) geosearch_guide_filter_usage_2: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', filter: ['_geoRadius(45.472735, 9.184019, 2000) AND type = pizza'], }) geosearch_guide_filter_usage_3: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', filter: ['_geoBoundingBox([45.494181, 9.214024], [45.449484, 9.179175])'], }) geosearch_guide_sort_settings_1: |- @@ -655,11 +687,13 @@ geosearch_guide_sort_settings_1: |- '_geo' ]) geosearch_guide_sort_usage_1: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', sort: ['_geoPoint(48.8561446, 2.2978204):asc'], }) geosearch_guide_sort_usage_2: |- - client.index('restaurants').search('', { + client.index('restaurants').search({ + q: '', sort: ['_geoPoint(48.8561446, 2.2978204):asc', 'rating:desc'], }) security_guide_search_key_1: |- @@ -702,7 +736,7 @@ tenant_token_guide_generate_sdk_1: |- const token = await generateTenantToken({ apiKey, apiKeyUid, searchRules, expiresAt }) tenant_token_guide_search_sdk_1: |- const frontEndClient = new MeiliSearch({ host: 'http://localhost:7700', apiKey: token }) - frontEndClient.index('patient_medical_records').search('blood test') + frontEndClient.index('patient_medical_records').search({ q: 'blood test' }) landing_getting_started_1: |- const client = new MeiliSearch('http://localhost:7700', 'masterKey') @@ -716,7 +750,7 @@ landing_getting_started_1: |- ]) // be aware this client is using the masterKey, it should not be used in front end - const search = await index.search('philodelphia') + const search = await index.search({ q: 'philodelphia' }) console.log(search) facet_search_1: |- client.index('books').searchForFacetValues({ @@ -736,22 +770,24 @@ facet_search_3: |- facetName: 'genres' }) search_parameter_guide_show_ranking_score_details_1: |- - client.index('movies').search('dragon', { showRankingScoreDetails: true }) + client.index('movies').search({ q: 'dragon', showRankingScoreDetails: true }) negative_search_1: |- - client.index('movies').search('-escape') + client.index('movies').search({ q: '-escape' }) negative_search_2: |- - client.index('movies').search('-"escape"') + client.index('movies').search({ q: '-"escape"' }) search_parameter_reference_ranking_score_threshold_1: |- - client.index('INDEX_NAME').search('badman', { rankingScoreThreshold: 0.2 }) + client.index('INDEX_NAME').search({ q: 'badman', rankingScoreThreshold: 0.2 }) search_parameter_reference_retrieve_vectors_1: |- - client.index('INDEX_NAME').search('kitchen utensils', { + client.index('INDEX_NAME').search({ + q: 'kitchen utensils', retrieveVectors: true, hybrid: { embedder: 'EMBEDDER_NAME' } }) search_parameter_guide_hybrid_1: |- - client.index('INDEX_NAME').search('kitchen utensils', { + client.index('INDEX_NAME').search({ + q: 'kitchen utensils', hybrid: { semanticRatio: 0.9, embedder: 'EMBEDDER_NAME' @@ -760,17 +796,18 @@ search_parameter_guide_hybrid_1: |- get_similar_post_1: |- client.index('INDEX_NAME').searchSimilarDocuments({ id: 'TARGET_DOCUMENT_ID', embedder: 'default' }) search_parameter_guide_matching_strategy_3: |- - client.index('movies').search('white shirt', { + client.index('movies').search({ + q: 'white shirt', matchingStrategy: 'frequency' }) search_parameter_reference_distinct_1: |- - client.index('INDEX_NAME').search('QUERY TERMS', { distinct: 'ATTRIBUTE_A' }) + client.index('INDEX_NAME').search({ q: 'QUERY TERMS', distinct: 'ATTRIBUTE_A' }) 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' }) + client.index('products').search({ q: 'white shirt', distinct: 'sku' }) multi_search_federated_1: |- - client.multiSearch({ + client.federatedMultiSearch({ federation: {}, queries: [ { @@ -784,7 +821,7 @@ multi_search_federated_1: |- ] }) search_parameter_reference_locales_1: |- - client.index('INDEX_NAME').search('QUERY TEXT IN JAPANESE', { locales: ['jpn'] }) + client.index('INDEX_NAME').search({ q: 'QUERY TEXT IN JAPANESE', locales: ['jpn'] }) get_localized_attribute_settings_1: |- client.index('INDEX_NAME').getLocalizedAttributes() update_localized_attribute_settings_1: |- diff --git a/README.md b/README.md index 3d5e3441f..76645b233 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Tasks such as document addition always return a unique identifier. You can use t ```javascript // Meilisearch is typo-tolerant: -const search = await index.search('philoudelphia') +const search = await index.search({ q: 'philoudelphia' }) console.log(search) ``` @@ -227,12 +227,10 @@ Output: `meilisearch-js` supports all [search parameters](https://www.meilisearch.com/docs/reference/api/search#search-parameters) described in our main documentation website. ```javascript -await index.search( - 'wonder', - { - attributesToHighlight: ['*'] - } -) +await index.search({ + q: 'wonder', + attributesToHighlight: ['*'] +}) ``` ```json @@ -275,12 +273,10 @@ Note that Meilisearch rebuilds your index whenever you update `filterableAttribu After you configured `filterableAttributes`, you can use the [`filter` search parameter](https://www.meilisearch.com/docs/reference/api/search#filter) to refine your search: ```js -await index.search( - 'wonder', - { - filter: ['id > 1 AND genres = Action'] - } -) +await index.search({ + q: 'wonder', + filter: ['id > 1 AND genres = Action'] +}) ``` ```json @@ -305,13 +301,11 @@ await index.search( Placeholder search makes it possible to receive hits based on your parameters without having any query (`q`). For example, in a movies database you can run an empty query to receive all results filtered by `genre`. ```javascript -await index.search( - '', - { - filter: ['genres = fantasy'], - facets: ['genres'] - } -) +await index.search({ + q: '', + filter: ['genres = fantasy'], + facets: ['genres'] +}) ``` ```json @@ -353,7 +347,7 @@ You can abort a pending search request by providing an [AbortSignal](https://dev const controller = new AbortController() index - .search('wonder', {}, { + .search({ q: 'wonder' }, { signal: controller.signal, }) .then((response) => { @@ -431,13 +425,13 @@ We welcome all contributions, big and small! If you want to know more about this #### [Make a search request](https://www.meilisearch.com/docs/reference/api/search) ```ts -client.index('xxx').search(query: string, options: SearchParams = {}, config?: Partial): Promise> +client.index('xxx').search(options: SearchParams = {}, config?: Partial): Promise> ``` #### [Make a search request using the GET method (slower than the search method)](https://www.meilisearch.com/docs/reference/api/search#search-in-an-index-with-get-route) ```ts -client.index('xxx').searchGet(query: string, options: SearchParams = {}, config?: Partial): Promise> +client.index('xxx').searchGet(options: SearchParams = {}, config?: Partial): Promise> ``` ### Multi Search diff --git a/eslint.config.js b/eslint.config.js index 16cbcb8ec..9be8b70e6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,6 +41,12 @@ export default tseslint.config([ { files: ["tests/**/*.test.ts"], extends: [vitest.configs.recommended], + rules: { + "vitest/expect-expect": [ + "error", + { assertFunctionNames: ["t.expect", "expect", "assert"] }, + ], + }, }, // Disable any style linting, as prettier takes care of that separately prettier, diff --git a/playgrounds/javascript/src/meilisearch.ts b/playgrounds/javascript/src/meilisearch.ts index 0f1818486..97b1e73ee 100644 --- a/playgrounds/javascript/src/meilisearch.ts +++ b/playgrounds/javascript/src/meilisearch.ts @@ -1,4 +1,7 @@ -import { Index, Meilisearch } from "../../../src/index.js"; +import { + Meilisearch, + type SearchQueryWithOffsetLimit, +} from "../../../src/index.js"; const client = new Meilisearch({ host: "http://127.0.0.1:7700", @@ -37,14 +40,13 @@ export async function getAllHits(element: HTMLDivElement): Promise { } export async function getSearchResponse(element: HTMLDivElement) { - const params: Parameters = [ - "philoudelphia", - { attributesToHighlight: ["title"] }, - ]; - - const response = await client.index(indexUid).search(...params); + const searchQuery: SearchQueryWithOffsetLimit = { + q: "philoudelphia", + attributesToHighlight: ["title"], + }; + const response = await client.index(indexUid).search(searchQuery); element.innerText = - `PARAMETERS: ${JSON.stringify(params, null, 4)}` + + `PARAMETERS: ${JSON.stringify(searchQuery, null, 4)}` + `\nRESPONSE: ${JSON.stringify(response, null, 4)}`; } diff --git a/src/http-requests.ts b/src/http-requests.ts index 2e3ff400e..fa73c6414 100644 --- a/src/http-requests.ts +++ b/src/http-requests.ts @@ -22,14 +22,7 @@ function appendRecordToURLSearchParams( ): void { for (const [key, val] of Object.entries(recordToAppend)) { if (val != null) { - searchParams.set( - key, - Array.isArray(val) - ? val.join() - : val instanceof Date - ? val.toISOString() - : String(val), - ); + searchParams.set(key, Array.isArray(val) ? val.join() : String(val)); } } } diff --git a/src/indexes.ts b/src/indexes.ts index a3cc3f020..0aff3c68e 100644 --- a/src/indexes.ts +++ b/src/indexes.ts @@ -5,13 +5,8 @@ * Copyright: 2019, MeiliSearch */ -import { MeiliSearchError } from "./errors/index.js"; import type { Config, - SearchResponse, - SearchParams, - Filter, - SearchRequestGET, IndexObject, IndexOptions, IndexStats, @@ -30,26 +25,36 @@ import type { TypoTolerance, PaginationSettings, Faceting, - ResourceResults, RawDocumentAdditionOptions, ContentType, DocumentsIds, DocumentsDeletionQuery, - SearchForFacetValuesParams, - SearchForFacetValuesResponse, SeparatorTokens, NonSeparatorTokens, Dictionary, ProximityPrecision, Embedders, SearchCutoffMs, - SearchSimilarDocumentsParams, LocalizedAttributes, UpdateDocumentsByFunctionOptions, ExtraRequestInit, PrefixSearch, RecordAny, + SearchQuery, + FacetSearchQuery, + FacetSearchResult, + SimilarQuery, + SimilarResult, EnqueuedTaskPromise, + ResourceResults, + SearchQueryWithOffsetLimit, + SearchResultWithOffsetLimit, + SearchQueryWithRequiredPagination, + SearchResultWithPagination, + SearchResult, + SearchQueryGet, + SearchQueryWithRequiredPaginationGet, + SearchQueryWithOffsetLimitGet, } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; import { @@ -57,6 +62,7 @@ import { TaskClient, type HttpRequestsWithEnqueuedTaskPromise, } from "./task.js"; +import { stringifyRecordKeyValues } from "./utils.js"; export class Index { uid: string; @@ -87,103 +93,79 @@ export class Index { /// SEARCH /// - /** - * Search for documents into an index - * - * @param query - Query string - * @param options - Search options - * @param config - Additional request configuration options - * @returns Promise containing the search response - */ - async search( - query?: string | null, - options?: S, - extraRequestInit?: ExtraRequestInit, - ): Promise> { - return await this.httpRequest.post>({ + /** {@link https://www.meilisearch.com/docs/reference/api/search} */ + search( + searchQuery?: SearchQueryWithOffsetLimit, + init?: ExtraRequestInit, + ): Promise>; + search( + searchQuery?: SearchQueryWithRequiredPagination, + init?: ExtraRequestInit, + ): Promise>; + async search( + searchQuery: SearchQuery = {}, + init?: ExtraRequestInit, + ): Promise> { + return await this.httpRequest.post({ path: `indexes/${this.uid}/search`, - body: { q: query, ...options }, - extraRequestInit, - }); - } - - /** - * Search for documents into an index using the GET method - * - * @param query - Query string - * @param options - Search options - * @param config - Additional request configuration options - * @returns Promise containing the search response - */ - async searchGet< - D extends RecordAny = T, - S extends SearchParams = SearchParams, - >( - query?: string | null, - options?: S, - extraRequestInit?: ExtraRequestInit, - ): Promise> { - // TODO: Make this a type thing instead of a runtime thing - const parseFilter = (filter?: Filter): string | undefined => { - if (typeof filter === "string") return filter; - else if (Array.isArray(filter)) - throw new MeiliSearchError( - "The filter query parameter should be in string format when using searchGet", - ); - else return undefined; - }; - - const getParams: SearchRequestGET = { - q: query, - ...options, - filter: parseFilter(options?.filter), - sort: options?.sort?.join(","), - facets: options?.facets?.join(","), - attributesToRetrieve: options?.attributesToRetrieve?.join(","), - attributesToCrop: options?.attributesToCrop?.join(","), - attributesToHighlight: options?.attributesToHighlight?.join(","), - vector: options?.vector?.join(","), - attributesToSearchOn: options?.attributesToSearchOn?.join(","), - }; - - return await this.httpRequest.get>({ + body: searchQuery, + extraRequestInit: init, + }); + } + + /** {@link https://www.meilisearch.com/docs/reference/api/search#search-in-an-index-with-get} */ + searchGet( + searchQuery?: SearchQueryWithOffsetLimitGet, + init?: ExtraRequestInit, + ): Promise>; + searchGet( + searchQuery?: SearchQueryWithRequiredPaginationGet, + init?: ExtraRequestInit, + ): Promise>; + async searchGet( + searchQuery?: SearchQueryGet, + init?: ExtraRequestInit, + ): Promise> { + return await this.httpRequest.get({ path: `indexes/${this.uid}/search`, - params: getParams, - extraRequestInit, + params: stringifyRecordKeyValues(searchQuery, ["filter"]), + extraRequestInit: init, }); } - /** - * Search for facet values - * - * @param params - Parameters used to search on the facets - * @param config - Additional request configuration options - * @returns Promise containing the search response - */ + /** {@link https://www.meilisearch.com/docs/reference/api/facet_search#facet-search} */ async searchForFacetValues( - params: SearchForFacetValuesParams, - extraRequestInit?: ExtraRequestInit, - ): Promise { - return await this.httpRequest.post({ + facetSearchQuery: FacetSearchQuery, + init?: ExtraRequestInit, + ): Promise { + return await this.httpRequest.post({ path: `indexes/${this.uid}/facet-search`, - body: params, - extraRequestInit, + body: facetSearchQuery, + extraRequestInit: init, }); } - /** - * Search for similar documents - * - * @param params - Parameters used to search for similar documents - * @returns Promise containing the search response - */ - async searchSimilarDocuments< - D extends RecordAny = T, - S extends SearchParams = SearchParams, - >(params: SearchSimilarDocumentsParams): Promise> { - return await this.httpRequest.post>({ + /** {@link https://www.meilisearch.com/docs/reference/api/similar} */ + async searchSimilarDocuments( + similarQuery: SimilarQuery, + init?: ExtraRequestInit, + ): Promise { + return await this.httpRequest.post({ path: `indexes/${this.uid}/similar`, - body: params, + body: similarQuery, + extraRequestInit: init, + }); + } + + /** {@link https://www.meilisearch.com/docs/reference/api/similar#get-similar-documents-with-get} */ + async searchSimilarDocumentsGet( + similarQuery: SimilarQuery, + init?: ExtraRequestInit, + ): Promise { + return await this.httpRequest.get({ + path: `indexes/${this.uid}/similar`, + params: stringifyRecordKeyValues(similarQuery, ["filter"]), + extraRequestInit: init, }); } @@ -313,7 +295,7 @@ export class Index { : // Else use `GET /documents` method await this.httpRequest.get>({ path: relativeBaseURL, - params, + params: params as Omit, }); } diff --git a/src/meilisearch.ts b/src/meilisearch.ts index 25f1c4e8d..91c1b5cf1 100644 --- a/src/meilisearch.ts +++ b/src/meilisearch.ts @@ -7,7 +7,6 @@ import { Index } from "./indexes.js"; import type { - KeyCreation, Config, IndexOptions, IndexObject, @@ -15,19 +14,24 @@ import type { Health, Stats, Version, - KeyUpdate, IndexesQuery, IndexesResults, - KeysQuery, - KeysResults, IndexSwap, - MultiSearchParams, - FederatedMultiSearchParams, - MultiSearchResponseOrSearchResponse, EnqueuedTaskPromise, ExtraRequestInit, Network, RecordAny, + MultiSearchOrFederatedSearch, + SearchResultsOrFederatedSearchResult, + KeysQuery, + KeysResults, + KeyCreation, + KeyUpdate, + FederatedSearchResult, + FederatedSearch, + MultiSearch, + SearchResults, + RuntimeTogglableFeatures, } from "./types/index.js"; import { ErrorStatusCode } from "./types/index.js"; import { HttpRequests } from "./http-requests.js"; @@ -213,66 +217,29 @@ export class MeiliSearch { /// Multi Search /// - /** - * Perform multiple search queries. - * - * It is possible to make multiple search queries on the same index or on - * different ones. With network feature enabled, you can also search across - * remote instances. - * - * @example - * - * ```ts - * client.multiSearch({ - * queries: [ - * { indexUid: "movies", q: "wonder" }, - * { indexUid: "books", q: "flower" }, - * ], - * }); - * - * // Federated search with remote instance (requires network feature enabled) - * client.multiSearch({ - * federation: {}, - * queries: [ - * { - * indexUid: "movies", - * q: "wonder", - * federationOptions: { - * remote: "meilisearch instance name", - * }, - * }, - * { - * indexUid: "movies", - * q: "wonder", - * federationOptions: { - * remote: "meilisearch instance name", - * }, - * }, - * ], - * }); - * ``` - * - * @param queries - Search queries - * @param extraRequestInit - Additional request configuration options - * @returns Promise containing the search responses - * @see {@link https://www.meilisearch.com/docs/learn/multi_search/implement_sharding#perform-a-search} - */ - async multiSearch< - T1 extends MultiSearchParams | FederatedMultiSearchParams, - T2 extends RecordAny = RecordAny, - >( - queries: T1, - extraRequestInit?: ExtraRequestInit, - ): Promise> { - return await this.httpRequest.post< - MultiSearchResponseOrSearchResponse - >({ + async #multiSearch( + body: MultiSearchOrFederatedSearch, + init?: ExtraRequestInit, + ): Promise { + return await this.httpRequest.post({ path: "multi-search", - body: queries, - extraRequestInit, + body, + extraRequestInit: init, }); } + /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ + readonly multiSearch = this.#multiSearch.bind(this) as ( + multiSearch: MultiSearch, + init?: ExtraRequestInit, + ) => Promise; + + /** {@link https://www.meilisearch.com/docs/reference/api/multi_search} */ + readonly federatedMultiSearch = this.#multiSearch.bind(this) as ( + federatedSearch: FederatedSearch, + init?: ExtraRequestInit, + ) => Promise; + /// /// Network /// @@ -454,4 +421,25 @@ export class MeiliSearch { path: "snapshots", }); } + + /// + /// EXPERIMENTAL-FEATURES + /// + + /** {@link https://www.meilisearch.com/docs/reference/api/experimental_features#get-all-experimental-features} */ + async getExperimentalFeatures(): Promise { + return await this.httpRequest.get({ + path: "experimental-features", + }); + } + + /** {@link https://www.meilisearch.com/docs/reference/api/experimental_features#configure-experimental-features} */ + async updateExperimentalFeatures( + runtimeTogglableFeatures: RuntimeTogglableFeatures, + ): Promise { + return await this.httpRequest.patch({ + path: "experimental-features", + body: runtimeTogglableFeatures, + }); + } } diff --git a/src/types/experimental-features.ts b/src/types/experimental-features.ts new file mode 100644 index 000000000..c81720d16 --- /dev/null +++ b/src/types/experimental-features.ts @@ -0,0 +1,14 @@ +/** + * {@link https://www.meilisearch.com/docs/reference/api/experimental_features#experimental-features-object} + * + * @see `meilisearch::routes::features::RuntimeTogglableFeatures` + */ +export type RuntimeTogglableFeatures = { + metrics?: boolean | null; + logsRoute?: boolean | null; + editDocumentsByFunction?: boolean | null; + containsFilter?: boolean | null; + network?: boolean | null; + getTaskDocumentsRoute?: boolean | null; + compositeEmbedders?: boolean | null; +}; diff --git a/src/types/functions.ts b/src/types/functions.ts new file mode 100644 index 000000000..b801e4df3 --- /dev/null +++ b/src/types/functions.ts @@ -0,0 +1,13 @@ +import type { Pagination, SearchQuery } from "./search-parameters.js"; +import type { + SearchResultWithOffsetLimit, + SearchResultWithPagination, +} from "./search-response.js"; +import type { RecordAny } from "./shared.js"; + +export type ConditionalSearchResult< + T extends SearchQuery, + U extends RecordAny = RecordAny, +> = T extends Pagination + ? SearchResultWithPagination + : SearchResultWithOffsetLimit; diff --git a/src/types/http-requests.ts b/src/types/http-requests.ts new file mode 100644 index 000000000..39f93061e --- /dev/null +++ b/src/types/http-requests.ts @@ -0,0 +1,99 @@ +import type { WaitOptions } from "./task-and-batch.js"; + +/** + * Shape of allowed record object that can be appended to a + * {@link URLSearchParams}. + */ +export type URLSearchParamsRecord = Record< + string, + string | string[] | number | number[] | boolean | null | undefined +>; + +/** + * {@link RequestInit} without {@link RequestInit.body} and + * {@link RequestInit.method} properties. + */ +export type ExtraRequestInit = Omit; + +/** Same as {@link ExtraRequestInit} but without {@link ExtraRequestInit.signal}. */ +export type BaseRequestInit = Omit; + +/** + * Same as {@link BaseRequestInit} but with its headers property forced as a + * {@link Headers} object. + */ +export type HttpRequestsRequestInit = Omit & { + headers: Headers; +}; + +/** 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. + */ + host: string; + /** + * API key for interacting with a meilisearch instance. + * + * @see {@link https://www.meilisearch.com/docs/learn/security/basic_security} + */ + apiKey?: string; + /** + * 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. */ + requestInit?: BaseRequestInit; + /** + * 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. + * @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?: number; + /** Options for waiting on tasks. */ + defaultWaitOptions?: WaitOptions; +}; + +/** Main options of a request. */ +export type MainRequestOptions = { + /** The path or subpath of the URL to make a request to. */ + path: string; + /** The REST method of the request. */ + method?: string; + /** The search parameters of the URL. */ + params?: URLSearchParamsRecord; + /** + * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type | Content-Type} + * passed to request {@link Headers}. + */ + contentType?: string; + /** + * The body of the request. + * + * @remarks + * This only really supports string for now (any other type gets stringified) + * but it could support more in the future. + * {@link https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#body} + */ + body?: string | boolean | number | object | null; + /** + * An extra, more limited {@link RequestInit}, that may override some of the + * options. + */ + extraRequestInit?: ExtraRequestInit; +}; + +/** + * {@link MainRequestOptions} without {@link MainRequestOptions.method}, for + * method functions. + */ +export type RequestOptions = Omit; diff --git a/src/types/index.ts b/src/types/index.ts index 2a13bc669..d263ac6bf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,10 @@ -export * from "./task_and_batch.js"; +export * from "./experimental-features.js"; +export * from "./http-requests.js"; +export * from "./network.js"; +export * from "./search-parameters.js"; +export * from "./search-response.js"; +export * from "./shared.js"; +export * from "./task-and-batch.js"; export * from "./token.js"; export * from "./types.js"; +export * from "./functions.js"; diff --git a/src/types/network.ts b/src/types/network.ts new file mode 100644 index 000000000..ef4a2020e --- /dev/null +++ b/src/types/network.ts @@ -0,0 +1,19 @@ +/** + * {@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; +}; diff --git a/src/types/search-parameters.ts b/src/types/search-parameters.ts new file mode 100644 index 000000000..73d01cc55 --- /dev/null +++ b/src/types/search-parameters.ts @@ -0,0 +1,225 @@ +import type { + NonNullKeys, + PascalToCamelCase, + RequiredKeys, + SafeOmit, +} from "./shared.js"; +import type { Locale } from "./types.js"; + +/** @see `meilisearch::search::HybridQuery` */ +export type HybridQuery = { + semanticRatio?: number; + embedder: string; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/search#matching-strategy} + * + * @see `meilisearch::search::MatchingStrategy` + */ +export type MatchingStrategy = PascalToCamelCase<"Last" | "All" | "Frequency">; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#federationoptions} + * + * @see `meilisearch::search::federated::types::FederationOptions` + */ +export type FederationOptions = { + weight?: number; + /** @experimental */ + remote?: string | null; + /** + * @privateRemarks + * Undocumented. + */ + queryPosition?: number | null; +}; + +type OffsetLimit = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#offset} */ + offset?: number | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#limit} */ + limit?: number | null; +}; + +export type Pagination = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#page} */ + page?: number | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#number-of-results-per-page} */ + hitsPerPage?: number | null; +}; + +/** + * {@link https://www.meilisearch.com/docs/learn/filtering_and_sorting/filter_expression_reference} + * + * @privateRemarks + * It is not strongly typed in any way in the source code. + * @see `milli::search::facet::filter::Filter::from_json` + */ +export type FilterExpression = string | (string | string[])[]; + +type FirstPartOfFacetAndSearchQuerySegment = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#filter} */ + filter?: FilterExpression | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#ranking-score-threshold} */ + rankingScoreThreshold?: number | null; +}; + +type FacetAndSearchQuerySegment = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#query-q} */ + q?: string | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#vector} */ + vector?: number[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#hybrid-search} */ + hybrid?: HybridQuery | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#matching-strategy} */ + matchingStrategy?: MatchingStrategy; + // TODO: Could use generic record to type these kinds of settings, but is it worth it? + // https://stackoverflow.com/a/76547796 + /** {@link https://www.meilisearch.com/docs/reference/api/search#customize-attributes-to-search-on-at-search-time} */ + attributesToSearchOn?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#query-locales} */ + locales?: Locale[] | null; +} & FirstPartOfFacetAndSearchQuerySegment; + +type FirstPartOfSearchQueryCore = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#attributes-to-retrieve} */ + attributesToRetrieve?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#display-_vectors-in-response} */ + retrieveVectors?: boolean; + /** {@link https://www.meilisearch.com/docs/reference/api/search#ranking-score} */ + showRankingScore?: boolean; + /** {@link https://www.meilisearch.com/docs/reference/api/search#ranking-score-details} */ + showRankingScoreDetails?: boolean; +}; + +type SearchQueryCore = { + /** {@link https://www.meilisearch.com/docs/reference/api/search#attributes-to-crop} */ + attributesToCrop?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#crop-length} */ + cropLength?: number; + /** {@link https://www.meilisearch.com/docs/reference/api/search#attributes-to-highlight} */ + attributesToHighlight?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#show-matches-position} */ + showMatchesPosition?: boolean; + /** {@link https://www.meilisearch.com/docs/reference/api/search#sort} */ + sort?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#distinct-attributes-at-search-time} */ + distinct?: string | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#facets} */ + facets?: string[] | null; + /** {@link https://www.meilisearch.com/docs/reference/api/search#highlight-tags} */ + highlightPreTag?: string; + /** {@link https://www.meilisearch.com/docs/reference/api/search#highlight-tags} */ + highlightPostTag?: string; + /** {@link https://www.meilisearch.com/docs/reference/api/search#crop-marker} */ + cropMarker?: string; +} & FacetAndSearchQuerySegment & + FirstPartOfSearchQueryCore; + +type IntoGet = SafeOmit & { + [TKey in keyof HybridQuery as `hybrid${Capitalize}`]?: + | HybridQuery[TKey] + | null; +}; + +export type SearchQueryWithOffsetLimit = SearchQueryCore & OffsetLimit; +export type SearchQueryWithOffsetLimitGet = IntoGet; + +type SearchQueryWithPagination = SearchQueryCore & Pagination; +export type SearchQueryWithPaginationGet = IntoGet; + +/** + * @remarks + * While `page` is an `Option` with a default value of `None` in the + * source code in `meilisearch::search::SearchQuery` (which serializes to + * `number | null | undefined`), we're enforcing it in this case to guarantee a + * response shape with pagination. + */ +export type SearchQueryWithRequiredPagination = RequiredKeys< + SearchQueryWithPagination, + "page" +>; +export type SearchQueryWithRequiredPaginationGet = + IntoGet; + +/** @see `meilisearch::search::SearchQuery` */ +export type SearchQuery = + | SearchQueryWithOffsetLimit + | SearchQueryWithPagination; + +/** @see `meilisearch::routes::indexes::search::SearchQueryGet` */ +export type SearchQueryGet = + | SearchQueryWithOffsetLimitGet + | SearchQueryWithPaginationGet; + +type SearchQueryWithIndexCore = SearchQueryCore & { indexUid: string }; + +export type SearchQueryWithIndexAndFederation = { + /** {@link https://www.meilisearch.com/docs/reference/api/multi_search#federationoptions} */ + federationOptions?: FederationOptions | null; +} & SearchQueryWithIndexCore; + +export type SearchQueryWithIndexAndOffsetLimit = SearchQueryWithIndexCore & + OffsetLimit; +export type SearchQueryWithIndexAndPagination = SearchQueryWithIndexCore & + Pagination; + +/** @see `meilisearch::search::SearchQueryWithIndex` */ +export type SearchQueryWithIndex = + | SearchQueryWithIndexAndOffsetLimit + | SearchQueryWithIndexAndPagination; + +/** @see `meilisearch::search::federated::types::MergeFacets` */ +export type MergeFacets = { maxValuesPerFacet?: number | null }; + +/** @see `meilisearch::search::federated::types::Federation` */ +export type Federation = { + limit?: number; + offset?: number; + facetsByIndex?: Record; + mergeFacets?: MergeFacets | null; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#body} + * + * @see `meilisearch::search::federated::types::FederatedSearch` + */ +export type FederatedSearch = { + queries: SearchQueryWithIndexAndFederation[]; + federation: Federation; +}; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#body} + * + * @see `meilisearch::search::federated::types::FederatedSearch` + */ +export type MultiSearch = { queries: SearchQueryWithIndex[] }; + +/** {@link https://www.meilisearch.com/docs/reference/api/multi_search#body} */ +export type MultiSearchOrFederatedSearch = MultiSearch | FederatedSearch; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/facet_search#body} + * + * @see `meilisearch::routes::indexes::facet_search::FacetSearchQuery` + */ +export type FacetSearchQuery = { + facetQuery?: string | null; + facetName: string; + exhaustiveFacetCount?: boolean | null; +} & FacetAndSearchQuerySegment; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/similar#body} + * + * @see `meilisearch::search::SimilarQuery` + */ +export type SimilarQuery = { + id: string | number; + embedder: string; +} & FirstPartOfFacetAndSearchQuerySegment & + FirstPartOfSearchQueryCore & + NonNullKeys; diff --git a/src/types/search-response.ts b/src/types/search-response.ts new file mode 100644 index 000000000..f43587ef3 --- /dev/null +++ b/src/types/search-response.ts @@ -0,0 +1,162 @@ +import type { RecordAny, DeepStringRecord } from "./shared.js"; +import type { FieldDistribution, MeiliSearchErrorResponse } from "./types.js"; + +// TODO: Maybe more links + +/** @see `milli::search::new::matches::MatchBounds` */ +export type MatchBounds = { start: number; length: number; indices?: number[] }; + +/** @see `meilisearch::search::MatchesPosition` */ +export type MatchesPosition = Record; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/search#ranking-score-details-object} + * + * @privateRemarks + * This could be typed more accurately, but both the source code and + * documentation is a little confusing. + * @see `milli::score_details::ScoreDetails::to_json_map` + */ +export type ScoreDetails = RecordAny; + +/** @see `milli::vector::parsed_vectors::ExplicitVectors` */ +export type ExplicitVectors = { + embeddings: number[] | number[][]; + regenerate: boolean; +}; + +/** @see `meilisearch::search::SearchHit` */ +export type SearchHit = T & { + _formatted?: DeepStringRecord; + _matchesPosition?: MatchesPosition; + _rankingScore?: number; + _rankingScoreDetails?: ScoreDetails; + /** @see `meilisearch::search::insert_geo_distance` */ + _geoDistance?: number; + /** @see `meilisearch::search::HitMaker::make_hit` */ + _vectors?: Record; +}; + +/** + * @privateRemarks + * This is an untyped structure in the source code. + * @see `meilisearch::search::federated::perform::SearchByIndex::execute` + */ +export type FederationDetails = { + indexUid: string; + queriesPosition: number; + remote?: string; + weightedRankingScore: number; +}; + +export type FederatedSearchHit = + SearchHit & { _federation: FederationDetails }; + +/** @see `meilisearch::search::HitsInfo` */ +type Pagination = { + hitsPerPage: number; + page: number; + totalPages: number; + totalHits: number; +}; + +/** @see `meilisearch::search::HitsInfo` */ +type OffsetLimit = { + limit: number; + offset: number; + estimatedTotalHits: number; +}; + +/** @see `meilisearch::search::FacetStats` */ +export type FacetStats = { + min: number; + max: number; +}; + +type ProcessingTime = { processingTimeMs: number }; + +type SearchResultCore = { + // TODO: this is not present on federated result + query: string; + facetDistribution?: Record; + facetStats?: Record; + semanticHitCount?: number; +} & ProcessingTime; + +export type SearchResultWithPagination = + SearchResultCore & { hits: SearchHit[] } & Pagination; +export type SearchResultWithOffsetLimit = + SearchResultCore & { hits: SearchHit[] } & OffsetLimit; + +/** @see `meilisearch::search::SearchResult` */ +export type SearchResult = + | SearchResultWithOffsetLimit + | SearchResultWithPagination; + +/** @see `meilisearch::search::ComputedFacets` */ +export type ComputedFacets = { + distribution: Record>; + stats: Record; +}; + +/** @see `meilisearch::search::federated::types::FederatedFacets` */ +export type FederatedFacets = Record; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#federated-multi-search-requests} + * + * @see `meilisearch::search::federated::types::FederatedSearchResult` + */ +export type FederatedSearchResult = SearchResultCore & { + hits: FederatedSearchHit>[]; + facetsByIndex: FederatedFacets; + remoteErrors?: Record; +} & OffsetLimit; + +type SearchResultIndex = { indexUid: string }; + +export type SearchResultWithIndexAndPagination< + T extends RecordAny = RecordAny, +> = SearchResultIndex & SearchResultWithPagination; +export type SearchResultWithIndexAndOffsetLimit< + T extends RecordAny = RecordAny, +> = SearchResultIndex & SearchResultWithOffsetLimit; + +/** @see `meilisearch::search::SearchResultWithIndex` */ +export type SearchResultWithIndex = + | SearchResultWithIndexAndPagination + | SearchResultWithIndexAndOffsetLimit; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/multi_search#non-federated-multi-search-requests} + * + * @see `meilisearch::routes::multi_search::SearchResults` + */ +export type SearchResults = { + results: SearchResultWithIndex>[]; +}; + +/** {@link https://www.meilisearch.com/docs/reference/api/multi_search#response} */ +export type SearchResultsOrFederatedSearchResult = + | SearchResults + | FederatedSearchResult; + +/** @see `milli::search::facet::search::FacetValueHit` */ +export type FacetValueHit = { value: string; count: number }; + +/** + * {@link https://www.meilisearch.com/docs/reference/api/facet_search#response} + * + * @see `meilisearch::search::FacetSearchResult` + */ +export type FacetSearchResult = { + facetHits: FacetValueHit[]; + facetQuery: string | null; +} & ProcessingTime; + +/** @see `meilisearch::search::SimilarResult` */ +export type SimilarResult = { + hits: SearchHit[]; + id: string; +} & ProcessingTime & + OffsetLimit; diff --git a/src/types/shared.ts b/src/types/shared.ts index d475d6948..fe03ceeaa 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -1,4 +1,13 @@ -import type { RecordAny } from "./types.js"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RecordAny = Record; + +export type NonNullKeys = { + [TKey in U]: Exclude; +} & { [TKey in Exclude]: T[TKey] }; + +export type RequiredKeys = { + [TKey in U]-?: Exclude; +} & Omit; export type CursorResults = { results: T[]; @@ -8,11 +17,13 @@ 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; +// taken from https://stackoverflow.com/a/65642944 +export type PascalToCamelCase = Uncapitalize; + +export type DeepStringRecord = { + [TKey in keyof T]: T[TKey] extends object + ? DeepStringRecord + : string; }; + +export type SafeOmit = Omit; diff --git a/src/types/task_and_batch.ts b/src/types/task-and-batch.ts similarity index 100% rename from src/types/task_and_batch.ts rename to src/types/task-and-batch.ts diff --git a/src/types/token.ts b/src/types/token.ts index 3ca1d570e..4cb2762e5 100644 --- a/src/types/token.ts +++ b/src/types/token.ts @@ -1,14 +1,14 @@ -import type { Filter } from "./types.js"; +import type { SearchQuery } from "./search-parameters.js"; /** @see {@link TokenSearchRules} */ -export type TokenIndexRules = { filter?: Filter }; +export type TokenIndexRules = Pick; /** * {@link https://www.meilisearch.com/docs/learn/security/tenant_token_reference#search-rules} * * @remarks * Not well documented. - * @see {@link https://github.com/meilisearch/meilisearch/blob/b21d7aedf9096539041362d438e973a18170f3fc/crates/meilisearch-auth/src/lib.rs#L271-L277 | GitHub source code} + * @see `meilisearch_auth::SearchRules` at {@link https://github.com/meilisearch/meilisearch} */ export type TokenSearchRules = | Record diff --git a/src/types/types.ts b/src/types/types.ts index 91d29f4b5..6c002c53b 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,121 +4,14 @@ // Definitions: https://github.com/meilisearch/meilisearch-js // TypeScript Version: ^5.8.2 -import type { WaitOptions } from "./task_and_batch.js"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RecordAny = Record; - -/** - * Shape of allowed record object that can be appended to a - * {@link URLSearchParams}. - */ -export type URLSearchParamsRecord = Record< - string, - | string - | string[] - | (string | string[])[] - | number - | number[] - | boolean - | Date - | null - | undefined ->; - -/** - * {@link RequestInit} without {@link RequestInit.body} and - * {@link RequestInit.method} properties. - */ -export type ExtraRequestInit = Omit; - -/** Same as {@link ExtraRequestInit} but without {@link ExtraRequestInit.signal}. */ -export type BaseRequestInit = Omit; - -/** - * Same as {@link BaseRequestInit} but with its headers property forced as a - * {@link Headers} object. - */ -export type HttpRequestsRequestInit = Omit & { - headers: Headers; -}; - -/** 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. - */ - host: string; - /** - * API key for interacting with a meilisearch instance. - * - * @see {@link https://www.meilisearch.com/docs/learn/security/basic_security} - */ - apiKey?: string; - /** - * Custom strings that will be concatted to the "X-Meilisearch-Client" header - * on each request. - */ - clientAgents?: string[]; - /** Base request options that may override the default ones. */ - requestInit?: BaseRequestInit; - /** - * 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. - * @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?: number; - defaultWaitOptions?: WaitOptions; -}; - -/** Main options of a request. */ -export type MainRequestOptions = { - /** The path or subpath of the URL to make a request to. */ - path: string; - /** The REST method of the request. */ - method?: string; - /** The search parameters of the URL. */ - params?: URLSearchParamsRecord; - /** - * {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type | Content-Type} - * passed to request {@link Headers}. - */ - contentType?: string; - /** - * The body of the request. - * - * @remarks - * This only really supports string for now (any other type gets stringified) - * but it could support more in the future. - * {@link https://developer.mozilla.org/en-US/docs/Web/API/RequestInit#body} - */ - body?: string | boolean | number | object | null; - /** - * An extra, more limited {@link RequestInit}, that may override some of the - * options. - */ - extraRequestInit?: ExtraRequestInit; -}; - -/** - * {@link MainRequestOptions} without {@link MainRequestOptions.method}, for - * method functions. - */ -export type RequestOptions = Omit; +import type { FilterExpression } from "./search-parameters.js"; +import type { RecordAny } from "./shared.js"; /// /// Resources /// -export type Pagination = { +type Pagination = { offset?: number; limit?: number; }; @@ -149,303 +42,6 @@ export type IndexesQuery = ResourceQuery & {}; export type IndexesResults = ResourceResults & {}; -/* - * SEARCH PARAMETERS - */ - -export const MatchingStrategies = { - ALL: "all", - LAST: "last", - FREQUENCY: "frequency", -} as const; - -export type MatchingStrategies = - (typeof MatchingStrategies)[keyof typeof MatchingStrategies]; - -export type Filter = string | (string | string[])[]; - -export type Query = { - q?: string | null; -}; - -export type Highlight = { - attributesToHighlight?: string[]; - highlightPreTag?: string; - highlightPostTag?: string; -}; - -export type Crop = { - attributesToCrop?: string[]; - cropLength?: number; - cropMarker?: string; -}; - -// `facetName` becomes mandatory when using `searchForFacetValues` -export type SearchForFacetValuesParams = Omit & { - facetName: string; - /** - * If true, the facet search will return the exhaustive count of the facet - * values. - */ - exhaustiveFacetCount?: boolean; -}; - -export type FacetHit = { - value: string; - count: number; -}; - -export type SearchForFacetValuesResponse = { - facetHits: FacetHit[]; - facetQuery: string | null; - processingTimeMs: number; -}; - -export type HybridSearch = { - embedder: string; - semanticRatio?: number; -}; - -// https://www.meilisearch.com/docs/reference/api/settings#localized-attributes -export type Locale = string; - -export type SearchParams = Query & - Pagination & - Highlight & - Crop & { - filter?: Filter; - sort?: string[]; - facets?: string[]; - attributesToRetrieve?: string[]; - showMatchesPosition?: boolean; - matchingStrategy?: MatchingStrategies; - hitsPerPage?: number; - page?: number; - facetName?: string; - facetQuery?: string; - vector?: number[] | null; - showRankingScore?: boolean; - showRankingScoreDetails?: boolean; - rankingScoreThreshold?: number; - attributesToSearchOn?: string[] | null; - hybrid?: HybridSearch; - distinct?: string; - retrieveVectors?: boolean; - locales?: Locale[]; - }; - -// Search parameters for searches made with the GET method -// Are different than the parameters for the POST method -export type SearchRequestGET = Pagination & - Query & - Omit & - Omit & { - filter?: string; - sort?: string; - facets?: string; - attributesToRetrieve?: string; - attributesToHighlight?: string; - attributesToCrop?: string; - showMatchesPosition?: boolean; - vector?: string | null; - attributesToSearchOn?: string | null; - hybridEmbedder?: string; - hybridSemanticRatio?: number; - rankingScoreThreshold?: number; - distinct?: string; - retrieveVectors?: boolean; - locales?: Locale[]; - }; - -export type MergeFacets = { - maxValuesPerFacet?: number | null; -}; - -export type FederationOptions = { weight: number; remote?: string }; -export type MultiSearchFederation = { - limit?: number; - offset?: number; - facetsByIndex?: Record; - mergeFacets?: MergeFacets | null; -}; - -export type MultiSearchQuery = SearchParams & { indexUid: string }; -export type MultiSearchQueryWithFederation = MultiSearchQuery & { - federationOptions?: FederationOptions; -}; - -export type MultiSearchParams = { - queries: MultiSearchQuery[]; -}; -export type FederatedMultiSearchParams = { - federation: MultiSearchFederation; - 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; -}; - -export type Facet = string; -export type FacetDistribution = Record; -export type MatchesPosition = Partial< - Record ->; - -export type RankingScoreDetails = { - words?: { - order: number; - matchingWords: number; - maxMatchingWords: number; - score: number; - }; - typo?: { - order: number; - typoCount: number; - maxTypoCount: number; - score: number; - }; - proximity?: { - order: number; - score: number; - }; - attribute?: { - order: number; - attributes_ranking_order: number; - attributes_query_word_order: number; - score: number; - }; - exactness?: { - order: number; - matchType: string; - score: number; - }; - [key: string]: RecordAny | 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 = Hit[]; - -export type FacetStat = { min: number; max: number }; -export type FacetStats = Record; - -export type FacetsByIndex = Record< - string, - { - distribution: FacetDistribution; - stats: FacetStats; - } ->; - -export type SearchResponse< - T = RecordAny, - S extends SearchParams | undefined = undefined, -> = { - hits: Hits; - processingTimeMs: number; - query: string; - facetDistribution?: FacetDistribution; - facetStats?: FacetStats; - facetsByIndex?: FacetsByIndex; -} & (undefined extends S - ? Partial - : true extends IsFinitePagination> - ? FinitePagination - : InfinitePagination); - -type FinitePagination = { - totalHits: number; - hitsPerPage: number; - page: number; - totalPages: number; -}; -type InfinitePagination = { - offset: number; - limit: number; - estimatedTotalHits: number; -}; - -type IsFinitePagination = Or< - HasHitsPerPage, - HasPage ->; - -type Or = true extends A - ? true - : true extends B - ? true - : false; - -type HasHitsPerPage = undefined extends S["hitsPerPage"] - ? false - : true; - -type HasPage = undefined extends S["page"] - ? false - : true; - -export type MultiSearchResult = SearchResponse & { indexUid: string }; - -export type MultiSearchResponse = { - results: MultiSearchResult[]; -}; - -export type MultiSearchResponseOrSearchResponse< - T1 extends FederatedMultiSearchParams | MultiSearchParams, - T2 extends RecordAny = RecordAny, -> = T1 extends FederatedMultiSearchParams - ? SearchResponse - : MultiSearchResponse; - -export type FieldDistribution = { - [field: string]: number; -}; - -export type SearchSimilarDocumentsParams = { - id: string | number; - offset?: number; - limit?: number; - filter?: Filter; - embedder?: string; - attributesToRetrieve?: string[]; - showRankingScore?: boolean; - showRankingScoreDetails?: boolean; - rankingScoreThreshold?: number; -}; - /* ** Documents */ @@ -476,7 +72,7 @@ export type RawDocumentAdditionOptions = DocumentOptions & { export type DocumentsQuery = ResourceQuery & { ids?: string[] | number[]; fields?: Fields; - filter?: Filter; + filter?: FilterExpression; limit?: number; offset?: number; retrieveVectors?: boolean; @@ -487,7 +83,7 @@ export type DocumentQuery = { }; export type DocumentsDeletionQuery = { - filter: Filter; + filter: FilterExpression; }; export type DocumentsIds = string[] | number[]; @@ -625,6 +221,13 @@ export type PaginationSettings = { export type SearchCutoffMs = number | null; +/** + * {@link https://www.meilisearch.com/docs/reference/api/settings#locales} + * + * @see `meilisearch_types::locales::Locale` + */ +export type Locale = string; + export type LocalizedAttribute = { attributePatterns: string[]; locales: Locale[]; @@ -666,6 +269,12 @@ export type Settings = { prefixSearch?: "indexingTime" | "disabled"; }; +/** + * @see `fieldDistribution` at {@link https://www.meilisearch.com/docs/reference/api/stats#stats-object} + * @see `milli::FieldDistribution` + */ +export type FieldDistribution = Record; + /* *** HEALTH */ diff --git a/src/utils.ts b/src/utils.ts index e748e1f10..4a5ddce64 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,4 +16,27 @@ function addTrailingSlash(url: string): string { return url; } -export { sleep, addProtocolIfNotPresent, addTrailingSlash }; +function stringifyRecordKeyValues< + T extends Record, + const U extends (keyof T)[], +>(v: T | undefined, keys: U) { + if (v === undefined) { + return; + } + + return Object.fromEntries( + Object.entries(v).map(([key, val]) => [ + key, + keys.includes(key) ? JSON.stringify(val) : val, + ]), + ) as { [TKey in Exclude]: T[TKey] } & { + [TKey in U[number]]: string; + }; +} + +export { + sleep, + addProtocolIfNotPresent, + addTrailingSlash, + stringifyRecordKeyValues, +}; diff --git a/tests/__snapshots__/search.test.ts.snap b/tests/__snapshots__/search.test.ts.snap deleted file mode 100644 index 90e7c1c42..000000000 --- a/tests/__snapshots__/search.test.ts.snap +++ /dev/null @@ -1,100 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Test on POST search > Admin key: search on attributesToSearchOn set to null 1`] = ` -{ - "estimatedTotalHits": 2, - "hits": [ - { - "author": "Antoine de Saint-Exupéry", - "comment": "A french book about a prince that walks on little cute planets", - "genre": [ - "adventure", - ], - "id": 456, - "isNull": null, - "isTrue": true, - "title": "Le Petit Prince", - }, - { - "author": "J.K. Rowling", - "comment": "The best book", - "genre": [ - "fantasy", - "adventure", - ], - "id": 4, - "title": "Harry Potter and the Half-Blood Prince", - }, - ], - "limit": 20, - "offset": 0, - "processingTimeMs": 0, - "query": "prince", -} -`; - -exports[`Test on POST search > Master key: search on attributesToSearchOn set to null 1`] = ` -{ - "estimatedTotalHits": 2, - "hits": [ - { - "author": "Antoine de Saint-Exupéry", - "comment": "A french book about a prince that walks on little cute planets", - "genre": [ - "adventure", - ], - "id": 456, - "isNull": null, - "isTrue": true, - "title": "Le Petit Prince", - }, - { - "author": "J.K. Rowling", - "comment": "The best book", - "genre": [ - "fantasy", - "adventure", - ], - "id": 4, - "title": "Harry Potter and the Half-Blood Prince", - }, - ], - "limit": 20, - "offset": 0, - "processingTimeMs": 0, - "query": "prince", -} -`; - -exports[`Test on POST search > Search key: search on attributesToSearchOn set to null 1`] = ` -{ - "estimatedTotalHits": 2, - "hits": [ - { - "author": "Antoine de Saint-Exupéry", - "comment": "A french book about a prince that walks on little cute planets", - "genre": [ - "adventure", - ], - "id": 456, - "isNull": null, - "isTrue": true, - "title": "Le Petit Prince", - }, - { - "author": "J.K. Rowling", - "comment": "The best book", - "genre": [ - "fantasy", - "adventure", - ], - "id": 4, - "title": "Harry Potter and the Half-Blood Prince", - }, - ], - "limit": 20, - "offset": 0, - "processingTimeMs": 0, - "query": "prince", -} -`; diff --git a/tests/client.test.ts b/tests/client.test.ts index c1b5b5926..b533b64c5 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -38,8 +38,17 @@ const index2 = { uid: "movies_test2", }; -afterAll(() => { - return clearAllIndexes(config); +beforeAll(async () => { + await ( + await getClient("Master") + ).updateExperimentalFeatures({ network: true }); +}); + +afterAll(async () => { + await ( + await getClient("Master") + ).updateExperimentalFeatures({ network: false }); + await clearAllIndexes(config); }); describe.each([ diff --git a/tests/embedders.test.ts b/tests/embedders.test.ts index bfdddc506..596571a5b 100644 --- a/tests/embedders.test.ts +++ b/tests/embedders.test.ts @@ -14,39 +14,6 @@ const index = { uid: "movies_test", }; -const datasetSimilarSearch = [ - { - title: "Shazam!", - release_year: 2019, - id: "287947", - _vectors: { manual: [0.8, 0.4, -0.5] }, - }, - { - title: "Captain Marvel", - release_year: 2019, - id: "299537", - _vectors: { manual: [0.6, 0.8, -0.2] }, - }, - { - title: "Escape Room", - release_year: 2019, - id: "522681", - _vectors: { manual: [0.1, 0.6, 0.8] }, - }, - { - title: "How to Train Your Dragon: The Hidden World", - release_year: 2019, - id: "166428", - _vectors: { manual: [0.7, 0.7, -0.4] }, - }, - { - title: "All Quiet on the Western Front", - release_year: 1930, - id: "143", - _vectors: { manual: [-0.5, 0.3, 0.85] }, - }, -]; - afterAll(() => { return clearAllIndexes(config); }); @@ -291,90 +258,6 @@ describe.each([{ permission: "Master" }, { permission: "Admin" }])( expect(response).toEqual({}); }); - - test(`${permission} key: search (POST) with vectors`, async () => { - const client = await getClient(permission); - - await client - .index(index.uid) - .updateEmbedders({ - default: { - source: "userProvided", - dimensions: 1, - }, - }) - .waitTask(); - - const response = await client.index(index.uid).search("", { - vector: [1], - hybrid: { - embedder: "default", - semanticRatio: 1.0, - }, - }); - - expect(response).toHaveProperty("hits"); - expect(response).toHaveProperty("semanticHitCount"); - // Those fields are no longer returned by the search response - // We want to ensure that they don't appear in it anymore - expect(response).not.toHaveProperty("vector"); - expect(response).not.toHaveProperty("_semanticScore"); - }); - - test(`${permission} key: search (GET) with vectors`, async () => { - const client = await getClient(permission); - - await client - .index(index.uid) - .updateEmbedders({ - default: { - source: "userProvided", - dimensions: 1, - }, - }) - .waitTask(); - - const response = await client.index(index.uid).searchGet("", { - vector: [1], - hybridEmbedder: "default", - hybridSemanticRatio: 1.0, - }); - - expect(response).toHaveProperty("hits"); - expect(response).toHaveProperty("semanticHitCount"); - // Those fields are no longer returned by the search response - // We want to ensure that they don't appear in it anymore - expect(response).not.toHaveProperty("vector"); - expect(response).not.toHaveProperty("_semanticScore"); - }); - - test(`${permission} key: search for similar documents`, async () => { - const client = await getClient(permission); - - const newEmbedder: Embedders = { - manual: { - source: "userProvided", - dimensions: 3, - }, - }; - await client.index(index.uid).updateEmbedders(newEmbedder).waitTask(); - - await client - .index(index.uid) - .addDocuments(datasetSimilarSearch) - .waitTask(); - - const response = await client.index(index.uid).searchSimilarDocuments({ - embedder: "manual", - id: "143", - }); - - expect(response).toHaveProperty("hits"); - expect(response.hits.length).toEqual(4); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("estimatedTotalHits", 4); - }); }, ); diff --git a/tests/env/node/getting_started.cjs b/tests/env/node/getting_started.cjs index f16d357c6..f24ebf3f1 100644 --- a/tests/env/node/getting_started.cjs +++ b/tests/env/node/getting_started.cjs @@ -27,19 +27,18 @@ const { MeiliSearch } = require('../../../dist/cjs/index.cjs') await index.addDocuments(dataset).waitTask() - const search = await index.search('philoudelphia') + const search = await index.search({ q: 'philoudelphia' }) console.log({ search, hit: search.hits }) - const filteredSearch = await index.search('Wonder', { + const filteredSearch = await index.search({ + q: 'Wonder', attributesToHighlight: ['*'], filter: 'id >= 1' }) console.log({ filteredSearch, hit: filteredSearch.hits[0] }) - const facetedSearch = await index.search( - '', - { - filter: ['genres = action'], - facets: ['genres'] - } - ) + const facetedSearch = await index.search({ + q: '', + filter: ['genres = action'], + facets: ['genres'] + }) console.log(JSON.stringify(facetedSearch)) })() diff --git a/tests/env/node/search_example.cjs b/tests/env/node/search_example.cjs index ed84b227c..aa008c195 100644 --- a/tests/env/node/search_example.cjs +++ b/tests/env/node/search_example.cjs @@ -24,7 +24,8 @@ const addDataset = async () => { ;(async () => { await addDataset() const index = await client.index('movies') - const resp = await index.search('Avengers', { + const resp = await index.search({ + q: 'Avengers', limit: 1, attributesToHighlight: ['title'], }) diff --git a/tests/env/typescript-node/src/index.ts b/tests/env/typescript-node/src/index.ts index 495723c13..4b965da8a 100644 --- a/tests/env/typescript-node/src/index.ts +++ b/tests/env/typescript-node/src/index.ts @@ -1,13 +1,6 @@ import { MeiliSearch, } from '../../../../src/index.js' -import type { - IndexObject, - SearchResponse, - Hits, - Hit, - SearchParams, -} from '../../../../src/index.js' import { generateTenantToken } from '../../../../src/token.js' const config = { @@ -31,29 +24,27 @@ const indexUid = "movies" await client.deleteIndex(indexUid).waitTask() await client.createIndex(indexUid).waitTask() - const index = client.index(indexUid) + const index = client.index(indexUid) const indexes = await client.getRawIndexes() - indexes.results.map((index: IndexObject) => { + indexes.results.map((index) => { console.log(index.uid) // console.log(index.something) -> ERROR }) - const searchParams: SearchParams = { + const searchParams = { + q: 'avenger', limit: 5, attributesToRetrieve: ['title', 'genre'], attributesToHighlight: ['title'], // test: true -> ERROR Test does not exist on type SearchParams } - indexes.results.map((index: IndexObject) => index.uid) - const res: SearchResponse = await index.search( - 'avenger', - searchParams - ) + indexes.results.map((index) => index.uid) + const res = await index.search(searchParams) // both work - const { hits }: { hits: Hits } = res + const { hits } = res - hits.map((hit: Hit) => { + hits.map((hit) => { console.log(hit?.genre) console.log(hit.title) // console.log(hit._formatted.title) -> ERROR, _formatted could be undefined diff --git a/tests/facet_search.test.ts b/tests/facet_search.test.ts deleted file mode 100644 index 1c27f10e7..000000000 --- a/tests/facet_search.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { expect, test, describe, beforeAll, afterAll } from "vitest"; -import { - clearAllIndexes, - config, - getClient, -} from "./utils/meilisearch-test-utils.js"; - -const index = { - uid: "movies_test", -}; - -const dataset = [ - { - id: 123, - title: "Pride and Prejudice", - genres: ["romance", "action"], - }, - { - id: 456, - title: "Le Petit Prince", - genres: ["adventure", "comedy"], - }, - { - id: 2, - title: "Le Rouge et le Noir", - genres: "romance", - }, - { - id: 1, - title: "Alice In Wonderland", - genres: ["adventure"], - }, -]; - -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on POST search", ({ permission }) => { - beforeAll(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - const newFilterableAttributes = ["genres", "title"]; - await client.createIndex(index.uid); - await client.index(index.uid).updateSettings({ - filterableAttributes: newFilterableAttributes, - }); - await client.index(index.uid).addDocuments(dataset).waitTask(); - }); - - test(`${permission} key: basic facet value search`, async () => { - const client = await getClient(permission); - - const params = { - facetQuery: "a", - facetName: "genres", - }; - const response = await client.index(index.uid).searchForFacetValues(params); - - expect(response).toMatchSnapshot(); - }); - - test(`${permission} key: facet value search with no facet query`, async () => { - const client = await getClient(permission); - - const params = { - facetName: "genres", - }; - const response = await client.index(index.uid).searchForFacetValues(params); - - expect(response).toMatchSnapshot(); - }); - - test(`${permission} key: facet value search with filter`, async () => { - const client = await getClient(permission); - - const params = { - facetName: "genres", - facetQuery: "a", - filter: ["genres = action"], - }; - - const response = await client.index(index.uid).searchForFacetValues(params); - - expect(response).toMatchSnapshot(); - }); - - test(`${permission} key: facet value search with search query`, async () => { - const client = await getClient(permission); - - const params = { - facetName: "genres", - facetQuery: "a", - q: "Alice", - }; - const response = await client.index(index.uid).searchForFacetValues(params); - - // @TODO: This is flaky, processingTimeMs is not guaranteed - expect(response).toMatchSnapshot(); - }); - - test(`${permission} key: facet value search with exhaustive facet count`, async () => { - const client = await getClient(permission); - - const params = { - facetName: "genres", - facetQuery: "a", - q: "Alice", - exhaustiveFacetCount: true, - }; - const response = await client.index(index.uid).searchForFacetValues(params); - - // @TODO: This is flaky, processingTimeMs is not guaranteed - expect(response).toMatchSnapshot(); - }); -}); - -afterAll(() => { - return clearAllIndexes(config); -}); diff --git a/tests/get_search.test.ts b/tests/get_search.test.ts deleted file mode 100644 index 3a6771b1d..000000000 --- a/tests/get_search.test.ts +++ /dev/null @@ -1,580 +0,0 @@ -import { expect, test, describe, afterAll, beforeAll } from "vitest"; -import { ErrorStatusCode } from "../src/types/index.js"; -import { - clearAllIndexes, - config, - BAD_HOST, - MeiliSearch, - getClient, -} from "./utils/meilisearch-test-utils.js"; - -const index = { - uid: "movies_test", -}; -const emptyIndex = { - uid: "empty_test", -}; - -const dataset = [ - { - id: 123, - title: "Pride and Prejudice", - author: "Jane Austen", - comment: "A great book", - genre: ["romance"], - }, - { - id: 456, - title: "Le Petit Prince", - author: "Antoine de Saint-Exupéry", - comment: "A french book about a prince that walks on little cute planets", - genre: ["adventure"], - }, - { - id: 2, - title: "Le Rouge et le Noir", - author: "Stendhal", - comment: "Another french book", - genre: ["romance"], - }, - { - id: 1, - title: "Alice In Wonderland", - author: "Lewis Carroll", - comment: "A weird book", - genre: ["adventure"], - }, - { - id: 1344, - title: "The Hobbit", - author: "J.R.R. Tolkien", - comment: "An awesome book", - genre: ["fantasy", "adventure"], - }, - { - id: 4, - title: "Harry Potter and the Half-Blood Prince", - author: "J.K. Rowling", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - { - id: 5, - title: "Harry Potter and the Deathly Hallows", - author: "J.K. Rowling", - genre: ["fantasy", "adventure"], - }, - { - id: 42, - title: "The Hitchhiker's Guide to the Galaxy", - author: "Douglas Adams", - genre: ["sci fi", "comedy"], - }, -]; - -afterAll(() => { - return clearAllIndexes(config); -}); - -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on GET search", ({ permission }) => { - beforeAll(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - await client.createIndex(index.uid).waitTask(); - await client.createIndex(emptyIndex.uid).waitTask(); - - const newFilterableAttributes = ["genre", "title", "id", "author"]; - await client - .index(index.uid) - .updateSettings({ - filterableAttributes: newFilterableAttributes, - sortableAttributes: ["id"], - }) - .waitTask(); - - await client.index(index.uid).addDocuments(dataset).waitTask(); - }); - - test(`${permission} key: Basic search`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", {}); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .searchGet("prince", { limit: 1 }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 1); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with sortable`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("", { sort: ["id:asc"] }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - const hit = response.hits[0]; - expect(hit.id).toEqual(1); - }); - - test(`${permission} key: search with array options`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - attributesToRetrieve: ["*"], - }); - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(Object.keys(hit).join(",")).toEqual( - Object.keys(dataset[1]).join(","), - ); - }); - - test(`${permission} key: search on attributesToSearchOn`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", { - attributesToSearchOn: ["id"], - }); - - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search on attributesToSearchOn set to null`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", { - attributesToSearchOn: null, - }); - - expect(response).toMatchSnapshot(); - }); - - test(`${permission} key: search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .searchGet("prince", { limit: 1 }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 1); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with limit and offset`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - limit: 1, - offset: 1, - }); - expect(response).toHaveProperty("hits", [ - { - id: 4, - title: "Harry Potter and the Half-Blood Prince", - author: "J.K. Rowling", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - ]); - expect(response).toHaveProperty("offset", 1); - expect(response).toHaveProperty("limit", 1); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with matches parameter and small croplength`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - filter: 'title = "Le Petit Prince"', - attributesToCrop: ["*"], - cropLength: 5, - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits[0]).toHaveProperty("_matchesPosition", { - comment: [{ start: 22, length: 6 }], - title: [{ start: 9, length: 6 }], - }); - }); - - test(`${permission} key: search with all options but not all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]._formatted).toHaveProperty("title"); - expect(response.hits[0]._formatted).toHaveProperty("id"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).not.toHaveProperty("description"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: search on default cropping parameters`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - attributesToCrop: ["*"], - cropLength: 6, - }); - - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "…book about a prince that walks…", - ); - }); - - test(`${permission} key: search on customized cropMarker`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - attributesToCrop: ["*"], - cropLength: 6, - cropMarker: "(ꈍᴗꈍ)", - }); - - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "(ꈍᴗꈍ)book about a prince that walks(ꈍᴗꈍ)", - ); - }); - - test(`${permission} key: search on customized highlight tags`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - attributesToHighlight: ["*"], - highlightPreTag: "(⊃。•́‿•̀。)⊃ ", - highlightPostTag: " ⊂(´• ω •`⊂)", - }); - - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "A french book about a (⊃。•́‿•̀。)⊃ prince ⊂(´• ω •`⊂) that walks on little cute planets", - ); - }); - - test(`${permission} key: search with all options and all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["*"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: search with all options but specific fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["id", "title"], - cropLength: 6, - attributesToHighlight: ["id", "title"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("id", 456); - expect(response.hits[0]).toHaveProperty("title", "Le Petit Prince"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]).not.toHaveProperty( - "description", - expect.any(Object), - ); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]._formatted).not.toHaveProperty("comment"); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: search with filter and facetDistribution`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("a", { - filter: "genre = romance", - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { romance: 2 }, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with filter on number`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("a", { - filter: "id < 0", - facets: ["genre"], - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search with filter with spaces`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("h", { - filter: 'genre = "sci fi"', - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with multiple filter`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("a", { - filter: "genre = romance AND (genre = romance OR genre = romance)", - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { romance: 2 }, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with multiple filter and undefined query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet(undefined, { - filter: "genre = fantasy", - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: search with multiple filter and null query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet(null, { - filter: "genre = fantasy", - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { - adventure: 3, - fantasy: 3, - }, - }); - expect(response.hits.length).toEqual(3); - expect(response.estimatedTotalHits).toEqual(3); - }); - - test(`${permission} key: search with multiple filter and empty string query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("", { - filter: "genre = fantasy", - facets: ["genre"], - }); - - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: Try to search with wrong format filter`, async () => { - const client = await getClient(permission); - await expect( - client.index(index.uid).searchGet("prince", { - filter: ["hello"], - }), - ).rejects.toHaveProperty( - "message", - "The filter query parameter should be in string format when using searchGet", - ); - }); - - test(`${permission} key: search without vectors`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("prince", {}); - - expect(response).not.toHaveProperty("semanticHitCount"); - }); - - test(`${permission} key: search with rankingScoreThreshold filter`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", { - showRankingScore: true, - rankingScoreThreshold: 0.8, - }); - - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(hit).toHaveProperty("_rankingScore"); - expect(hit["_rankingScore"]).toBeGreaterThanOrEqual(0.8); - - const response2 = await client.index(index.uid).search("prince", { - showRankingScore: true, - rankingScoreThreshold: 0.9, - }); - - expect(response2.hits.length).toBeLessThanOrEqual(0); - }); - - test(`${permission} key: search with distinct`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("", { distinct: "author" }); - - expect(response.hits.length).toEqual(7); - }); - - test(`${permission} key: search with retrieveVectors to true`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince", { - retrieveVectors: true, - }); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]).toHaveProperty("_vectors"); - }); - - test(`${permission} key: search without retrieveVectors`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).searchGet("prince"); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]).not.toHaveProperty("_vectors"); - }); - - test(`${permission} key: matches position contain indices`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).searchGet("fantasy", { - showMatchesPosition: true, - }); - expect(response.hits[0]._matchesPosition).toEqual({ - genre: [{ start: 0, length: 7, indices: [0] }], - }); - }); - - // This test deletes the index, so following tests may fail if they need an existing index - test(`${permission} key: Try to search on deleted index and fail`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - await masterClient.index(index.uid).delete().waitTask(); - await expect( - client.index(index.uid).searchGet("prince"), - ).rejects.toHaveProperty("cause.code", ErrorStatusCode.INDEX_NOT_FOUND); - }); -}); - -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(`get search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.index(index.uid).searchGet()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`post search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.index(index.uid).searchGet()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); -}); diff --git a/tests/search.test.ts b/tests/search.test.ts index 76611c04a..cbce615b6 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -1,1527 +1,493 @@ +import { afterAll, beforeAll, test, describe } from "vitest"; import { - expect, - test, - describe, - beforeEach, - afterAll, - beforeAll, - vi, -} from "vitest"; -import { ErrorStatusCode, MatchingStrategies } from "../src/types/index.js"; -import type { - FederatedMultiSearchParams, - MultiSearchParams, -} from "../src/types/index.js"; -import { - clearAllIndexes, - config, - BAD_HOST, - MeiliSearch, - getClient, - datasetWithNests, - getKey, - HOST, assert, + HOST, + MASTER_KEY, + ObjectKeys, } from "./utils/meilisearch-test-utils.js"; -import { MeiliSearchRequestError } from "../src/index.js"; - -const index = { - uid: "books", -}; -const emptyIndex = { - uid: "empty_test", -}; - -type Books = { - id: number; - title: string; - comment: string; - genre: string; -}; - -const dataset = [ - { - id: 123, - title: "Pride and Prejudice", - author: "Jane Austen", - comment: "A great book", - genre: ["romance"], - }, - { - id: 456, - title: "Le Petit Prince", - author: "Antoine de Saint-Exupéry", - comment: "A french book about a prince that walks on little cute planets", - genre: ["adventure"], - isNull: null, - isTrue: true, - }, - { - id: 2, - title: "Le Rouge et le Noir", - author: "Stendhal", - comment: "Another french book", - genre: ["romance"], - }, - { - id: 1, - title: "Alice In Wonderland", - author: "Lewis Carroll", - comment: "A weird book", - genre: ["adventure"], - }, - { - id: 1344, - title: "The Hobbit", - author: "J.R.R. Tolkien", - comment: "An awesome book", - genre: ["fantasy", "adventure"], - }, - { - id: 4, - title: "Harry Potter and the Half-Blood Prince", - author: "J.K. Rowling", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - { - id: 5, - title: "Harry Potter and the Deathly Hallows", - author: "J.K. Rowling", - genre: ["fantasy", "adventure"], - }, - { - id: 42, - title: "The Hitchhiker's Guide to the Galaxy", - author: "Douglas Adams", - genre: ["sci fi", "comedy"], - }, -]; - -type Movies = { - id: number; - title: string; -}; - -const movies = [ - { - id: 1, - title: "Pride and Prejudice", - }, - { - id: 2, - title: "The Hobbit: An Unexpected Journey", - }, - { - id: 3, - title: "Harry Potter and the Half-Blood Prince", - }, -]; - -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on POST search", ({ permission }) => { - beforeAll(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - await client.createIndex(index.uid); - await client.createIndex(emptyIndex.uid); - - const newFilterableAttributes = ["genre", "title", "id", "author"]; - await client - .index(index.uid) - .updateSettings({ - filterableAttributes: newFilterableAttributes, - sortableAttributes: ["id"], - }) - .waitTask(); - - await client.index(index.uid).addDocuments(dataset).waitTask(); - }); - - test(`${permission} key: Multi index search no queries`, async () => { - const client = await getClient(permission); - const response = await client.multiSearch({ - queries: [], - }); - - expect(response.results.length).toEqual(0); - }); - - test(`${permission} key: Multi index search with one query`, async () => { - const client = await getClient(permission); - const response = await client.multiSearch({ - queries: [{ indexUid: index.uid, q: "prince" }], - }); - - expect(response.results[0].hits.length).toEqual(2); - }); - - test(`${permission} key: Multi index search with multiple queries`, async () => { - const client = await getClient(permission); - const response = await client.multiSearch({ - queries: [ - { indexUid: index.uid, q: "something" }, - { indexUid: emptyIndex.uid, q: "something" }, - ], - }); - - expect(response.results.length).toEqual(2); - }); - - test(`${permission} key: Multi index search with one query`, async () => { - const client = await getClient(permission); - - type MyIndex = { - id: 1; - }; - - const response = await client.multiSearch< - MultiSearchParams, - MyIndex & Books - >({ - queries: [{ indexUid: index.uid, q: "prince" }], - }); - - expect(response.results[0].hits.length).toEqual(2); - expect(response.results[0].hits[0].id).toEqual(456); - 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< - FederatedMultiSearchParams, - 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: Multi index search with federation and remote`, async () => { - const adminKey = await getKey("Admin"); - - // first enable the network endpoint. - await fetch(`${HOST}/experimental-features`, { - body: JSON.stringify({ network: true }), - headers: { - Authorization: `Bearer ${adminKey}`, - "Content-Type": "application/json", - }, - method: "PATCH", - }); - - const masterClient = await getClient("Master"); - - const searchKey = await getKey("Search"); - - // set the remote name and instances - const instanceName = "instance_1"; - await masterClient.updateNetwork({ - self: instanceName, - remotes: { [instanceName]: { url: HOST, searchApiKey: searchKey } }, - }); - - const searchClient = await getClient(permission); - - const response = await searchClient.multiSearch< - FederatedMultiSearchParams, - Books | { id: number; asd: string } - >({ - federation: {}, - queries: [ - { - indexUid: index.uid, - q: "456", - attributesToSearchOn: ["id"], - federationOptions: { weight: 1, remote: instanceName }, - }, - { - indexUid: index.uid, - q: "1344", - federationOptions: { weight: 0.9, remote: instanceName }, - attributesToSearchOn: ["id"], - }, - ], - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - expect(response.hits[0].id).toEqual(456); - expect(response.hits[0]._federation).toHaveProperty("remote", instanceName); - }); - - test(`${permission} key: Multi search with facetsByIndex`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - - // Setup to have a new "movies" index - await masterClient.createIndex("movies"); - const newFilterableAttributes = ["title", "id"]; - await masterClient - .index("movies") - .updateSettings({ - filterableAttributes: newFilterableAttributes, - sortableAttributes: ["id"], - }) - .waitTask(); - await masterClient.index("movies").addDocuments(movies).waitTask(); - - // Make a multi search on both indexes with facetsByIndex - const response = await client.multiSearch< - FederatedMultiSearchParams, - Books | Movies - >({ - federation: { - limit: 20, - offset: 0, - facetsByIndex: { - movies: ["title", "id"], - books: ["title"], - }, - }, - queries: [ - { - q: "Hobbit", - indexUid: "movies", - }, - { - q: "Hobbit", - indexUid: "books", - }, +import { type Film, FILMS } from "./utils/test-data/films.js"; +import type { + ExplicitVectors, + Federation, + FilterExpression, + MatchingStrategy, + SearchHit, + SearchQuery, + SearchQueryWithIndexAndFederation, + SearchResultWithOffsetLimit, + SearchResultWithPagination, +} from "../src/index.js"; +import { + assertFacetDistributionAndStatsAreCorrect, + client, + federatedMultiSearch, + index, + INDEX_UID, + multiSearch, + search, + searchGet, + searchSimilarDocuments, + searchSimilarDocumentsGet, +} from "./utils/search.js"; + +beforeAll(async () => { + // TODO: If this disables the rest of the experimental features, that might be a problem, consider https://vitest.dev/config/#globalsetup + await client.updateExperimentalFeatures({ network: true }); + await client.updateNetwork({ + self: INDEX_UID, + remotes: { [INDEX_UID]: { url: HOST, searchApiKey: MASTER_KEY } }, + }); + + await index + .updateSettings({ + filterableAttributes: [ + "id", + "genres", + "popularity", + "release_date", + "_geo", ], - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - - expect(response).toHaveProperty("facetsByIndex"); - expect(response.facetsByIndex).toHaveProperty("movies"); - expect(response.facetsByIndex).toHaveProperty("books"); - - // Test search response on "movies" index - expect(response.facetsByIndex?.movies).toEqual({ - distribution: { - title: { - "The Hobbit: An Unexpected Journey": 1, - }, - id: { - "2": 1, - }, - }, - stats: { - id: { - min: 2, - max: 2, + sortableAttributes: ["popularity"], + embedders: { + default: { + source: "userProvided", + dimensions: 1, }, }, + }) + .waitTask() + .then(({ status, error }) => { + assert.isNull(error); + assert.strictEqual(status, "succeeded"); }); - // Test search response on "books" index - expect(response.facetsByIndex?.books).toEqual({ - distribution: { - title: { - "The Hobbit": 1, - }, - }, - stats: {}, + await index + .addDocuments(FILMS) + .waitTask() + .then(({ status, error }) => { + assert.isNull(error); + assert.strictEqual(status, "succeeded"); }); - }); - - test(`${permission} key: Multi search with mergeFacets`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - - // Setup to have a new "movies" index - await masterClient.createIndex("movies"); - const newFilterableAttributes = ["title", "id"]; - await masterClient - .index("movies") - .updateSettings({ - filterableAttributes: newFilterableAttributes, - sortableAttributes: ["id"], - }) - .waitTask(); - await masterClient.index("movies").addDocuments(movies).waitTask(); - - // Make a multi search on both indexes with mergeFacets - const response = await client.multiSearch< - FederatedMultiSearchParams, - Books | Movies - >({ - federation: { - limit: 20, - offset: 0, - facetsByIndex: { - movies: ["title", "id"], - books: ["title"], - }, - mergeFacets: { - maxValuesPerFacet: 10, - }, - }, - queries: [ - { - q: "Hobbit", - indexUid: "movies", - }, - { - q: "Hobbit", - indexUid: "books", - }, - ], - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - - expect(response).toHaveProperty("facetDistribution"); - expect(response).toHaveProperty("facetStats"); - - expect(response.facetDistribution).toEqual({ - title: { - "The Hobbit": 1, - "The Hobbit: An Unexpected Journey": 1, - }, - id: { - "2": 1, - }, - }); - - expect(response.facetStats).toEqual({ - id: { - min: 2, - max: 2, - }, - }); - }); - - test(`${permission} key: Basic search`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", {}); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.facetStats).toBeUndefined(); - expect(response.hits.length).toEqual(2); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.hitsPerPage).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.page).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.totalPages).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.totalHits).toBeUndefined(); - }); - - test(`${permission} key: Basic phrase search with matchingStrategy at ALL`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search('"french book" about', { - matchingStrategy: MatchingStrategies.ALL, - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 20); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: Basic phrase search with matchingStrategy at LAST`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("french book", { matchingStrategy: MatchingStrategies.LAST }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 20); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: Basic phrase search with matchingStrategy at FREQUENCY`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("french book", { - matchingStrategy: MatchingStrategies.FREQUENCY, - }); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 20); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: Search with query in searchParams overwriting query`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("other", { q: "prince" }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response.estimatedTotalHits).toBeDefined(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: Search with query in searchParams overwriting null query`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search(null, { q: "prince" }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response.estimatedTotalHits).toBeDefined(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: Basic phrase search`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search('"french book" about', {}); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("limit", 20); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", '"french book" about'); - - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("prince", { limit: 1 }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 1); - expect(response.estimatedTotalHits).toEqual(2); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with sortable`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("", { sort: ["id:asc"] }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - const hit = response.hits[0]; - expect(hit.id).toEqual(1); - }); - - test(`${permission} key: search with _showRankingScore enabled`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - showRankingScore: true, - }); - - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(hit).toHaveProperty("_rankingScore"); - }); - - test(`${permission} key: search with showRankingScoreDetails`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - showRankingScoreDetails: true, - }); - - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(hit).toHaveProperty("_rankingScoreDetails"); - expect(Object.keys(hit._rankingScoreDetails || {})).toEqual([ - "words", - "typo", - "proximity", - "attribute", - "exactness", - ]); - }); - - test(`${permission} key: search with rankingScoreThreshold filter`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - showRankingScore: true, - rankingScoreThreshold: 0.8, - }); - - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(hit).toHaveProperty("_rankingScore"); - expect(hit["_rankingScore"]).toBeGreaterThanOrEqual(0.8); - - const response2 = await client.index(index.uid).search("prince", { - showRankingScore: true, - rankingScoreThreshold: 0.9, - }); - - expect(response2.hits.length).toBeLessThanOrEqual(0); - }); - - test(`${permission} key: search with array options`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - attributesToRetrieve: ["*"], - }); - const hit = response.hits[0]; - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(Object.keys(hit).join(",")).toEqual( - Object.keys(dataset[1]).join(","), - ); - }); - - test(`${permission} key: search on attributesToSearchOn`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - attributesToSearchOn: ["id"], - }); - - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search on attributesToSearchOn set to null`, async () => { - const client = await getClient(permission); +}); - const response = await client.index(index.uid).search("prince", { - attributesToSearchOn: null, +afterAll(async () => { + await index + .delete() + .waitTask() + .then(({ status, error }) => { + assert.isNull(error); + assert.strictEqual(status, "succeeded"); }); + await client.updateNetwork({}); +}); - expect(response).toMatchSnapshot(); - }); +describe.concurrent("`q` param", () => { + const params: SearchQuery = { q: "earth" }; - test(`${permission} key: search with array options`, async () => { - const client = await getClient(permission); + test.for([search, searchGet, multiSearch])( + "with $name", + async ({ searchMethod }) => { + const { query, processingTimeMs, hits } = await searchMethod(params); - const response = await client.index(index.uid).search("prince", { - attributesToRetrieve: ["*"], - }); - const hit = response.hits[0]; + assert.typeOf(processingTimeMs, "number"); + assert.strictEqual(query, params.q); + assert.sameMembers( + hits.map((v) => v.id), + [95, 348, 607, 608], + ); + }, + ); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("query", "prince"); - expect(Object.keys(hit).join(",")).toEqual( - Object.keys(dataset[1]).join(","), + // Does this affect the search result? + test(`with ${index.searchForFacetValues.name}`, async () => { + await assert.resolves( + index.searchForFacetValues({ facetName: "popularity", ...params }), ); }); +}); - test(`${permission} key: search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("prince", { limit: 1 }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 1); - expect(response.estimatedTotalHits).toEqual(2); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with limit and offset`, async () => { - const client = await getClient(permission); +describe.concurrent("`limit` and `offset` params", () => { + const params = { limit: 1, offset: 1 }; - const response = await client.index(index.uid).search("prince", { - limit: 1, - offset: 1, - }); + test.for([ + search, + searchGet, + multiSearch, + federatedMultiSearch, + searchSimilarDocuments, + searchSimilarDocumentsGet, + ])("with $name", async ({ searchMethod }) => { + const { limit, offset, estimatedTotalHits } = (await searchMethod( + params, + )) as SearchResultWithOffsetLimit; - expect(response).toHaveProperty("hits", [ - { - id: 4, - author: "J.K. Rowling", - title: "Harry Potter and the Half-Blood Prince", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - ]); - expect(response).toHaveProperty("offset", 1); - expect(response).toHaveProperty("limit", 1); - expect(response.estimatedTotalHits).toEqual(2); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.hitsPerPage).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.page).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.totalPages).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because neither `page` or `hitsPerPage` is provided in the search params. - expect(response.totalHits).toBeUndefined(); + assert.typeOf(estimatedTotalHits, "number"); + assert.deepEqual({ limit, offset }, params); }); +}); - test(`${permission} key: search with matches parameter and small croplength`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - filter: 'title = "Le Petit Prince"', - attributesToCrop: ["*"], - cropLength: 5, - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits[0]).toHaveProperty("_matchesPosition", { - comment: [{ start: 22, length: 6 }], - title: [{ start: 9, length: 6 }], - }); - }); +describe.concurrent("pagination params", () => { + const params = { page: 1, hitsPerPage: 1 }; + + test.for([search, searchGet, multiSearch])( + "with $name", + async ({ searchMethod }) => { + const { page, hitsPerPage, totalHits, totalPages } = (await searchMethod( + params, + )) as SearchResultWithPagination; + + assert.typeOf(totalHits, "number"); + assert.typeOf(totalPages, "number"); + assert.deepEqual({ page, hitsPerPage }, params); + }, + ); +}); - test(`${permission} key: search with all options but not all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response.estimatedTotalHits).toEqual(1); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]._formatted).toHaveProperty("title"); - expect(response.hits[0]._formatted).toHaveProperty("id"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).not.toHaveProperty("description"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), +describe.concurrent("params affecting only `hits` or `hit._formatted`", () => { + const params: SearchQuery = { + attributesToRetrieve: ["overview", "popularity"], + attributesToSearchOn: ["overview"], + attributesToCrop: ["overview"], + cropLength: 1, + cropMarker: "♥", + attributesToHighlight: ["overview"], + highlightPreTag: "☻", + highlightPostTag: "☺", + distinct: "popularity", + sort: ["popularity:asc"], + locales: ["eng"], + // needed for some formatting to fully take effect + q: "earth", + // to limit number of results + limit: 2, + }; + + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { hits } = await searchMethod(params); + + for (const hit of hits) { + assert.lengthOf(Object.keys(hit), 3); + assert.typeOf(hit.overview, "string"); + assert.typeOf(hit.popularity, "number"); + + assert.isDefined(hit._formatted); + assert.lengthOf(Object.keys(hit._formatted), 2); + const { overview, popularity } = hit._formatted; + + assert.includeDeepMembers( + [ + { overview: "♥☻Earth☺♥", popularity: "34.945" }, + { overview: "♥☻earth☺♥", popularity: "41.947" }, + ], + [{ overview, popularity }], + ); + } + }, + ); + + // Does this affect the search result? + test(`with ${index.searchForFacetValues.name}`, async () => { + const { attributesToSearchOn, locales } = params; + + await assert.resolves( + index.searchForFacetValues({ + facetName: "popularity", + attributesToSearchOn, + locales, + }), ); }); - test(`${permission} key: search on default cropping parameters`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToCrop: ["*"], - cropLength: 6, - }); + test.for([searchSimilarDocuments, searchSimilarDocumentsGet])( + "with $name", + async ({ searchMethod }) => { + const { attributesToRetrieve } = params; - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "…book about a prince that walks…", - ); - }); + const { hits } = await searchMethod({ attributesToRetrieve }); - test(`${permission} key: search on customized cropMarker`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToCrop: ["*"], - cropLength: 6, - cropMarker: "(ꈍᴗꈍ)", - }); - - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "(ꈍᴗꈍ)book about a prince that walks(ꈍᴗꈍ)", - ); - }); + for (const hit of hits) { + assert.lengthOf(Object.keys(hit), 2); + assert.typeOf(hit.overview, "string"); + assert.typeOf(hit.popularity, "number"); + } + }, + ); +}); - test(`${permission} key: search on customized highlight tags`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["*"], - highlightPreTag: "(⊃。•́‿•̀。)⊃ ", - highlightPostTag: " ⊂(´• ω •`⊂)", - }); +describe.concurrent("`facets` param", () => { + const params: SearchQuery = { + facets: ["genres", "popularity"], + // to limit number of results + filter: "popularity < 17", + }; + + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { facetDistribution, facetStats } = await searchMethod(params); + assertFacetDistributionAndStatsAreCorrect(facetDistribution, facetStats); + }, + ); +}); - expect(response.hits[0]._formatted).toHaveProperty( - "comment", - "A french book about a (⊃。•́‿•̀。)⊃ prince ⊂(´• ω •`⊂) that walks on little cute planets", - ); +describe.concurrent("ranking score params", () => { + const params = { + showRankingScore: true, + showRankingScoreDetails: true, + rankingScoreThreshold: 0.5, + }; + + test.for([ + search, + searchGet, + multiSearch, + federatedMultiSearch, + searchSimilarDocuments, + searchSimilarDocumentsGet, + ])("with $name", async ({ searchMethod }) => { + const { hits } = await searchMethod(params); + + for (const { _rankingScore, _rankingScoreDetails } of hits) { + assert.typeOf(_rankingScore, "number"); + // TODO: This could be more thoroughly tested and typed + assert.typeOf(_rankingScoreDetails, "object"); + } }); - test(`${permission} key: search with all options and all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["*"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); + // Does this affect the search result? + test(`with ${index.searchForFacetValues.name}`, async () => { + const { rankingScoreThreshold } = params; - test(`${permission} key: search with all options but specific fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["id", "title"], - cropLength: 6, - attributesToHighlight: ["id", "title"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response).toHaveProperty("offset", 0); - expect(response).toHaveProperty("limit", 5); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(1); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]).toHaveProperty("title", "Le Petit Prince"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).toHaveProperty("_formatted", expect.any(Object)); - expect(response.hits[0]).not.toHaveProperty( - "description", - expect.any(Object), - ); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", + await assert.resolves( + index.searchForFacetValues({ + facetName: "genres", + rankingScoreThreshold, + }), ); - expect(response.hits[0]._formatted).not.toHaveProperty("comment"); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: Search with specific fields in attributesToHighlight and check for types of number fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["title"], - }); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]._formatted?.isNull).toEqual(null); - expect(response.hits[0]._formatted?.isTrue).toEqual(true); - }); - - test(`${permission} key: Search with specific fields in attributesToHighlight and check for types of number fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["title", "id"], - }); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]._formatted?.isNull).toEqual(null); - expect(response.hits[0]._formatted?.isTrue).toEqual(true); - }); - - test(`${permission} key: search with filter and facetDistribution`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: ["genre = romance"], - facets: ["genre", "id"], - }); - - expect(response).toHaveProperty("facetDistribution", { - genre: { romance: 2 }, - id: { "123": 1, "2": 1 }, - }); - - expect(response.facetStats).toEqual({ id: { min: 2, max: 123 } }); - expect(response.facetStats?.["id"]?.max).toBe(123); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with filter on number`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: "id < 0", - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search with filter with spaces`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("h", { - filter: ['genre = "sci fi"'], - }); - - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(1); - }); - - test(`${permission} key: search with multiple filter`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: ["genre = romance", ["genre = romance", "genre = romance"]], - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { romance: 2 }, - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length).toEqual(2); - }); - - test(`${permission} key: search with multiple filter and undefined query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search(undefined, { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: search with multiple filter and null query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search(null, { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: search with multiple filter and empty string query (placeholder)`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("", { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response).toHaveProperty("facetDistribution", { - genre: { adventure: 3, fantasy: 3 }, - }); - expect(response.hits.length).toEqual(3); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage and page`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - }); - - expect(response.hits.length).toEqual(1); - expect(response.totalPages).toEqual(8); - expect(response.hitsPerPage).toEqual(1); - expect(response.page).toEqual(1); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage at 0 and page at 1`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 0, - page: 1, - }); - - expect(response.hits.length).toEqual(0); - expect(response.hitsPerPage).toEqual(0); - expect(response.page).toEqual(1); - expect(response.totalPages).toEqual(0); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage at 0`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 0, - }); - - expect(response.hits.length).toEqual(0); - expect(response.hitsPerPage).toEqual(0); - expect(response.page).toEqual(1); - expect(response.totalPages).toEqual(0); - expect(response.totalHits).toEqual(8); - // @ts-expect-error Not present in the SearchResponse type because `page` and/or `hitsPerPage` is provided in the search params. - expect(response.limit).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because `page` and/or `hitsPerPage` is provided in the search params. - expect(response.offset).toBeUndefined(); - // @ts-expect-error Not present in the SearchResponse type because `page` and/or `hitsPerPage` is provided in the search params. - expect(response.estimatedTotalHits).toBeUndefined(); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage at 1 and page at 0`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 0, - }); - - expect(response.hits.length).toEqual(0); - expect(response.hitsPerPage).toEqual(1); - expect(response.page).toEqual(0); - expect(response.totalPages).toEqual(8); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters: page at 0`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - page: 0, - }); - - expect(response.hits.length).toEqual(0); - expect(response.hitsPerPage).toEqual(20); - expect(response.page).toEqual(0); - expect(response.totalPages).toEqual(1); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters: hitsPerPage at 0 and page at 0`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 0, - page: 0, - }); - - expect(response.hits.length).toEqual(0); - - // @ts-expect-error Property not existing on type - expect(response.limit).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.offset).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.estimatedTotalHits).toBeUndefined(); - - expect(response.hitsPerPage).toEqual(0); - expect(response.page).toEqual(0); - expect(response.totalPages).toEqual(0); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters hitsPerPage/page and offset/limit`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - offset: 1, - limit: 1, - }); - - expect(response.hits.length).toEqual(1); - // @ts-expect-error Property not existing on type - expect(response.limit).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.offset).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.estimatedTotalHits).toBeUndefined(); - expect(response.hitsPerPage).toEqual(1); - expect(response.page).toEqual(1); - expect(response.totalPages).toEqual(8); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters hitsPerPage/page and offset`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - offset: 1, - }); - - expect(response.hits.length).toEqual(1); - // @ts-expect-error Property not existing on type - expect(response.limit).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.offset).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.estimatedTotalHits).toBeUndefined(); - expect(response.hitsPerPage).toEqual(1); - expect(response.page).toEqual(1); - expect(response.totalHits).toEqual(8); - expect(response.totalPages).toEqual(8); - }); - - test(`${permission} key: search with pagination parameters hitsPerPage/page and limit`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - limit: 1, - }); - - expect(response.hits.length).toEqual(1); - // @ts-expect-error Property not existing on type - expect(response.limit).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.offset).toBeUndefined(); - // @ts-expect-error Property not existing on type - expect(response.estimatedTotalHits).toBeUndefined(); - expect(response.page).toEqual(1); - expect(response.hitsPerPage).toEqual(1); - expect(response.totalPages).toEqual(8); - expect(response.totalHits).toEqual(8); - }); - - test(`${permission} key: search on index with no documents and no primary key`, async () => { - const client = await getClient(permission); - const response = await client.index(emptyIndex.uid).search("prince", {}); - - expect(response).toHaveProperty("hits", []); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits.length).toEqual(0); - }); - - test(`${permission} key: search without vectors`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", {}); - - expect(response).not.toHaveProperty("semanticHitCount"); - }); - - test(`${permission} key: search with distinct`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("", { distinct: "author" }); - - expect(response.hits.length).toEqual(7); - }); - - test(`${permission} key: search with retrieveVectors to true`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince", { - retrieveVectors: true, - }); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]).toHaveProperty("_vectors"); - }); - - test(`${permission} key: search without retrieveVectors`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("prince"); - - expect(response).toHaveProperty("hits", expect.any(Array)); - expect(response).toHaveProperty("query", "prince"); - expect(response.hits[0]).not.toHaveProperty("_vectors"); - }); - - test(`${permission} key: Search with locales`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - - await masterClient - .index(index.uid) - .updateLocalizedAttributes([ - { attributePatterns: ["title", "comment"], locales: ["fra", "eng"] }, - ]) - .waitTask(); - - const searchResponse = await client.index(index.uid).search("french", { - locales: ["fra", "eng"], - }); - - expect(searchResponse.hits.length).toEqual(2); - }); - - test(`${permission} key: matches position contain indices`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("fantasy", { - showMatchesPosition: true, - }); - expect(response.hits[0]._matchesPosition).toEqual({ - genre: [{ start: 0, length: 7, indices: [0] }], - }); }); +}); - // This test deletes the index, so following tests may fail if they need an existing index - test(`${permission} key: Try to search on deleted index and fail`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - await masterClient.index(index.uid).delete().waitTask(); - - await expect( - client.index(index.uid).search("prince", {}), - ).rejects.toHaveProperty("cause.code", ErrorStatusCode.INDEX_NOT_FOUND); - }); +describe.concurrent("`showMatchesPosition` param", () => { + const params: SearchQuery = { + showMatchesPosition: true, + // required to get matches + q: "apple", + // to limit number of results + limit: 1, + }; + + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { hits } = await searchMethod(params); + + assert.deepEqual( + hits.map((hit) => hit._matchesPosition), + [ + { + "providers.buy.name": [{ indices: [0], length: 5, start: 0 }], + "providers.rent.name": [{ indices: [0], length: 5, start: 0 }], + }, + ], + ); + }, + ); }); -describe.each([{ permission: "No" }])( - "Test failing test on search", - ({ permission }) => { - beforeAll(async () => { - const client = await getClient("Master"); - await client.createIndex(index.uid).waitTask(); - }); +const possibleMatchingStrategies = ObjectKeys({ + last: null, + all: null, + frequency: null, +}); - test(`${permission} key: Try Basic search and be denied`, async () => { - const client = await getClient(permission); - await expect( - client.index(index.uid).search("prince"), - ).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, - ); - }); +describe.concurrent.for(possibleMatchingStrategies)( + "`matchingStrategy` = `%s` param", + (matchingStrategy) => { + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + await assert.resolves(searchMethod({ matchingStrategy })); + }, + ); - test(`${permission} key: Try multi search and be denied`, async () => { - const client = await getClient(permission); - await expect(client.multiSearch({ queries: [] })).rejects.toHaveProperty( - "cause.code", - ErrorStatusCode.MISSING_AUTHORIZATION_HEADER, + // Does this affect the search result? + test(`with ${index.searchForFacetValues.name}`, async () => { + await assert.resolves( + index.searchForFacetValues({ + facetName: "genres", + matchingStrategy, + }), ); }); }, ); -describe.each([{ permission: "Master" }])( - "Tests on documents with nested objects", - ({ permission }) => { - beforeEach(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - await client.createIndex(index.uid); - - await client.index(index.uid).addDocuments(datasetWithNests).waitTask(); - }); - - test(`${permission} key: search on nested content with no parameters`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("An awesome", {}); - - expect(response.hits[0]).toEqual({ - id: 5, - title: "The Hobbit", - info: { - comment: "An awesome book", - reviewNb: 900, - }, - }); - }); - - test(`${permission} key: search on nested content with searchable on specific nested field`, async () => { - const client = await getClient(permission); - await client - .index(index.uid) - .updateSettings({ - searchableAttributes: ["title", "info.comment"], - }) - .waitTask(); +// TODO: Another filter with geo filtering +const filterExpressions: [name: string, filter: FilterExpression][] = [ + ["string", `release_date < ${Date.parse("1998-01-01")} AND id = 607`], + ["array", [[`release_date < ${Date.parse("1998-01-01")}`], ["id = 607"]]], +]; - const response = await client.index(index.uid).search("An awesome", {}); +describe.concurrent.for(filterExpressions)( + "%s `filter` param", + ([, filter]) => { + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { hits } = await searchMethod({ filter }); + + assert.deepEqual( + hits.map((v) => v.id), + [607], + ); + }, + ); - expect(response.hits[0]).toEqual({ - id: 5, - title: "The Hobbit", - info: { - comment: "An awesome book", - reviewNb: 900, - }, - }); + test(`with ${index.searchForFacetValues.name}`, async () => { + await assert.resolves( + index.searchForFacetValues({ + facetName: "genres", + filter, + }), + ); }); - test(`${permission} key: search on nested content with sort`, async () => { - const client = await getClient(permission); - await client - .index(index.uid) - .updateSettings({ - searchableAttributes: ["title", "info.comment"], - sortableAttributes: ["info.reviewNb"], - }) - .waitTask(); - - const response = await client.index(index.uid).search("", { - sort: ["info.reviewNb:desc"], - }); - - expect(response.hits[0]).toEqual({ - id: 6, - title: "Harry Potter and the Half-Blood Prince", - info: { - comment: "The best book", - reviewNb: 1000, - }, - }); - }); + test.for([searchSimilarDocuments, searchSimilarDocumentsGet])( + "with $name", + async ({ searchMethod }) => { + await assert.resolves(searchMethod({ filter })); + }, + ); }, ); -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on abortable search", ({ permission }) => { - beforeAll(async () => { - const client = await getClient("Master"); - await clearAllIndexes(config); - await client.createIndex(index.uid).waitTask(); - }); - - test(`${permission} key: search on index and abort`, async () => { - const controller = new AbortController(); - const client = await getClient(permission); - const searchPromise = client - .index(index.uid) - .search("unreachable", {}, { signal: controller.signal }); +describe.concurrent("`filter` param with geo", () => { + const filter = "_geoRadius(45.472735, 9.184019, 2000)"; - controller.abort(); + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { hits } = await searchMethod({ filter }); - searchPromise.catch((error) => { - expect(error).toHaveProperty( - "cause.message", - "This operation was aborted", + assert.sameMembers( + hits.map((v) => v.id), + [95, 97], ); - }); - }); - - test(`${permission} key: search on index multiple times, and abort only one request`, async () => { - const ind = (await getClient(permission)).index(index.uid); - const controllerA = new AbortController(); - const controllerB = new AbortController(); - const controllerC = new AbortController(); - const searchQuery = "prince"; - - const searchAPromise = ind.search( - searchQuery, - {}, - { signal: controllerA.signal }, - ); - - const searchBPromise = ind.search( - searchQuery, - {}, - { signal: controllerB.signal }, - ); - - const searchCPromise = ind.search( - searchQuery, - {}, - { signal: controllerC.signal }, + }, + ); + + test(`with ${index.searchForFacetValues.name}`, async () => { + await assert.resolves( + index.searchForFacetValues({ + facetName: "genres", + filter, + }), ); - - const searchDPromise = ind.search(searchQuery, {}); - - controllerB.abort(); - - const [a, b, c, d] = await Promise.allSettled([ - searchAPromise, - searchBPromise, - searchCPromise, - searchDPromise, - ]); - - expect(a).toHaveProperty("value.query", searchQuery); - expect(b).toHaveProperty( - "reason.cause.message", - "This operation was aborted", - ); - expect(d).toHaveProperty("value.query", searchQuery); - expect(c).toHaveProperty("value.query", searchQuery); }); - test(`${permission} key: search should be aborted when reaching timeout`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - timeout: 1, - }); - - const error = await assert.rejects( - client.health(), - MeiliSearchRequestError, - ); + test.for([searchSimilarDocuments, searchSimilarDocumentsGet])( + "with $name", + async ({ searchMethod }) => { + await assert.resolves(searchMethod({ filter })); + }, + ); +}); - assert.strictEqual( - (error.cause as Error)?.message, - "request timed out after 1ms", - ); - }); +test.concurrent(`${federatedMultiSearch.name} method`, async () => { + const queries: SearchQueryWithIndexAndFederation[] = [ + { + indexUid: INDEX_UID, + q: "earth", + federationOptions: { + weight: 1, + remote: INDEX_UID, + queryPosition: 1, + }, + }, + ]; + const federation: Federation = { + facetsByIndex: { [INDEX_UID]: ["genres", "popularity"] }, + }; - test(`${permission} key: search should be aborted on abort signal`, async () => { - const key = await getKey(permission); - const client = new MeiliSearch({ - ...config, - apiKey: key, - timeout: 1_000, + const { hits, facetsByIndex, remoteErrors } = + await client.federatedMultiSearch({ + queries, + federation, }); - const someErrorObj = {}; - - const ac = new AbortController(); - ac.abort(someErrorObj); - - const error = await assert.rejects( - client.multiSearch( - { queries: [{ indexUid: "doesn't matter" }] }, - { signal: ac.signal }, - ), - MeiliSearchRequestError, - ); - assert.strictEqual(error.cause, someErrorObj); - // and now with a delayed abort, for this we have to stub fetch - vi.stubGlobal( - "fetch", - (_: unknown, requestInit?: RequestInit) => - new Promise((_, reject) => - requestInit?.signal?.addEventListener("abort", () => - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(requestInit.signal?.reason), - ), - ), - ); + for (const { + _federation: { weightedRankingScore, ..._federation }, + } of hits) { + assert.typeOf(weightedRankingScore, "number"); + assert.deepEqual(_federation, { + indexUid: INDEX_UID, + queriesPosition: 1, + remote: INDEX_UID, + }); + } - try { - const ac = new AbortController(); + assert.sameMembers(Object.keys(facetsByIndex), [INDEX_UID]); + const { distribution, stats } = facetsByIndex[INDEX_UID]; + assertFacetDistributionAndStatsAreCorrect(distribution, stats); - const promise = client.multiSearch( - { queries: [{ indexUid: "doesn't matter" }] }, - { signal: ac.signal }, - ); - setTimeout(() => ac.abort(someErrorObj), 1); - const error = await assert.rejects(promise, MeiliSearchRequestError); - assert.strictEqual(error.cause, someErrorObj); - } finally { - vi.unstubAllGlobals(); - } - }); -}); + // TODO: Maybe could get an error response for this, to validate it against + assert.deepEqual(remoteErrors, {}); -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(`get search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.index(index.uid).search()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); + const { facetDistribution, facetStats } = await client.federatedMultiSearch({ + queries, + federation: { ...federation, mergeFacets: { maxValuesPerFacet: 100 } }, }); - test(`post search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect(client.index(index.uid).search()).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); + assert.deepEqual(facetDistribution, distribution); + assert.deepEqual(facetStats, stats); }); -afterAll(() => { - return clearAllIndexes(config); +// TODO: +function assertSomething(hits: SearchHit[]) { + for (const { _vectors } of hits) { + assert.lengthOf(Object.keys(_vectors), 1); + const { + default: { embeddings, ...restOfObj }, + } = _vectors as Record; + + for (const embedding of embeddings) { + assert(Array.isArray(embedding)); + for (const embeddingElement of embedding) { + assert.typeOf(embeddingElement, "number"); + } + } + + assert.deepEqual(restOfObj, { regenerate: false }); + } +} + +describe.concurrent("embedding related params", () => { + const params: SearchQuery = { + q: "", + vector: [1], + hybrid: { semanticRatio: 1.0, embedder: "default" }, + retrieveVectors: true, + }; + + test.for([search, searchGet, multiSearch, federatedMultiSearch])( + "with $name", + async ({ searchMethod }) => { + const { semanticHitCount, hits } = await searchMethod(params); + + assert.typeOf(semanticHitCount, "number"); + assertSomething(hits); + }, + ); + + test.for([searchSimilarDocuments, searchSimilarDocumentsGet])( + "with $name", + async ({ searchMethod }) => { + const { retrieveVectors } = params; + const { hits } = await searchMethod({ retrieveVectors }); + + assertSomething(hits); + }, + ); }); diff --git a/tests/token.test.ts b/tests/token.test.ts index 349c95037..bd813985a 100644 --- a/tests/token.test.ts +++ b/tests/token.test.ts @@ -332,7 +332,7 @@ describe.each([{ permission: "Admin" }])( // search await expect( - searchClient.index(UID).search("pride"), + searchClient.index(UID).search({ q: "pride" }), ).rejects.toHaveProperty("cause.code", "invalid_api_key"); }); @@ -350,7 +350,7 @@ describe.each([{ permission: "Admin" }])( // search await expect( - searchClient.index(UID).search("pride"), + searchClient.index(UID).search({ q: "pride" }), ).rejects.toHaveProperty("cause.code", "invalid_api_key"); }); diff --git a/tests/typed_search.test.ts b/tests/typed_search.test.ts deleted file mode 100644 index a34381989..000000000 --- a/tests/typed_search.test.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - test, -} from "vitest"; -import { ErrorStatusCode, type SearchResponse } from "../src/types/index.js"; -import { - clearAllIndexes, - config, - BAD_HOST, - MeiliSearch, - getClient, - datasetWithNests, -} from "./utils/meilisearch-test-utils.js"; - -const index = { - uid: "movies_test", -}; -const emptyIndex = { - uid: "empty_test", -}; - -interface Movie { - id: number; - title: string; - comment?: string; - genre?: string[]; - isNull?: null; - isTrue?: boolean; -} - -interface NestedDocument { - id: number; - title: string; - info: { - comment?: string; - reviewNb?: number; - }; -} - -const dataset = [ - { - id: 123, - title: "Pride and Prejudice", - author: "Jane Austen", - comment: "A great book", - genre: ["romance"], - }, - { - id: 456, - title: "Le Petit Prince", - author: "Antoine de Saint-Exupéry", - comment: "A french book about a prince that walks on little cute planets", - genre: ["adventure"], - isNull: null, - isTrue: true, - }, - { - id: 2, - title: "Le Rouge et le Noir", - author: "Stendhal", - comment: "Another french book", - genre: ["romance"], - }, - { - id: 1, - title: "Alice In Wonderland", - author: "Lewis Carroll", - comment: "A weird book", - genre: ["adventure"], - }, - { - id: 1344, - title: "The Hobbit", - author: "J.R.R. Tolkien", - comment: "An awesome book", - genre: ["fantasy", "adventure"], - }, - { - id: 4, - title: "Harry Potter and the Half-Blood Prince", - author: "J.K. Rowling", - comment: "The best book", - genre: ["fantasy", "adventure"], - }, - { - id: 5, - title: "Harry Potter and the Deathly Hallows", - author: "J.K. Rowling", - genre: ["fantasy", "adventure"], - }, - { - id: 42, - title: "The Hitchhiker's Guide to the Galaxy", - author: "Douglas Adams", - genre: ["sci fi", "comedy"], - }, -]; - -afterAll(() => { - return clearAllIndexes(config); -}); - -describe.each([ - { permission: "Master" }, - { permission: "Admin" }, - { permission: "Search" }, -])("Test on search", ({ permission }) => { - beforeAll(async () => { - const client = await getClient("Master"); - await clearAllIndexes(config); - - await client.createIndex(index.uid).waitTask(); - await client.createIndex(emptyIndex.uid).waitTask(); - - const newFilterableAttributes = ["genre", "title"]; - await client - .index(index.uid) - .updateFilterableAttributes(newFilterableAttributes) - .waitTask(); - - await client.index(index.uid).addDocuments(dataset).waitTask(); - }); - - test(`${permission} key: Basic search`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", {}); - expect(response.hits.length === 2).toBeTruthy(); - expect(response.limit === 20).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - }); - - test(`${permission} key: Search with query in searchParams`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("other", { q: "prince" }); // ensures `q` is a valid field in SearchParams type - - expect(response).toHaveProperty("query", "prince"); - }); - - test(`${permission} key: Search with options`, async () => { - const client = await getClient(permission); - const response = await client - .index(index.uid) - .search("prince", { limit: 1 }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response.limit === 1).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - }); - - test(`${permission} key: Search with limit and offset`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 1, - offset: 1, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 1).toBeTruthy(); - // expect(response.bloub).toEqual(0) -> ERROR, bloub does not exist on type Response - expect(response.limit === 1).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - expect(response.hits[0].id).toEqual(4); - expect(response.hits[0].title).toEqual( - "Harry Potter and the Half-Blood Prince", - ); - expect(response.hits[0].comment).toEqual("The best book"); - expect(response.hits[0].genre).toEqual(["fantasy", "adventure"]); - expect(response.query === "prince").toBeTruthy(); - }); - - test(`${permission} key: Search with matches parameter and small croplength`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - filter: 'title = "Le Petit Prince"', - attributesToCrop: ["*"], - cropLength: 5, - showMatchesPosition: true, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.hits[0]?._matchesPosition?.comment).toEqual([ - { start: 22, length: 6 }, - ]); - expect(response.hits[0]?._matchesPosition?.title).toEqual([ - { start: 9, length: 6 }, - ]); - }); - - test(`${permission} key: Search with all options but not all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response.limit === 5).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]).not.toHaveProperty("comment"); - expect(response.hits[0]).not.toHaveProperty("description"); - expect(response.hits[0]._formatted).toHaveProperty("comment"); - expect(response.hits[0]._formatted).not.toHaveProperty("description"); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.hits[0]).toHaveProperty( - "_matchesPosition", - expect.any(Object), - ); - }); - - test(`${permission} key: Search with all options and all fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["*"], - attributesToCrop: ["*"], - cropLength: 6, - attributesToHighlight: ["*"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response.limit === 5).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - expect(response.hits[0]?.title === "Le Petit Prince").toBeTruthy(); - expect( - response.hits[0]?._matchesPosition?.title?.[0]?.start === 9, - ).toBeTruthy(); - expect( - response.hits[0]?._matchesPosition?.title?.[0]?.length === 6, - ).toBeTruthy(); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - }); - - test(`${permission} key: Search with all options but specific fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - limit: 5, - offset: 0, - attributesToRetrieve: ["id", "title"], - attributesToCrop: ["id", "title"], - cropLength: 6, - attributesToHighlight: ["id", "title"], - filter: 'title = "Le Petit Prince"', - showMatchesPosition: true, - }); - expect(response.hits.length === 1).toBeTruthy(); - expect(response.offset === 0).toBeTruthy(); - expect(response.limit === 5).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - - expect(response.hits[0].id).toEqual(456); - expect(response.hits[0].title).toEqual("Le Petit Prince"); - // ERROR Property 'comment' does not exist on type 'Hit>'. - // expect(response.hits[0].comment).toEqual('comment') - - expect(response.hits[0]?.title === "Le Petit Prince").toBeTruthy(); - expect(response.hits[0]?._matchesPosition?.title).toEqual([ - { start: 9, length: 6 }, - ]); - expect(response.hits[0]._formatted).toHaveProperty( - "title", - "Le Petit Prince", - ); - expect(response.hits[0]).not.toHaveProperty( - "description", - expect.any(Object), - ); - expect(response.hits[0]._formatted).not.toHaveProperty("comment"); - }); - - test(`${permission} key: Search with specific fields in attributesToHighlight and check for types of number fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["title"], - }); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]._formatted?.isNull).toEqual(null); - expect(response.hits[0]._formatted?.isTrue).toEqual(true); - }); - - test(`${permission} key: Search with specific fields in attributesToHighlight and check for types of number fields`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("prince", { - attributesToHighlight: ["title", "id"], - }); - expect(response.hits[0]._formatted?.id).toEqual("456"); - expect(response.hits[0]._formatted?.isNull).toEqual(null); - expect(response.hits[0]._formatted?.isTrue).toEqual(true); - }); - - test(`${permission} key: Search with filter and facets`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: ["genre=romance"], - facets: ["genre"], - }); - expect(response.facetDistribution?.genre?.romance === 2).toBeTruthy(); - expect(response.hits.length === 2).toBeTruthy(); - }); - - test(`${permission} key: Search with filter with spaces`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("h", { - filter: ['genre="sci fi"'], - }); - expect(response).toHaveProperty("hits"); - expect(Array.isArray(response.hits)).toBe(true); - expect(response.hits.length === 1).toBeTruthy(); - }); - - test(`${permission} key: Search with multiple filter`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search("a", { - filter: ["genre=romance", ["genre=romance", "genre=romance"]], - facets: ["genre"], - }); - expect(response.facetDistribution?.genre?.romance === 2).toBeTruthy(); - expect(response.hits.length === 2).toBeTruthy(); - }); - - test(`${permission} key: Search with multiple filter and placeholder search using undefined`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search(undefined, { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response.facetDistribution?.genre?.fantasy === 3).toBeTruthy(); - expect(response.hits.length === 3).toBeTruthy(); - }); - - test(`${permission} key: Search with multiple filter and placeholder search using NULL`, async () => { - const client = await getClient(permission); - const response = await client.index(index.uid).search(null, { - filter: ["genre = fantasy"], - facets: ["genre"], - }); - expect(response.facetDistribution?.genre?.fantasy === 3).toBeTruthy(); - expect(response.hits.length === 3).toBeTruthy(); - }); - - test(`${permission} key: Search on index with no documents and no primary key`, async () => { - const client = await getClient(permission); - const response = await client.index(emptyIndex.uid).search("prince", {}); - - expect(response.hits.length === 0).toBeTruthy(); - expect(response).toHaveProperty("processingTimeMs", expect.any(Number)); - expect(response.query === "prince").toBeTruthy(); - }); - - test(`${permission} key: search with pagination parameters hitsPerPage/page and offset`, async () => { - const client = await getClient(permission); - - const response = await client.index(index.uid).search("", { - hitsPerPage: 1, - page: 1, - limit: 1, - }); - - expect(response.hits.length).toEqual(1); - expect(response.hitsPerPage === 1).toBeTruthy(); - expect(response.page === 1).toBeTruthy(); - expect(response.totalPages === 8).toBeTruthy(); - expect(response.totalHits === 8).toBeTruthy(); - }); - - test(`${permission} key: Try to Search on deleted index and fail`, async () => { - const client = await getClient(permission); - const masterClient = await getClient("Master"); - await masterClient.index(index.uid).delete().waitTask(); - - await expect( - client.index(index.uid).search("prince"), - ).rejects.toHaveProperty("cause.code", ErrorStatusCode.INDEX_NOT_FOUND); - }); -}); - -describe.each([{ permission: "Master" }])( - "Tests on documents with nested objects", - ({ permission }) => { - beforeEach(async () => { - await clearAllIndexes(config); - const client = await getClient("Master"); - await client.createIndex(index.uid); - - await client.index(index.uid).addDocuments(datasetWithNests).waitTask(); - }); - - test(`${permission} key: search on nested content with no parameters`, async () => { - const client = await getClient(permission); - const response: SearchResponse = await client - .index(index.uid) - .search("An awesome", {}); - - expect(response.hits[0].info?.comment === "An awesome book").toBeTruthy(); - expect(response.hits[0].info?.reviewNb === 900).toBeTruthy(); - }); - - test(`${permission} key: search on nested content with searchable on specific nested field`, async () => { - const client = await getClient(permission); - await client - .index(index.uid) - .updateSettings({ - searchableAttributes: ["title", "info.comment"], - }) - .waitTask(); - - const response: SearchResponse = await client - .index(index.uid) - .search("An awesome", {}); - - expect(response.hits[0].info?.comment === "An awesome book").toBeTruthy(); - expect(response.hits[0].info?.reviewNb === 900).toBeTruthy(); - }); - - test(`${permission} key: search on nested content with sort`, async () => { - const client = await getClient(permission); - await client - .index(index.uid) - .updateSettings({ - searchableAttributes: ["title", "info.comment"], - sortableAttributes: ["info.reviewNb"], - }) - .waitTask(); - - const response: SearchResponse = await client - .index(index.uid) - .search("", { - sort: ["info.reviewNb:desc"], - }); - - expect(response.hits[0].info?.comment === "The best book").toBeTruthy(); - expect(response.hits[0].info?.reviewNb === 1000).toBeTruthy(); - }); - }, -); - -describe.each([{ permission: "No" }])( - "Test failing test on search", - ({ permission }) => { - beforeEach(async () => { - await clearAllIndexes(config); - }); - - test(`${permission} key: Try Basic search and be denied`, async () => { - const client = await getClient(permission); - await expect( - client.index(index.uid).search("prince"), - ).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(`get search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect( - client.index(index.uid).search(), - ).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); - - test(`post search route`, async () => { - const route = `indexes/${index.uid}/search`; - const client = new MeiliSearch({ host }); - const strippedHost = trailing ? host.slice(0, -1) : host; - await expect( - client.index(index.uid).search(), - ).rejects.toHaveProperty( - "message", - `Request to ${strippedHost}/${route} has failed`, - ); - }); -}); diff --git a/tests/utils/meilisearch-test-utils.ts b/tests/utils/meilisearch-test-utils.ts index e9faf9510..793efc8dd 100644 --- a/tests/utils/meilisearch-test-utils.ts +++ b/tests/utils/meilisearch-test-utils.ts @@ -1,6 +1,6 @@ import { assert as vitestAssert } from "vitest"; import { MeiliSearch, Index } from "../../src/index.js"; -import type { Config } from "../../src/types/index.js"; +import type { Config, Task } from "../../src/types/index.js"; // testing const MASTER_KEY = "masterKey"; @@ -93,6 +93,7 @@ function decode64(buff: string) { } const NOT_RESOLVED = Symbol(""); +const RESOLVED = Symbol(""); const source = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -127,6 +128,17 @@ const source = { "expected value to not resolve", ); }, + async resolves(promise: Promise): Promise { + try { + await promise; + } catch (error) { + vitestAssert.fail(error, RESOLVED, "expected value to not reject"); + } + }, + isTaskSuccessful(task: Task) { + vitestAssert.isNull(task.error); + vitestAssert.strictEqual(task.status, "succeeded"); + }, }; export const assert: typeof vitestAssert & typeof source = Object.assign( vitestAssert, @@ -244,7 +256,12 @@ export type Book = { author: string; }; +function ObjectKeys(o: { [TKey in T]: null }): T[] { + return Object.keys(o) as T[]; +} + export { + ObjectKeys, clearAllIndexes, config, masterClient, diff --git a/tests/utils/search.ts b/tests/utils/search.ts new file mode 100644 index 000000000..cd689f0eb --- /dev/null +++ b/tests/utils/search.ts @@ -0,0 +1,147 @@ +import { assert, getClient } from "./meilisearch-test-utils.js"; +import type { Film } from "./test-data/films.js"; +import type { + FacetStats, + MeiliSearch, + SearchHit, + SearchQuery, + SearchQueryWithOffsetLimit, + SearchResult, +} from "../../src/index.js"; + +export const INDEX_UID = "b568151e-ddb8-455e-85b9-871e8d57c6b7"; +export const client = await getClient("Master"); +export const index = client.index(INDEX_UID); + +export const [ + search, + searchGet, + multiSearch, + federatedMultiSearch, + searchSimilarDocuments, + searchSimilarDocumentsGet, +] = [ + { name: index.search.name, searchMethod: index.search.bind(index) }, + { + name: index.searchGet.name, + searchMethod: (searchQuery) => { + const { hybrid, ...rest } = searchQuery; + + return index.searchGet( + hybrid == null + ? rest + : { + ...rest, + hybridSemanticRatio: hybrid.semanticRatio, + hybridEmbedder: hybrid.embedder, + }, + ); + }, + }, + { + name: "multiSearch" satisfies keyof Pick, + searchMethod: async (searchQuery) => { + const { results } = await client.multiSearch({ + queries: [{ indexUid: INDEX_UID, ...searchQuery }], + }); + + assert.lengthOf(results, 1); + const [{ indexUid, hits, ...result }] = results; + assert.strictEqual(indexUid, INDEX_UID); + + return { ...result, hits: hits as SearchHit[] }; + }, + }, + { + name: "federatedMultiSearch" satisfies keyof Pick< + MeiliSearch, + "federatedMultiSearch" + >, + searchMethod: async (searchQuery) => { + const { offset, limit, facets, ...restOfSearchQuery } = + searchQuery as SearchQueryWithOffsetLimit; + + const { hits, ...result } = await client.federatedMultiSearch({ + queries: [{ indexUid: INDEX_UID, ...restOfSearchQuery }], + federation: { + offset: offset ?? undefined, + limit: limit ?? undefined, + facetsByIndex: { [INDEX_UID]: facets ?? null }, + mergeFacets: {}, + }, + }); + + return { + ...result, + hits: hits.map(({ _federation, ...hit }) => hit as SearchHit), + }; + }, + }, + { + name: index.searchSimilarDocuments.name, + searchMethod: async ({ + offset, + limit, + ...searchQuery + }: SearchQueryWithOffsetLimit) => { + const { hits, ...result } = await index.searchSimilarDocuments({ + id: 607, + embedder: "default", + offset: offset ?? undefined, + limit: limit ?? undefined, + ...searchQuery, + }); + + return { + ...result, + query: searchQuery.q ?? "", + hits: hits as SearchHit[], + }; + }, + }, + { + name: index.searchSimilarDocumentsGet.name, + searchMethod: async ({ + offset, + limit, + ...searchQuery + }: SearchQueryWithOffsetLimit) => { + const { hits, ...result } = await index.searchSimilarDocumentsGet({ + id: 607, + embedder: "default", + offset: offset ?? undefined, + limit: limit ?? undefined, + ...searchQuery, + }); + + return { + ...result, + query: searchQuery.q ?? "", + hits: hits as SearchHit[], + }; + }, + }, +] as const satisfies { + name: string; + searchMethod: (searchQuery: SearchQuery) => Promise>; +}[]; + +export function assertFacetDistributionAndStatsAreCorrect( + distribution?: Record>, + stats?: Record, +) { + assert.isDefined(distribution); + for (const indDist of Object.values(distribution)) { + for (const val of Object.values(indDist)) { + assert.typeOf(val, "number"); + } + } + + assert.isDefined(stats); + for (const val of Object.values(stats)) { + assert.sameMembers(Object.keys(val), ["min", "max"]); + const { min, max } = val; + assert.typeOf(min, "number"); + assert.typeOf(max, "number"); + } +} diff --git a/tests/utils/test-data/films.ts b/tests/utils/test-data/films.ts new file mode 100644 index 000000000..e8d1a434e --- /dev/null +++ b/tests/utils/test-data/films.ts @@ -0,0 +1,887 @@ +export type Film = { + id: number; + title: string; + overview: string; + popularity: number; + release_date: number; + poster_path: string; + providers: Record; + genres: string[]; + language: string; + // TODO: Should type these too in lib? + _geo?: { lat: number; lng: number }; + _vectors: Record; +}; + +export const FILMS: Film[] = [ + { + id: 95, + title: "Armageddon", + overview: + "When an asteroid threatens to collide with Earth, NASA honcho Dan Truman determines the only way to stop it is to drill into its surface and detonate a nuclear bomb. This leads him to renowned driller Harry Stamper, who agrees to helm the dangerous space mission provided he can bring along his own hotshot crew. Among them is the cocksure A.J. who Harry thinks isn't good enough for his daughter, until the mission proves otherwise.", + popularity: 34.945, + release_date: Date.parse("1998-07-01"), + poster_path: + "https://image.tmdb.org/t/p/w780/eTM3qtGhDU8cvjpoa6KEt5E2auU.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "fuboTV", + logo: "/2wPRZit7b8u79GsqTdygmGL6kBW.jpg", + }, + ], + }, + genres: ["Action", "Thriller", "Science Fiction", "Adventure"], + language: "en", + _geo: { lat: 45.4777599, lng: 9.1967508 }, + _vectors: { default: [0.8] }, + }, + { + id: 98, + title: "Gladiator", + overview: + "In the year 180, the death of emperor Marcus Aurelius throws the Roman Empire into chaos. Maximus is one of the Roman army's most capable and trusted generals and a key advisor to the emperor. As Marcus' devious son Commodus ascends to the throne, Maximus is set to be executed. He escapes, but is captured by slave traders. Renamed Spaniard and forced to become a gladiator, Maximus must battle to the death with other men for the amusement of paying audiences.", + popularity: 51.818, + release_date: Date.parse("2000-05-01"), + poster_path: + "https://image.tmdb.org/t/p/w780/rotQFyaeNQivUJOm3J3M7YqPNMx.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "Netflix", + logo: "/9A1JSVmSxsyaBK4SUFsYVqbAYfW.jpg", + }, + ], + }, + genres: ["Action", "Drama", "Adventure"], + language: "en", + _geo: { lat: 48.8826517, lng: 2.3352748 }, + _vectors: { default: [0.6] }, + }, + { + id: 106, + title: "Predator", + overview: + "A team of commandos on a mission in a Central American jungle find themselves hunted by an extraterrestrial warrior.", + popularity: 44.021, + release_date: Date.parse("1987-06-12"), + poster_path: + "https://image.tmdb.org/t/p/w780/iieEddHTv5zzTyr7OnN5ULOu7bI.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Science Fiction", "Action", "Adventure", "Thriller"], + language: "en", + _vectors: { default: [0.1] }, + }, + { + id: 97, + title: "Tron", + overview: + "As Kevin Flynn searches for proof that he invented a hit video game, he is 'digitized' by a laser and finds himself inside 'The Grid', where programs suffer under the tyrannical rule of the Master Control Program (MCP). With the help of a security program called 'TRON', Flynn seeks to free The Grid from the MCP.", + popularity: 16.007, + release_date: Date.parse("1982-07-09"), + poster_path: + "https://image.tmdb.org/t/p/w780/zwSFEczP7AzqugAHHIX3zHniT0t.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Science Fiction", "Action", "Adventure"], + language: "en", + _geo: { lat: 45.4632046, lng: 9.1719421 }, + _vectors: { default: [0.7] }, + }, + { + id: 107, + title: "Snatch", + overview: + "Unscrupulous boxing promoters, violent bookmakers, a Russian gangster, incompetent amateur robbers and supposedly Jewish jewelers fight to track down a priceless stolen diamond.", + popularity: 18.364, + release_date: Date.parse("2000-09-01"), + poster_path: + "https://image.tmdb.org/t/p/w780/56mOJth6DJ6JhgoE2jtpilVqJO.jpg", + + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + + genres: ["Crime", "Comedy"], + language: "en", + _vectors: { default: [-0.5] }, + }, + { + id: 104, + title: "Run Lola Run", + overview: + "Lola receives a phone call from her boyfriend Manni. He lost 100,000 DM in a subway train that belongs to a very bad guy. She has 20 minutes to raise this amount and meet Manni. Otherwise, he will rob a store to get the money. Three different alternatives may happen depending on some minor event along Lola's run.", + popularity: 13.53, + release_date: Date.parse("1998-03-03"), + + poster_path: + "https://image.tmdb.org/t/p/w780/yBt6rkxRTP15nyOZOJt9pOgXDW0.jpg", + + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Action", "Drama", "Thriller"], + language: "en", + _vectors: { default: null }, + }, + { + id: 348, + title: "Alien", + overview: + "During its return to the earth, commercial spaceship Nostromo intercepts a distress signal from a distant planet. When a three-member team of the crew discovers a chamber containing thousands of eggs on the planet, a creature inside one of the eggs attacks an explorer. The entire crew is unaware of the impending nightmare set to descend upon them when the alien parasite planted inside its unfortunate host is birthed.", + popularity: 41.947, + release_date: Date.parse("1979-05-25"), + poster_path: + "https://image.tmdb.org/t/p/w780/vfrQk5IPloGg1v9Rzbh2Eg3VGyM.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "Amazon Prime Video", + logo: "/68MNrwlkpF7WnmNPXLah69CR5cb.jpg", + }, + ], + }, + genres: ["Horror", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 402, + title: "Basic Instinct", + overview: + "A violent police detective investigates a brutal murder that might involve a manipulative and seductive novelist.", + popularity: 23.737, + release_date: Date.parse("1992-03-20"), + poster_path: + "https://image.tmdb.org/t/p/w780/76Ts0yoHk8kVQj9MMnoMixhRWoh.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Thriller", "Mystery"], + language: "en", + _vectors: { default: null }, + }, + { + id: 170, + title: "28 Days Later", + overview: + "Twenty-eight days after a killer virus was accidentally unleashed from a British research facility, a small group of London survivors are caught in a desperate struggle to protect themselves from the infected. Carried by animals and humans, the virus turns those it infects into homicidal maniacs -- and it's absolutely impossible to contain.", + popularity: 40.725, + release_date: Date.parse("2002-10-31"), + poster_path: + "https://image.tmdb.org/t/p/w780/w4SL5hv0qOanrN7GjwNgtjF1RtD.jpg", + + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Horror", "Thriller", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 508, + title: "Love Actually", + overview: + "'Love Actually' follows the lives of eight very different couples dealing with their love lives, in various loosely and interrelated tales, all set during a frantic month before Christmas in London, England.", + popularity: 21.26, + release_date: Date.parse("2003-09-07"), + poster_path: + "https://image.tmdb.org/t/p/w780/7QPeVsr9rcFU9Gl90yg0gTOTpVv.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "IMDB TV Amazon Channel", + logo: "/nd4NLxYeSv2TQ3HFzsecbAuSq1C.jpg", + }, + ], + }, + genres: ["Comedy", "Romance", "Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 200, + title: "Star Trek: Insurrection", + overview: + 'When an alien race and factions within Starfleet attempt to take over a planet that has "regenerative" properties, it falls upon Captain Picard and the crew of the Enterprise to defend the planet\'s people as well as the very ideals upon which the Federation itself was founded.', + popularity: 17.696, + release_date: Date.parse("1998-12-11"), + poster_path: + "https://image.tmdb.org/t/p/w780/9pbc44kltJhArUNyrdQcantMEvH.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Science Fiction", "Action", "Adventure", "Thriller"], + language: "en", + _vectors: { default: null }, + }, + { + id: 155, + title: "The Dark Knight", + overview: + "Batman raises the stakes in his war on crime. With the help of Lt. Jim Gordon and District Attorney Harvey Dent, Batman sets out to dismantle the remaining criminal organizations that plague the streets. The partnership proves to be effective, but they soon find themselves prey to a reign of chaos unleashed by a rising criminal mastermind known to the terrified citizens of Gotham as the Joker.", + popularity: 68.919, + release_date: Date.parse("2008-07-14"), + poster_path: + "https://image.tmdb.org/t/p/w780/qJ2tW6WMUDux911r6m7haRef0WH.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Drama", "Action", "Crime", "Thriller"], + language: "en", + _vectors: { default: null }, + }, + { + id: 217, + title: "Indiana Jones and the Kingdom of the Crystal Skull", + overview: + "Set during the Cold War, the Soviets—led by sword-wielding Irina Spalko—are in search of a crystal skull which has supernatural powers related to a mystical Lost City of Gold. Indy is coerced to head to Peru at the behest of a young man whose friend—and Indy's colleague—Professor Oxley has been captured for his knowledge of the skull's whereabouts.", + popularity: 27.343, + release_date: Date.parse("2008-05-21"), + poster_path: + "https://image.tmdb.org/t/p/w780/56As6XEM1flWvprX4LgkPl8ii4K.jpg", + + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Adventure", "Action"], + language: "en", + _vectors: { default: null }, + }, + { + id: 185, + title: "A Clockwork Orange", + overview: + "In a near-future Britain, young Alexander DeLarge and his pals get their kicks beating and raping anyone they please. When not destroying the lives of others, Alex swoons to the music of Beethoven. The state, eager to crack down on juvenile crime, gives an incarcerated Alex the option to undergo an invasive procedure that'll rob him of all personal agency. In a time when conscience is a commodity, can Alex change his tune?", + popularity: 33.915, + release_date: Date.parse("1971-12-19"), + poster_path: + "https://image.tmdb.org/t/p/w780/4sHeTAp65WrSSuc05nRBKddhBxO.jpg", + providers: { + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + + genres: ["Science Fiction", "Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 162, + title: "Edward Scissorhands", + overview: + "A small suburban town receives a visit from a castaway unfinished science experiment named Edward.", + popularity: 41.017, + release_date: Date.parse("1990-12-05"), + poster_path: + "https://image.tmdb.org/t/p/w780/1RFIbuW9Z3eN9Oxw2KaQG5DfLmD.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Fantasy", "Drama", "Romance"], + language: "en", + _vectors: { default: null }, + }, + { + id: 364, + title: "Batman Returns", + overview: + "While Batman deals with a deformed man calling himself the Penguin, an employee of a corrupt businessman transforms into the Catwoman.", + popularity: 27.257, + release_date: Date.parse("1992-06-19"), + poster_path: + "https://image.tmdb.org/t/p/w780/jKBjeXM7iBBV9UkUcOXx3m7FSHY.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Action", "Fantasy"], + language: "en", + _vectors: { default: null }, + }, + { + id: 196, + title: "Back to the Future Part III", + overview: + "The final installment of the Back to the Future trilogy finds Marty digging the trusty DeLorean out of a mineshaft and looking for Doc in the Wild West of 1885. But when their time machine breaks down, the travelers are stranded in a land of spurs. More problems arise when Doc falls for pretty schoolteacher Clara Clayton, and Marty tangles with Buford Tannen.", + popularity: 27.714, + release_date: Date.parse("1990-05-25"), + poster_path: + "https://image.tmdb.org/t/p/w780/crzoVQnMzIrRfHtQw0tLBirNfVg.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Adventure", "Comedy", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 180, + title: "Minority Report", + overview: + "John Anderton is a top 'Precrime' cop in the late-21st century, when technology can predict crimes before they're committed. But Anderton becomes the quarry when another investigator targets him for a murder charge.", + popularity: 20.598, + release_date: Date.parse("2002-06-20"), + poster_path: + "https://image.tmdb.org/t/p/w780/ccqpHq5tk5W4ymbSbuoy4uYOxFI.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [{ name: "Hulu", logo: "/giwM8XX4V2AQb9vsoN7yti82tKK.jpg" }], + }, + genres: ["Action", "Thriller", "Science Fiction", "Mystery"], + language: "en", + _vectors: { default: null }, + }, + { + id: 114, + title: "Pretty Woman", + overview: + "When a millionaire wheeler-dealer enters a business contract with a Hollywood hooker Vivian Ward, he loses his heart in the bargain.", + popularity: 35.235, + release_date: Date.parse("1990-03-23"), + poster_path: + "https://image.tmdb.org/t/p/w780/hMVMMy1yDUvdufpTl8J8KKNYaZX.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Google Play Movies", + logo: "/p3Z12gKq2qvJaUOMeKNU2mzKVI9.jpg", + }, + { + name: "AMC on Demand", + logo: "/p1e92kLeYHalxC9GClqNJ75lBDG.jpg", + }, + ], + flatrate: [ + { + name: "fuboTV", + logo: "/2wPRZit7b8u79GsqTdygmGL6kBW.jpg", + }, + ], + }, + genres: ["Romance", "Comedy"], + language: "en", + _vectors: { default: null }, + }, + { + id: 65, + title: "8 Mile", + overview: + 'The setting is Detroit in 1995. The city is divided by 8 Mile, a road that splits the town in half along racial lines. A young white rapper, Jimmy "B-Rabbit" Smith Jr. summons strength within himself to cross over these arbitrary boundaries to fulfill his dream of success in hip hop. With his pal Future and the three one third in place, all he has to do is not choke.', + popularity: 30.162, + release_date: Date.parse("2002-11-08"), + poster_path: + "https://image.tmdb.org/t/p/w780/7BmQj8qE1FLuLTf7Xjf9sdIHzoa.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "HBO Max", + logo: "/aS2zvJWn9mwiCOeaaCkIh4wleZS.jpg", + }, + ], + }, + genres: ["Music", "Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 165, + title: "Back to the Future Part II", + overview: + "Marty and Doc are at it again in this wacky sequel to the 1985 blockbuster as the time-traveling duo head to 2015 to nip some McFly family woes in the bud. But things go awry thanks to bully Biff Tannen and a pesky sports almanac. In a last-ditch attempt to set things straight, Marty finds himself bound for 1955 and face to face with his teenage parents -- again.", + popularity: 26.354, + release_date: Date.parse("1989-11-22"), + poster_path: + "https://image.tmdb.org/t/p/w780/hQq8xZe5uLjFzSBt4LanNP7SQjl.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + rent: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + flatrate: [ + { + name: "AMC Plus", + logo: "/9DrynzGqT6DNLoc4BB3rlcxFRn.jpg", + }, + ], + }, + genres: ["Adventure", "Comedy", "Family", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 197, + title: "Braveheart", + overview: + "Enraged at the slaughter of Murron, his new bride and childhood love, Scottish warrior William Wallace slays a platoon of the local English lord's soldiers. This leads the village to revolt and, eventually, the entire country to rise up against English rule.", + popularity: 36.408, + release_date: Date.parse("1995-03-14"), + poster_path: + "https://image.tmdb.org/t/p/w780/uE2Q9RaNBdJbAV1N67LhwCYluK0.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Action", "Drama", "History", "War"], + language: "en", + _vectors: { default: null }, + }, + { + id: 607, + title: "Men in Black", + overview: + "After a police chase with an otherworldly being, a New York City cop is recruited as an agent in a top-secret organization established to monitor and police alien activity on Earth: the Men in Black. Agent Kay and new recruit Agent Jay find themselves in the middle of a deadly plot by an intergalactic terrorist who has arrived on Earth to assassinate two ambassadors from opposing galaxies.", + popularity: 35.303, + release_date: Date.parse("1997-07-02"), + poster_path: + "https://image.tmdb.org/t/p/w780/uLOmOF5IzWoyrgIF5MfUnh5pa1X.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Action", "Adventure", "Comedy", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 24, + title: "Kill Bill: Vol. 1", + overview: + "An assassin is shot by her ruthless employer, Bill, and other members of their assassination circle – but she lives to plot her vengeance.", + popularity: 36.782, + release_date: Date.parse("2003-10-10"), + poster_path: + "https://image.tmdb.org/t/p/w780/v7TaX8kXMXs5yFFGR41guUDNcnB.jpg", + providers: { + buy: [ + { + name: "Google Play Movies", + logo: "/p3Z12gKq2qvJaUOMeKNU2mzKVI9.jpg", + }, + ], + }, + genres: ["Action", "Crime"], + language: "en", + _vectors: { default: null }, + }, + { + id: 393, + title: "Kill Bill: Vol. 2", + overview: + "The Bride unwaveringly continues on her roaring rampage of revenge against the band of assassins who had tried to kill her and her unborn child. She visits each of her former associates one-by-one, checking off the victims on her Death List Five until there's nothing left to do … but kill Bill.", + popularity: 32.364, + release_date: Date.parse("2004-04-16"), + poster_path: + "https://image.tmdb.org/t/p/w780/2yhg0mZQMhDyvUQ4rG1IZ4oIA8L.jpg", + providers: { + buy: [ + { + name: "Google Play Movies", + logo: "/p3Z12gKq2qvJaUOMeKNU2mzKVI9.jpg", + }, + ], + }, + genres: ["Action", "Crime", "Thriller"], + language: "en", + _vectors: { default: null }, + }, + { + id: 606, + title: "Out of Africa", + overview: + "Out of Africa tells the story of the life of Danish author Karen Blixen, who at the beginning of the 20th century moved to Africa to build a new life for herself. The film is based on the autobiographical novel by Karen Blixen from 1937.", + popularity: 15.148, + release_date: Date.parse("1985-12-20"), + poster_path: + "https://image.tmdb.org/t/p/w780/3eLAm1kuVD5QZCOydbiu7j6GAbw.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["History", "Romance", "Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 608, + title: "Men in Black II", + overview: + "Kay and Jay reunite to provide our best, last and only line of defense against a sinister seductress who levels the toughest challenge yet to the MIB's untarnished mission statement – protecting Earth from the scum of the universe. It's been four years since the alien-seeking agents averted an intergalactic disaster of epic proportions. Now it's a race against the clock as Jay must convince Kay – who not only has absolutely no memory of his time spent with the MIB, but is also the only living person left with the expertise to save the galaxy – to reunite with the MIB before the earth submits to ultimate destruction.", + popularity: 31.8, + release_date: Date.parse("2002-07-03"), + poster_path: + "https://image.tmdb.org/t/p/w780/enA22EPyzc2WQ1VVyY7zxresQQr.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Action", "Adventure", "Comedy", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 115, + title: "The Big Lebowski", + overview: + "Jeffrey 'The Dude' Lebowski, a Los Angeles slacker who only wants to bowl and drink White Russians, is mistaken for another Jeffrey Lebowski, a wheelchair-bound millionaire, and finds himself dragged into a strange series of events involving nihilists, adult film producers, ferrets, errant toes, and large sums of money.", + popularity: 20.46, + release_date: Date.parse("1998-03-06"), + poster_path: + "https://image.tmdb.org/t/p/w780/84zhHB9wreAwPxGXys8CxSk9ARr.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Comedy", "Crime"], + language: "en", + _vectors: { default: null }, + }, + { + id: 389, + title: "12 Angry Men", + overview: + "The defense and the prosecution have rested and the jury is filing into the jury room to decide if a young Spanish-American is guilty or innocent of murdering his father. What begins as an open and shut case soon becomes a mini-drama of each of the jurors' prejudices and preconceptions about the trial, the accused, and each other.", + popularity: 18.549, + release_date: Date.parse("1957-04-10"), + poster_path: + "https://image.tmdb.org/t/p/w780/e02s4wmTAExkKg0yF4dEG98ZRpK.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Drama"], + language: "en", + _vectors: { default: null }, + }, + { + id: 11, + title: "Star Wars", + overview: + "Princess Leia is captured and held hostage by the evil Imperial forces in their effort to take over the galactic Empire. Venturesome Luke Skywalker and dashing captain Han Solo team together with the loveable robot duo R2-D2 and C-3PO to rescue the beautiful princess and restore peace and justice in the Empire.", + popularity: 68.013, + release_date: Date.parse("1977-05-25"), + poster_path: + "https://image.tmdb.org/t/p/w780/6FfCtAuVAW8XJjZ7eWeLibRLWTw.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Adventure", "Action", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 161, + title: "Ocean's Eleven", + overview: + "Less than 24 hours into his parole, charismatic thief Danny Ocean is already rolling out his next plan: In one night, Danny's hand-picked crew of specialists will attempt to steal more than $150 million from three Las Vegas casinos. But to score the cash, Danny risks his chances of reconciling with ex-wife, Tess.", + popularity: 24.355, + release_date: Date.parse("2001-12-07"), + poster_path: + "https://image.tmdb.org/t/p/w780/v5D7K4EHuQHFSjveq8LGxdSfrGS.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Thriller", "Crime"], + language: "en", + _vectors: { default: null }, + }, + { + id: 18, + title: "The Fifth Element", + overview: + "In 2257, a taxi driver is unintentionally given the task of saving a young girl who is part of the key that will ensure the survival of humanity.", + popularity: 37.356, + release_date: Date.parse("1997-05-02"), + poster_path: + "https://image.tmdb.org/t/p/w780/fPtlCO1yQtnoLHOwKtWz7db6RGU.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Adventure", "Fantasy", "Action", "Thriller", "Science Fiction"], + language: "en", + _vectors: { default: null }, + }, + { + id: 138, + title: "Dracula", + overview: + "British estate agent Renfield travels to Transylvania to meet with the mysterious Count Dracula, who is interested in leasing a castle in London and is, unbeknownst to Renfield, a vampire. After Dracula enslaves Renfield and drives him to insanity, the pair sail to London together, and as Dracula begins preying on London socialites, the two become the subject of study for a supernaturalist professor, Abraham Van Helsing.", + popularity: 13.227, + release_date: Date.parse("1931-02-12"), + poster_path: + "https://image.tmdb.org/t/p/w780/ueVSPt7vAba0XScHWTDWS5tNxYX.jpg", + providers: { + buy: [ + { + name: "Apple iTunes", + logo: "/q6tl6Ib6X5FT80RMlcDbexIo4St.jpg", + }, + ], + }, + genres: ["Horror", "Drama", "Fantasy"], + language: "en", + _vectors: { default: null }, + }, +];