diff --git a/dump/src/lib.rs b/dump/src/lib.rs index 1e21eed05..33bd8c63b 100644 --- a/dump/src/lib.rs +++ b/dump/src/lib.rs @@ -210,6 +210,7 @@ pub(crate) mod test { use big_s::S; use maplit::{btreemap, btreeset}; use meilisearch_types::facet_values_sort::FacetValuesSort; + use meilisearch_types::features::RuntimeTogglableFeatures; use meilisearch_types::index_uid_pattern::IndexUidPattern; use meilisearch_types::keys::{Action, Key}; use meilisearch_types::milli; @@ -418,7 +419,10 @@ pub(crate) mod test { } keys.flush().unwrap(); - // ========== TODO: create features here + // ========== experimental features + let features = create_test_features(); + + dump.create_experimental_features(features).unwrap(); // create the dump let mut file = tempfile::tempfile().unwrap(); @@ -428,6 +432,10 @@ pub(crate) mod test { file } + fn create_test_features() -> RuntimeTogglableFeatures { + RuntimeTogglableFeatures { vector_store: true, ..Default::default() } + } + #[test] fn test_creating_and_read_dump() { let mut file = create_test_dump(); @@ -472,5 +480,9 @@ pub(crate) mod test { for (key, expected) in dump.keys().unwrap().zip(create_test_api_keys()) { assert_eq!(key.unwrap(), expected); } + + // ==== checking the features + let expected = create_test_features(); + assert_eq!(dump.features().unwrap().unwrap(), expected); } } diff --git a/dump/src/reader/mod.rs b/dump/src/reader/mod.rs index 0899579f8..af02888d2 100644 --- a/dump/src/reader/mod.rs +++ b/dump/src/reader/mod.rs @@ -195,8 +195,53 @@ pub(crate) mod test { use meili_snap::insta; use super::*; + use crate::reader::v6::RuntimeTogglableFeatures; - // TODO: add `features` to tests + #[test] + fn import_dump_v6_experimental() { + let dump = File::open("tests/assets/v6-with-experimental.dump").unwrap(); + let mut dump = DumpReader::open(dump).unwrap(); + + // top level infos + insta::assert_display_snapshot!(dump.date().unwrap(), @"2023-07-06 7:10:27.21958 +00:00:00"); + insta::assert_debug_snapshot!(dump.instance_uid().unwrap(), @"None"); + + // tasks + let tasks = dump.tasks().unwrap().collect::>>().unwrap(); + let (tasks, update_files): (Vec<_>, Vec<_>) = tasks.into_iter().unzip(); + meili_snap::snapshot_hash!(meili_snap::json_string!(tasks), @"d45cd8571703e58ae53c7bd7ce3f5c22"); + assert_eq!(update_files.len(), 2); + assert!(update_files[0].is_none()); // the dump creation + assert!(update_files[1].is_none()); // the processed document addition + + // keys + let keys = dump.keys().unwrap().collect::>>().unwrap(); + meili_snap::snapshot_hash!(meili_snap::json_string!(keys), @"13c2da155e9729c2344688cab29af71d"); + + // indexes + let mut indexes = dump.indexes().unwrap().collect::>>().unwrap(); + // the index are not ordered in any way by default + indexes.sort_by_key(|index| index.metadata().uid.to_string()); + + let mut test = indexes.pop().unwrap(); + assert!(indexes.is_empty()); + + insta::assert_json_snapshot!(test.metadata(), @r###" + { + "uid": "test", + "primaryKey": "id", + "createdAt": "2023-07-06T07:07:41.364694Z", + "updatedAt": "2023-07-06T07:07:41.396114Z" + } + "###); + + assert_eq!(test.documents().unwrap().count(), 1); + + assert_eq!( + dump.features().unwrap().unwrap(), + RuntimeTogglableFeatures { vector_store: true, ..Default::default() } + ); + } #[test] fn import_dump_v5() { @@ -274,6 +319,8 @@ pub(crate) mod test { let documents = spells.documents().unwrap().collect::>>().unwrap(); assert_eq!(documents.len(), 10); meili_snap::snapshot_hash!(format!("{:#?}", documents), @"235016433dd04262c7f2da01d1e808ce"); + + assert_eq!(dump.features().unwrap(), None); } #[test] diff --git a/dump/src/writer.rs b/dump/src/writer.rs index 695610f78..3c8126876 100644 --- a/dump/src/writer.rs +++ b/dump/src/writer.rs @@ -292,6 +292,7 @@ pub(crate) mod test { │ ├---- update_files/ │ │ └---- 1.jsonl │ └---- queue.jsonl + ├---- experimental-features.json ├---- instance_uid.uuid ├---- keys.jsonl └---- metadata.json diff --git a/dump/tests/assets/v6-with-experimental.dump b/dump/tests/assets/v6-with-experimental.dump new file mode 100644 index 000000000..ac6833dc3 Binary files /dev/null and b/dump/tests/assets/v6-with-experimental.dump differ diff --git a/meilisearch-types/src/features.rs b/meilisearch-types/src/features.rs index 6d02fc47b..f62300485 100644 --- a/meilisearch-types/src/features.rs +++ b/meilisearch-types/src/features.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)] #[serde(rename_all = "camelCase", default)] pub struct RuntimeTogglableFeatures { pub score_details: bool, diff --git a/meilisearch/tests/auth/authorization.rs b/meilisearch/tests/auth/authorization.rs index 58fba4481..5af214e5f 100644 --- a/meilisearch/tests/auth/authorization.rs +++ b/meilisearch/tests/auth/authorization.rs @@ -61,6 +61,8 @@ pub static AUTHORIZATIONS: Lazy hashset!{"keys.delete", "*"}, ("POST", "/keys") => hashset!{"keys.create", "*"}, ("GET", "/keys") => hashset!{"keys.get", "*"}, + ("GET", "/experimental-features") => hashset!{"experimental.get", "*"}, + ("PATCH", "/experimental-features") => hashset!{"experimental.update", "*"}, }; authorizations diff --git a/meilisearch/tests/common/server.rs b/meilisearch/tests/common/server.rs index 66b82eace..40d8e8366 100644 --- a/meilisearch/tests/common/server.rs +++ b/meilisearch/tests/common/server.rs @@ -189,6 +189,14 @@ impl Server { let url = format!("/tasks/{}", update_id); self.service.get(url).await } + + pub async fn get_features(&self) -> (Value, StatusCode) { + self.service.get("/experimental-features").await + } + + pub async fn set_features(&self, value: Value) -> (Value, StatusCode) { + self.service.patch("/experimental-features", value).await + } } pub fn default_settings(dir: impl AsRef) -> Opt { diff --git a/meilisearch/tests/features/mod.rs b/meilisearch/tests/features/mod.rs new file mode 100644 index 000000000..045440f49 --- /dev/null +++ b/meilisearch/tests/features/mod.rs @@ -0,0 +1,109 @@ +use serde_json::json; + +use crate::common::Server; + +/// Feature name to test against. +/// This will have to be changed by a different one when that feature is stabilized. +/// All tests that need to set a feature can make use of this constant. +const FEATURE_NAME: &str = "vectorStore"; + +#[actix_rt::test] +async fn experimental_features() { + let server = Server::new().await; + + let (response, code) = server.get_features().await; + + meili_snap::snapshot!(code, @"200 OK"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "scoreDetails": false, + "vectorStore": false + } + "###); + + let (response, code) = server.set_features(json!({FEATURE_NAME: true})).await; + + meili_snap::snapshot!(code, @"200 OK"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "scoreDetails": false, + "vectorStore": true + } + "###); + + let (response, code) = server.get_features().await; + + meili_snap::snapshot!(code, @"200 OK"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "scoreDetails": false, + "vectorStore": true + } + "###); + + // sending null does not change the value + let (response, code) = server.set_features(json!({FEATURE_NAME: null})).await; + + meili_snap::snapshot!(code, @"200 OK"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "scoreDetails": false, + "vectorStore": true + } + "###); + + // not sending the field does not change the value + let (response, code) = server.set_features(json!({})).await; + + meili_snap::snapshot!(code, @"200 OK"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "scoreDetails": false, + "vectorStore": true + } + "###); +} + +#[actix_rt::test] +async fn errors() { + let server = Server::new().await; + + // Sending a feature not in the list is an error + let (response, code) = server.set_features(json!({"NotAFeature": true})).await; + + meili_snap::snapshot!(code, @"400 Bad Request"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "message": "Unknown field `NotAFeature`: expected one of `scoreDetails`, `vectorStore`", + "code": "bad_request", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#bad_request" + } + "###); + + // The type must be a bool, not a number + let (response, code) = server.set_features(json!({FEATURE_NAME: 42})).await; + + meili_snap::snapshot!(code, @"400 Bad Request"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "message": "Invalid value type at `.vectorStore`: expected a boolean, but found a positive integer: `42`", + "code": "bad_request", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#bad_request" + } + "###); + + // The type must be a bool, not a string + let (response, code) = server.set_features(json!({FEATURE_NAME: "true"})).await; + + meili_snap::snapshot!(code, @"400 Bad Request"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "message": "Invalid value type at `.vectorStore`: expected a boolean, but found a string: `\"true\"`", + "code": "bad_request", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#bad_request" + } + "###); +} diff --git a/meilisearch/tests/integration.rs b/meilisearch/tests/integration.rs index 4383aea57..b6992791a 100644 --- a/meilisearch/tests/integration.rs +++ b/meilisearch/tests/integration.rs @@ -3,6 +3,7 @@ mod common; mod dashboard; mod documents; mod dumps; +mod features; mod index; mod search; mod settings; diff --git a/meilisearch/tests/search/mod.rs b/meilisearch/tests/search/mod.rs index 97556ae2a..e6eae7cb1 100644 --- a/meilisearch/tests/search/mod.rs +++ b/meilisearch/tests/search/mod.rs @@ -752,3 +752,127 @@ async fn faceting_max_values_per_facet() { ) .await; } + +#[actix_rt::test] +async fn experimental_feature_score_details() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + + index.add_documents(json!(documents), None).await; + index.wait_task(0).await; + + index + .search( + json!({ + "q": "train dragon", + "showRankingScoreDetails": true, + }), + |response, code| { + meili_snap::snapshot!(code, @"400 Bad Request"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "message": "Computing score details requires enabling the `score details` experimental feature. See https://github.com/meilisearch/product/discussions/674", + "code": "feature_not_enabled", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#feature_not_enabled" + } + "###); + }, + ) + .await; + + let (response, code) = server.set_features(json!({"scoreDetails": true})).await; + meili_snap::snapshot!(code, @"200 OK"); + meili_snap::snapshot!(response["scoreDetails"], @"true"); + + index + .search( + json!({ + "q": "train dragon", + "showRankingScoreDetails": true, + }), + |response, code| { + meili_snap::snapshot!(code, @"200 OK"); + meili_snap::snapshot!(meili_snap::json_string!(response["hits"]), @r###" + [ + { + "title": "How to Train Your Dragon: The Hidden World", + "id": "166428", + "_rankingScoreDetails": { + "words": { + "order": 0, + "matchingWords": 2, + "maxMatchingWords": 2, + "score": 1.0 + }, + "typo": { + "order": 1, + "typoCount": 0, + "maxTypoCount": 2, + "score": 1.0 + }, + "proximity": { + "order": 2, + "score": 0.875 + }, + "attribute": { + "order": 3, + "attribute_ranking_order_score": 1.0, + "query_word_distance_score": 0.8095238095238095, + "score": 0.9365079365079364 + }, + "exactness": { + "order": 4, + "matchType": "noExactMatch", + "matchingWords": 2, + "maxMatchingWords": 2, + "score": 0.3333333333333333 + } + } + } + ] + "###); + }, + ) + .await; +} + +#[actix_rt::test] +async fn experimental_feature_vector_store() { + let server = Server::new().await; + let index = server.index("test"); + + let documents = DOCUMENTS.clone(); + + index.add_documents(json!(documents), None).await; + index.wait_task(0).await; + + let (response, code) = index + .search_post(json!({ + "vector": [1.0, 2.0, 3.0], + })) + .await; + meili_snap::snapshot!(code, @"400 Bad Request"); + meili_snap::snapshot!(meili_snap::json_string!(response), @r###" + { + "message": "Passing `vector` as a query parameter requires enabling the `vector store` experimental feature. See https://github.com/meilisearch/product/discussions/677", + "code": "feature_not_enabled", + "type": "invalid_request", + "link": "https://docs.meilisearch.com/errors#feature_not_enabled" + } + "###); + + let (response, code) = server.set_features(json!({"vectorStore": true})).await; + meili_snap::snapshot!(code, @"200 OK"); + meili_snap::snapshot!(response["vectorStore"], @"true"); + + let (response, code) = index + .search_post(json!({ + "vector": [1.0, 2.0, 3.0], + })) + .await; + meili_snap::snapshot!(code, @"200 OK"); + meili_snap::snapshot!(meili_snap::json_string!(response["hits"]), @"[]"); +}