1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
12 use error_support::{breadcrumb, handle_error};
13 use once_cell::sync::OnceCell;
14 use parking_lot::Mutex;
15 use remote_settings::{self, RemoteSettingsConfig, RemoteSettingsServer};
17 use serde::de::DeserializeOwned;
20 config::{SuggestGlobalConfig, SuggestProviderConfig},
21 db::{ConnectionType, SuggestDao, SuggestDb},
23 provider::SuggestionProvider,
25 Client, Record, RecordRequest, SuggestAttachment, SuggestRecord, SuggestRecordId,
26 SuggestRecordType, DEFAULT_RECORDS_TYPES, REMOTE_SETTINGS_COLLECTION,
28 Result, SuggestApiResult, Suggestion, SuggestionQuery,
31 /// Builder for [SuggestStore]
33 /// Using a builder is preferred to calling the constructor directly since it's harder to confuse
34 /// the data_path and cache_path strings.
35 pub struct SuggestStoreBuilder(Mutex<SuggestStoreBuilderInner>);
38 struct SuggestStoreBuilderInner {
39 data_path: Option<String>,
40 remote_settings_server: Option<RemoteSettingsServer>,
41 remote_settings_bucket_name: Option<String>,
44 impl Default for SuggestStoreBuilder {
45 fn default() -> Self {
50 impl SuggestStoreBuilder {
51 pub fn new() -> SuggestStoreBuilder {
52 Self(Mutex::new(SuggestStoreBuilderInner::default()))
55 pub fn data_path(self: Arc<Self>, path: String) -> Arc<Self> {
56 self.0.lock().data_path = Some(path);
60 pub fn cache_path(self: Arc<Self>, _path: String) -> Arc<Self> {
61 // We used to use this, but we're not using it anymore, just ignore the call
65 pub fn remote_settings_server(self: Arc<Self>, server: RemoteSettingsServer) -> Arc<Self> {
66 self.0.lock().remote_settings_server = Some(server);
70 pub fn remote_settings_bucket_name(self: Arc<Self>, bucket_name: String) -> Arc<Self> {
71 self.0.lock().remote_settings_bucket_name = Some(bucket_name);
75 #[handle_error(Error)]
76 pub fn build(&self) -> SuggestApiResult<Arc<SuggestStore>> {
77 let inner = self.0.lock();
81 .ok_or_else(|| Error::SuggestStoreBuilder("data_path not specified".to_owned()))?;
83 let remote_settings_config = RemoteSettingsConfig {
84 server: inner.remote_settings_server.clone(),
85 bucket_name: inner.remote_settings_bucket_name.clone(),
87 collection_name: REMOTE_SETTINGS_COLLECTION.into(),
89 let settings_client = remote_settings::Client::new(remote_settings_config)?;
90 Ok(Arc::new(SuggestStore {
91 inner: SuggestStoreInner::new(data_path, settings_client),
96 /// What should be interrupted when [SuggestStore::interrupt] is called?
97 #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
98 pub enum InterruptKind {
99 /// Interrupt read operations like [SuggestStore::query]
101 /// Interrupt write operations. This mostly means [SuggestStore::ingest], but
102 /// [SuggestStore::dismiss_suggestion] may also be interrupted.
104 /// Interrupt both read and write operations,
108 /// The store is the entry point to the Suggest component. It incrementally
109 /// downloads suggestions from the Remote Settings service, stores them in a
110 /// local database, and returns them in response to user queries.
112 /// Your application should create a single store, and manage it as a singleton.
113 /// The store is thread-safe, and supports concurrent queries and ingests. We
114 /// expect that your application will call [`SuggestStore::query()`] to show
115 /// suggestions as the user types into the address bar, and periodically call
116 /// [`SuggestStore::ingest()`] in the background to update the database with
117 /// new suggestions from Remote Settings.
119 /// For responsiveness, we recommend always calling `query()` on a worker
120 /// thread. When the user types new input into the address bar, call
121 /// [`SuggestStore::interrupt()`] on the main thread to cancel the query
122 /// for the old input, and unblock the worker thread for the new query.
124 /// The store keeps track of the state needed to support incremental ingestion,
125 /// but doesn't schedule the ingestion work itself, or decide how many
126 /// suggestions to ingest at once. This is for two reasons:
128 /// 1. The primitives for scheduling background work vary between platforms, and
129 /// aren't available to the lower-level Rust layer. You might use an idle
130 /// timer on Desktop, `WorkManager` on Android, or `BGTaskScheduler` on iOS.
131 /// 2. Ingestion constraints can change, depending on the platform and the needs
132 /// of your application. A mobile device on a metered connection might want
133 /// to request a small subset of the Suggest data and download the rest
134 /// later, while a desktop on a fast link might download the entire dataset
135 /// on the first launch.
136 pub struct SuggestStore {
137 inner: SuggestStoreInner<remote_settings::Client>,
141 /// Creates a Suggest store.
142 #[handle_error(Error)]
145 settings_config: Option<RemoteSettingsConfig>,
146 ) -> SuggestApiResult<Self> {
147 let settings_client = || -> Result<_> {
148 Ok(remote_settings::Client::new(
149 settings_config.unwrap_or_else(|| RemoteSettingsConfig {
153 collection_name: REMOTE_SETTINGS_COLLECTION.into(),
158 inner: SuggestStoreInner::new(path.to_owned(), settings_client),
162 /// Queries the database for suggestions.
163 #[handle_error(Error)]
164 pub fn query(&self, query: SuggestionQuery) -> SuggestApiResult<Vec<Suggestion>> {
165 self.inner.query(query)
168 /// Dismiss a suggestion
170 /// Dismissed suggestions will not be returned again
172 /// In the case of AMP suggestions this should be the raw URL.
173 #[handle_error(Error)]
174 pub fn dismiss_suggestion(&self, suggestion_url: String) -> SuggestApiResult<()> {
175 self.inner.dismiss_suggestion(suggestion_url)
178 /// Clear dismissed suggestions
179 #[handle_error(Error)]
180 pub fn clear_dismissed_suggestions(&self) -> SuggestApiResult<()> {
181 self.inner.clear_dismissed_suggestions()
184 /// Interrupts any ongoing queries.
186 /// This should be called when the user types new input into the address
187 /// bar, to ensure that they see fresh suggestions as they type. This
188 /// method does not interrupt any ongoing ingests.
189 pub fn interrupt(&self, kind: Option<InterruptKind>) {
190 self.inner.interrupt(kind)
193 /// Ingests new suggestions from Remote Settings.
194 #[handle_error(Error)]
195 pub fn ingest(&self, constraints: SuggestIngestionConstraints) -> SuggestApiResult<()> {
196 self.inner.ingest(constraints)
199 /// Removes all content from the database.
200 #[handle_error(Error)]
201 pub fn clear(&self) -> SuggestApiResult<()> {
205 // Returns global Suggest configuration data.
206 #[handle_error(Error)]
207 pub fn fetch_global_config(&self) -> SuggestApiResult<SuggestGlobalConfig> {
208 self.inner.fetch_global_config()
211 // Returns per-provider Suggest configuration data.
212 #[handle_error(Error)]
213 pub fn fetch_provider_config(
215 provider: SuggestionProvider,
216 ) -> SuggestApiResult<Option<SuggestProviderConfig>> {
217 self.inner.fetch_provider_config(provider)
221 /// Constraints limit which suggestions to ingest from Remote Settings.
222 #[derive(Clone, Default, Debug)]
223 pub struct SuggestIngestionConstraints {
224 /// The approximate maximum number of suggestions to ingest. Set to [`None`]
227 /// Because of how suggestions are partitioned in Remote Settings, this is a
228 /// soft limit, and the store might ingest more than requested.
229 pub max_suggestions: Option<u64>,
230 pub providers: Option<Vec<SuggestionProvider>>,
231 /// Only run ingestion if the table `suggestions` is empty
232 pub empty_only: bool,
235 /// The implementation of the store. This is generic over the Remote Settings
236 /// client, and is split out from the concrete [`SuggestStore`] for testing
237 /// with a mock client.
238 pub(crate) struct SuggestStoreInner<S> {
239 /// Path to the persistent SQL database.
241 /// This stores things that should persist when the user clears their cache.
242 /// It's not currently used because not all consumers pass this in yet.
245 dbs: OnceCell<SuggestStoreDbs>,
249 impl<S> SuggestStoreInner<S> {
250 pub fn new(data_path: impl Into<PathBuf>, settings_client: S) -> Self {
252 data_path: data_path.into(),
253 dbs: OnceCell::new(),
258 /// Returns this store's database connections, initializing them if
259 /// they're not already open.
260 fn dbs(&self) -> Result<&SuggestStoreDbs> {
262 .get_or_try_init(|| SuggestStoreDbs::open(&self.data_path))
265 fn query(&self, query: SuggestionQuery) -> Result<Vec<Suggestion>> {
266 if query.keyword.is_empty() || query.providers.is_empty() {
267 return Ok(Vec::new());
269 self.dbs()?.reader.read(|dao| dao.fetch_suggestions(&query))
272 fn dismiss_suggestion(&self, suggestion_url: String) -> Result<()> {
275 .write(|dao| dao.insert_dismissal(&suggestion_url))
278 fn clear_dismissed_suggestions(&self) -> Result<()> {
279 self.dbs()?.writer.write(|dao| dao.clear_dismissals())?;
283 fn interrupt(&self, kind: Option<InterruptKind>) {
284 if let Some(dbs) = self.dbs.get() {
285 // Only interrupt if the databases are already open.
286 match kind.unwrap_or(InterruptKind::Read) {
287 InterruptKind::Read => {
288 dbs.reader.interrupt_handle.interrupt();
290 InterruptKind::Write => {
291 dbs.writer.interrupt_handle.interrupt();
293 InterruptKind::ReadWrite => {
294 dbs.reader.interrupt_handle.interrupt();
295 dbs.writer.interrupt_handle.interrupt();
301 fn clear(&self) -> Result<()> {
302 self.dbs()?.writer.write(|dao| dao.clear())
305 pub fn fetch_global_config(&self) -> Result<SuggestGlobalConfig> {
306 self.dbs()?.reader.read(|dao| dao.get_global_config())
309 pub fn fetch_provider_config(
311 provider: SuggestionProvider,
312 ) -> Result<Option<SuggestProviderConfig>> {
315 .read(|dao| dao.get_provider_config(provider))
319 impl<S> SuggestStoreInner<S>
323 pub fn ingest(&self, constraints: SuggestIngestionConstraints) -> Result<()> {
324 breadcrumb!("Ingestion starting");
325 let writer = &self.dbs()?.writer;
326 if constraints.empty_only && !writer.read(|dao| dao.suggestions_table_empty())? {
330 // use std::collections::BTreeSet;
331 let ingest_record_types = if let Some(rt) = &constraints.providers {
333 .flat_map(|x| x.records_for_provider())
334 .collect::<BTreeSet<_>>()
338 DEFAULT_RECORDS_TYPES.to_vec()
341 // Handle ingestion inside single write scope
342 let mut write_scope = writer.write_scope()?;
343 for ingest_record_type in ingest_record_types {
344 breadcrumb!("Ingesting {ingest_record_type}");
346 .write(|dao| self.ingest_records_by_type(ingest_record_type, dao, &constraints))?;
347 write_scope.err_if_interrupted()?;
349 breadcrumb!("Ingestion complete");
354 fn ingest_records_by_type(
356 ingest_record_type: SuggestRecordType,
357 dao: &mut SuggestDao,
358 constraints: &SuggestIngestionConstraints,
360 let request = RecordRequest {
361 record_type: Some(ingest_record_type.to_string()),
363 .get_meta::<u64>(ingest_record_type.last_ingest_meta_key().as_str())?,
364 limit: constraints.max_suggestions,
367 let records = self.settings_client.get_records(request)?;
368 self.ingest_records(&ingest_record_type.last_ingest_meta_key(), dao, &records)?;
374 last_ingest_key: &str,
375 dao: &mut SuggestDao,
378 for record in records {
379 let record_id = SuggestRecordId::from(&record.id);
381 // If the entire record was deleted, drop all its suggestions
382 // and advance the last ingest time.
383 dao.handle_deleted_record(last_ingest_key, record)?;
387 serde_json::from_value(serde_json::Value::Object(record.fields.clone()))
389 // We don't recognize this record's type, so we don't know how
390 // to ingest its suggestions. Skip processing this record.
395 SuggestRecord::AmpWikipedia => {
396 self.ingest_attachment(
397 // TODO: Currently re-creating the last_ingest_key because using last_ingest_meta
398 // breaks the tests (particularly the unparsable functionality). So, keeping
399 // a direct reference until we remove the "unparsable" functionality.
400 &SuggestRecordType::AmpWikipedia.last_ingest_meta_key(),
403 |dao, record_id, suggestions| {
404 dao.insert_amp_wikipedia_suggestions(record_id, suggestions)
408 SuggestRecord::AmpMobile => {
409 self.ingest_attachment(
410 &SuggestRecordType::AmpMobile.last_ingest_meta_key(),
413 |dao, record_id, suggestions| {
414 dao.insert_amp_mobile_suggestions(record_id, suggestions)
418 SuggestRecord::Icon => {
419 let (Some(icon_id), Some(attachment)) =
420 (record_id.as_icon_id(), record.attachment.as_ref())
422 // An icon record should have an icon ID and an
423 // attachment. Icons that don't have these are
424 // malformed, so skip to the next record.
425 dao.put_last_ingest_if_newer(
426 &SuggestRecordType::Icon.last_ingest_meta_key(),
427 record.last_modified,
431 let data = record.require_attachment_data()?;
432 dao.put_icon(icon_id, data, &attachment.mimetype)?;
433 dao.handle_ingested_record(
434 &SuggestRecordType::Icon.last_ingest_meta_key(),
438 SuggestRecord::Amo => {
439 self.ingest_attachment(
440 &SuggestRecordType::Amo.last_ingest_meta_key(),
443 |dao, record_id, suggestions| {
444 dao.insert_amo_suggestions(record_id, suggestions)
448 SuggestRecord::Pocket => {
449 self.ingest_attachment(
450 &SuggestRecordType::Pocket.last_ingest_meta_key(),
453 |dao, record_id, suggestions| {
454 dao.insert_pocket_suggestions(record_id, suggestions)
458 SuggestRecord::Yelp => {
459 self.ingest_attachment(
460 &SuggestRecordType::Yelp.last_ingest_meta_key(),
463 |dao, record_id, suggestions| match suggestions.first() {
464 Some(suggestion) => dao.insert_yelp_suggestions(record_id, suggestion),
469 SuggestRecord::Mdn => {
470 self.ingest_attachment(
471 &SuggestRecordType::Mdn.last_ingest_meta_key(),
474 |dao, record_id, suggestions| {
475 dao.insert_mdn_suggestions(record_id, suggestions)
479 SuggestRecord::Weather(data) => {
481 &SuggestRecordType::Weather.last_ingest_meta_key(),
484 |dao, record_id| dao.insert_weather_data(record_id, &data),
487 SuggestRecord::GlobalConfig(config) => {
489 &SuggestRecordType::GlobalConfig.last_ingest_meta_key(),
492 |dao, _| dao.put_global_config(&SuggestGlobalConfig::from(&config)),
502 last_ingest_key: &str,
503 dao: &mut SuggestDao,
505 ingestion_handler: impl FnOnce(&mut SuggestDao<'_>, &SuggestRecordId) -> Result<()>,
507 let record_id = SuggestRecordId::from(&record.id);
509 // Drop any data that we previously ingested from this record.
510 // Suggestions in particular don't have a stable identifier, and
511 // determining which suggestions in the record actually changed is
512 // more complicated than dropping and re-ingesting all of them.
513 dao.drop_suggestions(&record_id)?;
515 // Ingest (or re-ingest) all data in the record.
516 ingestion_handler(dao, &record_id)?;
518 dao.handle_ingested_record(last_ingest_key, record)
521 fn ingest_attachment<T>(
523 last_ingest_key: &str,
524 dao: &mut SuggestDao,
526 ingestion_handler: impl FnOnce(&mut SuggestDao<'_>, &SuggestRecordId, &[T]) -> Result<()>,
531 if record.attachment.is_none() {
532 // This method should be called only when a record is expected to
533 // have an attachment. If it doesn't have one, it's malformed, so
534 // skip to the next record.
535 dao.put_last_ingest_if_newer(last_ingest_key, record.last_modified)?;
539 let attachment_data = record.require_attachment_data()?;
540 match serde_json::from_slice::<SuggestAttachment<T>>(attachment_data) {
541 Ok(attachment) => self.ingest_record(last_ingest_key, dao, record, |dao, record_id| {
542 ingestion_handler(dao, record_id, attachment.suggestions())
544 // If the attachment doesn't match our expected schema, just skip it. It's possible
545 // that we're using an older version. If so, we'll get the data when we re-ingest
546 // after updating the schema.
552 #[cfg(feature = "benchmark_api")]
553 impl<S> SuggestStoreInner<S>
557 pub fn into_settings_client(self) -> S {
561 pub fn ensure_db_initialized(&self) {
565 pub fn force_reingest(&self, ingest_record_type: SuggestRecordType) {
566 // To force a re-ingestion, we're going to ingest all records then forget the last
568 self.benchmark_ingest_records_by_type(ingest_record_type);
569 let writer = &self.dbs().unwrap().writer;
571 .write(|dao| dao.clear_meta(ingest_record_type.last_ingest_meta_key().as_str()))
575 pub fn benchmark_ingest_records_by_type(&self, ingest_record_type: SuggestRecordType) {
576 let writer = &self.dbs().unwrap().writer;
579 dao.clear_meta(ingest_record_type.last_ingest_meta_key().as_str())?;
580 self.ingest_records_by_type(
583 &SuggestIngestionConstraints::default(),
589 pub fn table_row_counts(&self) -> Vec<(String, u32)> {
590 use sql_support::ConnExt;
592 // Note: since this is just used for debugging, use unwrap to simplify the error handling.
593 let reader = &self.dbs().unwrap().reader;
594 let conn = reader.conn.lock();
595 let table_names: Vec<String> = conn
596 .query_rows_and_then(
597 "SELECT name FROM sqlite_master where type = 'table'",
602 let mut table_names_with_counts: Vec<(String, u32)> = table_names
605 let count: u32 = conn
606 .query_one(&format!("SELECT COUNT(*) FROM {name}"))
611 table_names_with_counts.sort_by(|a, b| (b.1.cmp(&a.1)));
612 table_names_with_counts
615 pub fn db_size(&self) -> usize {
616 use sql_support::ConnExt;
618 let reader = &self.dbs().unwrap().reader;
619 let conn = reader.conn.lock();
620 conn.query_one("SELECT page_size * page_count FROM pragma_page_count(), pragma_page_size()")
625 /// Holds a store's open connections to the Suggest database.
626 struct SuggestStoreDbs {
627 /// A read-write connection used to update the database with new data.
629 /// A read-only connection used to query the database.
633 impl SuggestStoreDbs {
634 fn open(path: &Path) -> Result<Self> {
635 // Order is important here: the writer must be opened first, so that it
636 // can set up the database and run any migrations.
637 let writer = SuggestDb::open(path, ConnectionType::ReadWrite)?;
638 let reader = SuggestDb::open(path, ConnectionType::ReadOnly)?;
639 Ok(Self { writer, reader })
647 use std::sync::atomic::{AtomicUsize, Ordering};
649 use parking_lot::Once;
650 use serde_json::json;
651 use sql_support::ConnExt;
653 use crate::{testing::*, SuggestionProvider};
655 /// In-memory Suggest store for testing
657 pub inner: SuggestStoreInner<MockRemoteSettingsClient>,
661 fn new(client: MockRemoteSettingsClient) -> Self {
662 static COUNTER: AtomicUsize = AtomicUsize::new(0);
663 let db_path = format!(
664 "file:test_store_data_{}?mode=memory&cache=shared",
665 COUNTER.fetch_add(1, Ordering::Relaxed),
668 inner: SuggestStoreInner::new(db_path, client),
672 fn replace_client(&mut self, client: MockRemoteSettingsClient) {
673 self.inner.settings_client = client;
676 fn last_modified_timestamp(&self) -> u64 {
677 self.inner.settings_client.last_modified_timestamp
680 fn read<T>(&self, op: impl FnOnce(&SuggestDao) -> Result<T>) -> Result<T> {
681 self.inner.dbs().unwrap().reader.read(op)
684 fn write<T>(&self, op: impl FnOnce(&mut SuggestDao) -> Result<T>) -> Result<T> {
685 self.inner.dbs().unwrap().writer.write(op)
688 fn count_rows(&self, table_name: &str) -> u64 {
689 let sql = format!("SELECT count(*) FROM {table_name}");
690 self.read(|dao| Ok(dao.conn.query_one(&sql)?))
691 .unwrap_or_else(|e| panic!("SQL error in count: {e}"))
694 fn ingest(&self, constraints: SuggestIngestionConstraints) {
695 self.inner.ingest(constraints).unwrap();
698 fn fetch_suggestions(&self, query: SuggestionQuery) -> Vec<Suggestion> {
703 .read(|dao| Ok(dao.fetch_suggestions(&query).unwrap()))
707 pub fn fetch_global_config(&self) -> SuggestGlobalConfig {
709 .fetch_global_config()
710 .expect("Error fetching global config")
713 pub fn fetch_provider_config(
715 provider: SuggestionProvider,
716 ) -> Option<SuggestProviderConfig> {
718 .fetch_provider_config(provider)
719 .expect("Error fetching provider config")
724 static ONCE: Once = Once::new();
730 /// Tests that `SuggestStore` is usable with UniFFI, which requires exposed
731 /// interfaces to be `Send` and `Sync`.
733 fn is_thread_safe() {
736 fn is_send_sync<T: Send + Sync>() {}
737 is_send_sync::<SuggestStore>();
740 /// Tests ingesting suggestions into an empty database.
742 fn ingest_suggestions() -> anyhow::Result<()> {
745 let store = TestStore::new(
746 MockRemoteSettingsClient::default()
747 .with_record("data", "1234", json![los_pollos_amp()])
748 .with_icon(los_pollos_icon()),
750 store.ingest(SuggestIngestionConstraints::default());
752 store.fetch_suggestions(SuggestionQuery::amp("lo")),
753 vec![los_pollos_suggestion("los")],
758 /// Tests ingesting suggestions into an empty database.
760 fn ingest_empty_only() -> anyhow::Result<()> {
763 let mut store = TestStore::new(MockRemoteSettingsClient::default().with_record(
766 json![los_pollos_amp()],
768 // suggestions_table_empty returns true before the ingestion is complete
769 assert!(store.read(|dao| dao.suggestions_table_empty())?);
770 // This ingestion should run, since the DB is empty
771 store.ingest(SuggestIngestionConstraints {
773 ..SuggestIngestionConstraints::default()
775 // suggestions_table_empty returns false after the ingestion is complete
776 assert!(!store.read(|dao| dao.suggestions_table_empty())?);
778 // This ingestion should not run since the DB is no longer empty
779 store.replace_client(MockRemoteSettingsClient::default().with_record(
782 json!([los_pollos_amp(), good_place_eats_amp()]),
784 store.ingest(SuggestIngestionConstraints {
786 ..SuggestIngestionConstraints::default()
788 // "la" should not match the good place eats suggestion, since that should not have been
790 assert_eq!(store.fetch_suggestions(SuggestionQuery::amp("la")), vec![]);
795 /// Tests ingesting suggestions with icons.
797 fn ingest_amp_icons() -> anyhow::Result<()> {
800 let store = TestStore::new(
801 MockRemoteSettingsClient::default()
805 json!([los_pollos_amp(), good_place_eats_amp()]),
807 .with_icon(los_pollos_icon())
808 .with_icon(good_place_eats_icon()),
810 // This ingestion should run, since the DB is empty
811 store.ingest(SuggestIngestionConstraints::default());
814 store.fetch_suggestions(SuggestionQuery::amp("lo")),
815 vec![los_pollos_suggestion("los")]
818 store.fetch_suggestions(SuggestionQuery::amp("la")),
819 vec![good_place_eats_suggestion("lasagna")]
826 fn ingest_full_keywords() -> anyhow::Result<()> {
829 let store = TestStore::new(MockRemoteSettingsClient::default()
830 .with_record("data", "1234", json!([
831 // AMP attachment with full keyword data
832 los_pollos_amp().merge(json!({
833 "keywords": ["lo", "los", "los p", "los pollos", "los pollos h", "los pollos hermanos"],
835 // Full keyword for the first 4 keywords
837 // Full keyword for the next 2 keywords
838 ("los pollos hermanos (restaurant)", 2),
841 // AMP attachment without full keyword data
842 good_place_eats_amp(),
843 // Wikipedia attachment with full keyword data. We should ignore the full
844 // keyword data for Wikipedia suggestions
846 // california_wiki().merge(json!({
847 // "keywords": ["cal", "cali", "california"],
848 // "full_keywords": [("california institute of technology", 3)],
851 .with_record("amp-mobile-suggestions", "2468", json!([
852 // Amp mobile attachment with full keyword data
853 a1a_amp_mobile().merge(json!({
854 "keywords": ["a1a", "ca", "car", "car wash"],
861 .with_icon(los_pollos_icon())
862 .with_icon(good_place_eats_icon())
863 .with_icon(california_icon())
865 store.ingest(SuggestIngestionConstraints::default());
868 store.fetch_suggestions(SuggestionQuery::amp("lo")),
869 // This keyword comes from the provided full_keywords list
870 vec![los_pollos_suggestion("los pollos")],
874 store.fetch_suggestions(SuggestionQuery::amp("la")),
875 // Good place eats did not have full keywords, so this one is calculated with the
877 vec![good_place_eats_suggestion("lasagna")],
881 store.fetch_suggestions(SuggestionQuery::wikipedia("cal")),
882 // Even though this had a full_keywords field, we should ignore it since it's a
883 // wikipedia suggestion and use the keywords.rs code instead
884 vec![california_suggestion("california")],
888 store.fetch_suggestions(SuggestionQuery::amp_mobile("a1a")),
889 // This keyword comes from the provided full_keywords list.
890 vec![a1a_suggestion("A1A Car Wash")],
896 /// Tests ingesting a data attachment containing a single suggestion,
897 /// instead of an array of suggestions.
899 fn ingest_one_suggestion_in_data_attachment() -> anyhow::Result<()> {
902 let store = TestStore::new(
903 MockRemoteSettingsClient::default()
904 // This record contains just one JSON object, rather than an array of them
905 .with_record("data", "1234", los_pollos_amp())
906 .with_icon(los_pollos_icon()),
908 store.ingest(SuggestIngestionConstraints::default());
910 store.fetch_suggestions(SuggestionQuery::amp("lo")),
911 vec![los_pollos_suggestion("los")],
917 /// Tests re-ingesting suggestions from an updated attachment.
919 fn reingest_amp_suggestions() -> anyhow::Result<()> {
922 let mut store = TestStore::new(MockRemoteSettingsClient::default().with_record(
925 json!([los_pollos_amp(), good_place_eats_amp()]),
928 store.ingest(SuggestIngestionConstraints::default());
929 // Update the snapshot with new suggestions: Los pollos has a new name and Good place eats
930 // is now serving Penne
931 store.replace_client(MockRemoteSettingsClient::default().with_record(
935 los_pollos_amp().merge(json!({
936 "title": "Los Pollos Hermanos - Now Serving at 14 Locations!",
938 good_place_eats_amp().merge(json!({
939 "keywords": ["pe", "pen", "penne", "penne for your thoughts"],
940 "title": "Penne for Your Thoughts",
941 "url": "https://penne.biz",
945 store.ingest(SuggestIngestionConstraints::default());
948 store.fetch_suggestions(SuggestionQuery::amp("lo")).as_slice(),
949 [ Suggestion::Amp { title, .. } ] if title == "Los Pollos Hermanos - Now Serving at 14 Locations!",
952 assert_eq!(store.fetch_suggestions(SuggestionQuery::amp("la")), vec![]);
954 store.fetch_suggestions(SuggestionQuery::amp("pe")).as_slice(),
955 [ Suggestion::Amp { title, url, .. } ] if title == "Penne for Your Thoughts" && url == "https://penne.biz"
961 /// Tests re-ingesting icons from an updated attachment.
963 fn reingest_icons() -> anyhow::Result<()> {
966 let mut store = TestStore::new(
967 MockRemoteSettingsClient::default()
971 json!([los_pollos_amp(), good_place_eats_amp()]),
973 .with_icon(los_pollos_icon())
974 .with_icon(good_place_eats_icon()),
976 // This ingestion should run, since the DB is empty
977 store.ingest(SuggestIngestionConstraints::default());
979 // Reingest with updated icon data
980 // - Los pollos gets new data and a new id
981 // - Good place eats gets new data only
982 store.replace_client(
983 MockRemoteSettingsClient::default()
988 los_pollos_amp().merge(json!({"icon": "1000"})),
989 good_place_eats_amp()
992 .with_icon(MockIcon {
994 data: "new-los-pollos-icon",
997 .with_icon(MockIcon {
998 data: "new-good-place-eats-icon",
999 ..good_place_eats_icon()
1002 store.ingest(SuggestIngestionConstraints::default());
1005 store.fetch_suggestions(SuggestionQuery::amp("lo")).as_slice(),
1006 [ Suggestion::Amp { icon, .. } ] if *icon == Some("new-los-pollos-icon".as_bytes().to_vec())
1010 store.fetch_suggestions(SuggestionQuery::amp("la")).as_slice(),
1011 [ Suggestion::Amp { icon, .. } ] if *icon == Some("new-good-place-eats-icon".as_bytes().to_vec())
1017 /// Tests re-ingesting AMO suggestions from an updated attachment.
1019 fn reingest_amo_suggestions() -> anyhow::Result<()> {
1022 let mut store = TestStore::new(
1023 MockRemoteSettingsClient::default()
1024 .with_record("amo-suggestions", "data-1", json!([relay_amo()]))
1028 json!([dark_mode_amo(), foxy_guestures_amo()]),
1032 store.ingest(SuggestIngestionConstraints::default());
1035 store.fetch_suggestions(SuggestionQuery::amo("masking e")),
1036 vec![relay_suggestion()],
1039 store.fetch_suggestions(SuggestionQuery::amo("night")),
1040 vec![dark_mode_suggestion()],
1043 store.fetch_suggestions(SuggestionQuery::amo("grammar")),
1044 vec![foxy_guestures_suggestion()],
1047 // Update the snapshot with new suggestions: update the second, drop the
1048 // third, and add the fourth.
1049 store.replace_client(
1050 MockRemoteSettingsClient::default()
1051 .with_record("amo-suggestions", "data-1", json!([relay_amo()]))
1056 dark_mode_amo().merge(json!({"title": "Updated second suggestion"})),
1057 new_tab_override_amo(),
1061 store.ingest(SuggestIngestionConstraints::default());
1064 store.fetch_suggestions(SuggestionQuery::amo("masking e")),
1065 vec![relay_suggestion()],
1068 store.fetch_suggestions(SuggestionQuery::amo("night")).as_slice(),
1069 [Suggestion::Amo { title, .. } ] if title == "Updated second suggestion"
1072 store.fetch_suggestions(SuggestionQuery::amo("grammar")),
1076 store.fetch_suggestions(SuggestionQuery::amo("image search")),
1077 vec![new_tab_override_suggestion()],
1083 /// Tests ingesting tombstones for previously-ingested suggestions and
1086 fn ingest_tombstones() -> anyhow::Result<()> {
1089 let mut store = TestStore::new(
1090 MockRemoteSettingsClient::default()
1091 .with_record("data", "data-1", json!([los_pollos_amp()]))
1092 .with_record("data", "data-2", json!([good_place_eats_amp()]))
1093 .with_icon(los_pollos_icon())
1094 .with_icon(good_place_eats_icon()),
1096 store.ingest(SuggestIngestionConstraints::default());
1098 store.fetch_suggestions(SuggestionQuery::amp("lo")),
1099 vec![los_pollos_suggestion("los")],
1102 store.fetch_suggestions(SuggestionQuery::amp("la")),
1103 vec![good_place_eats_suggestion("lasagna")],
1106 // - Los pollos replaced with a tombstone
1107 // - Good place eat's icon replaced with a tombstone
1108 store.replace_client(
1109 MockRemoteSettingsClient::default()
1110 .with_tombstone("data", "data-1")
1111 .with_record("data", "data-2", json!([good_place_eats_amp()]))
1112 .with_icon_tombstone(los_pollos_icon())
1113 .with_icon_tombstone(good_place_eats_icon()),
1115 store.ingest(SuggestIngestionConstraints::default());
1117 assert_eq!(store.fetch_suggestions(SuggestionQuery::amp("lo")), vec![]);
1119 store.fetch_suggestions(SuggestionQuery::amp("la")).as_slice(),
1121 Suggestion::Amp { icon, icon_mimetype, .. }
1122 ] if icon.is_none() && icon_mimetype.is_none(),
1127 /// Tests clearing the store.
1129 fn clear() -> anyhow::Result<()> {
1132 let store = TestStore::new(
1133 MockRemoteSettingsClient::default()
1134 .with_record("data", "data-1", json!([los_pollos_amp()]))
1135 .with_record("data", "data-2", json!([good_place_eats_amp()]))
1136 .with_icon(los_pollos_icon())
1137 .with_icon(good_place_eats_icon()),
1139 store.ingest(SuggestIngestionConstraints::default());
1140 assert!(store.count_rows("suggestions") > 0);
1141 assert!(store.count_rows("keywords") > 0);
1142 assert!(store.count_rows("icons") > 0);
1144 store.inner.clear()?;
1145 assert!(store.count_rows("suggestions") == 0);
1146 assert!(store.count_rows("keywords") == 0);
1147 assert!(store.count_rows("icons") == 0);
1152 /// Tests querying suggestions.
1154 fn query() -> anyhow::Result<()> {
1157 let store = TestStore::new(
1158 MockRemoteSettingsClient::default()
1163 good_place_eats_amp(),
1172 json!([relay_amo(), multimatch_amo(),]),
1175 "pocket-suggestions",
1177 json!([burnout_pocket(), multimatch_pocket(),]),
1179 .with_record("yelp-suggestions", "data-4", json!([ramen_yelp(),]))
1180 .with_record("yeld-suggestions", "data-4", json!([ramen_yelp(),]))
1181 .with_record("mdn-suggestions", "data-5", json!([array_mdn(),]))
1182 .with_icon(good_place_eats_icon())
1183 .with_icon(california_icon())
1184 .with_icon(caltech_icon())
1185 .with_icon(yelp_favicon())
1186 .with_icon(multimatch_wiki_icon()),
1189 store.ingest(SuggestIngestionConstraints::default());
1192 store.fetch_suggestions(SuggestionQuery::all_providers("")),
1196 store.fetch_suggestions(SuggestionQuery::all_providers("la")),
1197 vec![good_place_eats_suggestion("lasagna"),]
1200 store.fetch_suggestions(SuggestionQuery::all_providers("multimatch")),
1202 multimatch_pocket_suggestion(true),
1203 multimatch_amo_suggestion(),
1204 multimatch_wiki_suggestion(),
1208 store.fetch_suggestions(SuggestionQuery::all_providers("MultiMatch")),
1210 multimatch_pocket_suggestion(true),
1211 multimatch_amo_suggestion(),
1212 multimatch_wiki_suggestion(),
1216 store.fetch_suggestions(SuggestionQuery::all_providers("multimatch").limit(2)),
1218 multimatch_pocket_suggestion(true),
1219 multimatch_amo_suggestion(),
1223 store.fetch_suggestions(SuggestionQuery::amp("la")),
1224 vec![good_place_eats_suggestion("lasagna")],
1227 store.fetch_suggestions(SuggestionQuery::all_providers_except(
1229 SuggestionProvider::Amp
1234 store.fetch_suggestions(SuggestionQuery::with_providers("la", vec![])),
1238 store.fetch_suggestions(SuggestionQuery::with_providers(
1241 SuggestionProvider::Amp,
1242 SuggestionProvider::Amo,
1243 SuggestionProvider::Pocket,
1249 store.fetch_suggestions(SuggestionQuery::wikipedia("cal")),
1251 california_suggestion("california"),
1252 caltech_suggestion("california"),
1256 store.fetch_suggestions(SuggestionQuery::wikipedia("cal").limit(1)),
1257 vec![california_suggestion("california"),],
1260 store.fetch_suggestions(SuggestionQuery::with_providers("cal", vec![])),
1264 store.fetch_suggestions(SuggestionQuery::amo("spam")),
1265 vec![relay_suggestion()],
1268 store.fetch_suggestions(SuggestionQuery::amo("masking")),
1269 vec![relay_suggestion()],
1272 store.fetch_suggestions(SuggestionQuery::amo("masking e")),
1273 vec![relay_suggestion()],
1276 store.fetch_suggestions(SuggestionQuery::amo("masking s")),
1280 store.fetch_suggestions(SuggestionQuery::with_providers(
1282 vec![SuggestionProvider::Amp, SuggestionProvider::Wikipedia]
1287 store.fetch_suggestions(SuggestionQuery::pocket("soft")),
1288 vec![burnout_suggestion(false),],
1291 store.fetch_suggestions(SuggestionQuery::pocket("soft l")),
1292 vec![burnout_suggestion(false),],
1295 store.fetch_suggestions(SuggestionQuery::pocket("sof")),
1299 store.fetch_suggestions(SuggestionQuery::pocket("burnout women")),
1300 vec![burnout_suggestion(true),],
1303 store.fetch_suggestions(SuggestionQuery::pocket("burnout person")),
1307 store.fetch_suggestions(SuggestionQuery::yelp("best spicy ramen delivery in tokyo")),
1308 vec![ramen_suggestion(
1309 "best spicy ramen delivery in tokyo",
1310 "https://www.yelp.com/search?find_desc=best+spicy+ramen+delivery&find_loc=tokyo"
1314 store.fetch_suggestions(SuggestionQuery::yelp("BeSt SpIcY rAmEn DeLiVeRy In ToKyO")),
1315 vec![ramen_suggestion(
1316 "BeSt SpIcY rAmEn DeLiVeRy In ToKyO",
1317 "https://www.yelp.com/search?find_desc=BeSt+SpIcY+rAmEn+DeLiVeRy&find_loc=ToKyO"
1321 store.fetch_suggestions(SuggestionQuery::yelp("best ramen delivery in tokyo")),
1322 vec![ramen_suggestion(
1323 "best ramen delivery in tokyo",
1324 "https://www.yelp.com/search?find_desc=best+ramen+delivery&find_loc=tokyo"
1328 store.fetch_suggestions(SuggestionQuery::yelp(
1329 "best invalid_ramen delivery in tokyo"
1334 store.fetch_suggestions(SuggestionQuery::yelp("best in tokyo")),
1338 store.fetch_suggestions(SuggestionQuery::yelp("super best ramen in tokyo")),
1339 vec![ramen_suggestion(
1340 "super best ramen in tokyo",
1341 "https://www.yelp.com/search?find_desc=super+best+ramen&find_loc=tokyo"
1345 store.fetch_suggestions(SuggestionQuery::yelp("invalid_best ramen in tokyo")),
1349 store.fetch_suggestions(SuggestionQuery::yelp("ramen delivery in tokyo")),
1350 vec![ramen_suggestion(
1351 "ramen delivery in tokyo",
1352 "https://www.yelp.com/search?find_desc=ramen+delivery&find_loc=tokyo"
1356 store.fetch_suggestions(SuggestionQuery::yelp("ramen super delivery in tokyo")),
1357 vec![ramen_suggestion(
1358 "ramen super delivery in tokyo",
1359 "https://www.yelp.com/search?find_desc=ramen+super+delivery&find_loc=tokyo"
1363 store.fetch_suggestions(SuggestionQuery::yelp("ramen invalid_delivery in tokyo")),
1367 store.fetch_suggestions(SuggestionQuery::yelp("ramen in tokyo")),
1368 vec![ramen_suggestion(
1370 "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo"
1374 store.fetch_suggestions(SuggestionQuery::yelp("ramen near tokyo")),
1375 vec![ramen_suggestion(
1377 "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo"
1381 store.fetch_suggestions(SuggestionQuery::yelp("ramen invalid_in tokyo")),
1385 store.fetch_suggestions(SuggestionQuery::yelp("ramen in San Francisco")),
1386 vec![ramen_suggestion(
1387 "ramen in San Francisco",
1388 "https://www.yelp.com/search?find_desc=ramen&find_loc=San+Francisco"
1392 store.fetch_suggestions(SuggestionQuery::yelp("ramen in")),
1393 vec![ramen_suggestion(
1395 "https://www.yelp.com/search?find_desc=ramen"
1399 store.fetch_suggestions(SuggestionQuery::yelp("ramen near by")),
1400 vec![ramen_suggestion(
1402 "https://www.yelp.com/search?find_desc=ramen+near+by"
1404 .has_location_sign(false),],
1407 store.fetch_suggestions(SuggestionQuery::yelp("ramen near me")),
1408 vec![ramen_suggestion(
1410 "https://www.yelp.com/search?find_desc=ramen+near+me"
1412 .has_location_sign(false),],
1415 store.fetch_suggestions(SuggestionQuery::yelp("ramen near by tokyo")),
1419 store.fetch_suggestions(SuggestionQuery::yelp("ramen")),
1421 ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
1422 .has_location_sign(false),
1425 // Test an extremely long yelp query
1427 store.fetch_suggestions(SuggestionQuery::yelp(
1428 "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"
1432 "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789",
1433 "https://www.yelp.com/search?find_desc=012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"
1434 ).has_location_sign(false),
1437 // This query is over the limit and no suggestions should be returned
1439 store.fetch_suggestions(SuggestionQuery::yelp(
1440 "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789Z"
1445 store.fetch_suggestions(SuggestionQuery::yelp("best delivery")),
1449 store.fetch_suggestions(SuggestionQuery::yelp("same_modifier same_modifier")),
1453 store.fetch_suggestions(SuggestionQuery::yelp("same_modifier ")),
1457 store.fetch_suggestions(SuggestionQuery::yelp("yelp ramen")),
1459 ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
1460 .has_location_sign(false),
1464 store.fetch_suggestions(SuggestionQuery::yelp("yelp keyword ramen")),
1466 ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
1467 .has_location_sign(false),
1471 store.fetch_suggestions(SuggestionQuery::yelp("ramen in tokyo yelp")),
1472 vec![ramen_suggestion(
1474 "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo"
1478 store.fetch_suggestions(SuggestionQuery::yelp("ramen in tokyo yelp keyword")),
1479 vec![ramen_suggestion(
1481 "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo"
1485 store.fetch_suggestions(SuggestionQuery::yelp("yelp ramen yelp")),
1487 ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
1488 .has_location_sign(false)
1492 store.fetch_suggestions(SuggestionQuery::yelp("best yelp ramen")),
1496 store.fetch_suggestions(SuggestionQuery::yelp("Spicy R")),
1497 vec![ramen_suggestion(
1499 "https://www.yelp.com/search?find_desc=Spicy+Ramen"
1501 .has_location_sign(false)
1502 .subject_exact_match(false)],
1505 store.fetch_suggestions(SuggestionQuery::yelp("BeSt Ramen")),
1506 vec![ramen_suggestion(
1508 "https://www.yelp.com/search?find_desc=BeSt+Ramen"
1510 .has_location_sign(false)],
1513 store.fetch_suggestions(SuggestionQuery::yelp("BeSt Spicy R")),
1514 vec![ramen_suggestion(
1516 "https://www.yelp.com/search?find_desc=BeSt+Spicy+Ramen"
1518 .has_location_sign(false)
1519 .subject_exact_match(false)],
1522 store.fetch_suggestions(SuggestionQuery::yelp("BeSt R")),
1525 assert_eq!(store.fetch_suggestions(SuggestionQuery::yelp("r")), vec![],);
1527 store.fetch_suggestions(SuggestionQuery::yelp("ra")),
1529 ramen_suggestion("rats", "https://www.yelp.com/search?find_desc=rats")
1530 .has_location_sign(false)
1531 .subject_exact_match(false)
1535 store.fetch_suggestions(SuggestionQuery::yelp("ram")),
1537 ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
1538 .has_location_sign(false)
1539 .subject_exact_match(false)
1543 store.fetch_suggestions(SuggestionQuery::yelp("rac")),
1545 ramen_suggestion("raccoon", "https://www.yelp.com/search?find_desc=raccoon")
1546 .has_location_sign(false)
1547 .subject_exact_match(false)
1551 store.fetch_suggestions(SuggestionQuery::yelp("best r")),
1555 store.fetch_suggestions(SuggestionQuery::yelp("best ra")),
1556 vec![ramen_suggestion(
1558 "https://www.yelp.com/search?find_desc=best+rats"
1560 .has_location_sign(false)
1561 .subject_exact_match(false)],
1567 // Tests querying AMP / Wikipedia / Pocket
1569 fn query_with_multiple_providers_and_diff_scores() -> anyhow::Result<()> {
1572 let store = TestStore::new(
1573 // Create a data set where one keyword matches multiple suggestions from each provider
1574 // where the scores are manually set. We will test that the fetched suggestions are in
1575 // the correct order.
1576 MockRemoteSettingsClient::default()
1581 los_pollos_amp().merge(json!({
1582 "keywords": ["amp wiki match"],
1585 good_place_eats_amp().merge(json!({
1586 "keywords": ["amp wiki match"],
1589 california_wiki().merge(json!({
1590 "keywords": ["amp wiki match", "pocket wiki match"],
1595 "pocket-suggestions",
1598 burnout_pocket().merge(json!({
1599 "lowConfidenceKeywords": ["work-life balance", "pocket wiki match"],
1602 multimatch_pocket().merge(json!({
1603 "highConfidenceKeywords": ["pocket wiki match"],
1608 .with_icon(los_pollos_icon())
1609 .with_icon(good_place_eats_icon())
1610 .with_icon(california_icon()),
1613 store.ingest(SuggestIngestionConstraints::default());
1615 store.fetch_suggestions(SuggestionQuery::all_providers("amp wiki match")),
1617 los_pollos_suggestion("amp wiki match").with_score(0.3),
1618 // Wikipedia entries default to a 0.2 score
1619 california_suggestion("amp wiki match"),
1620 good_place_eats_suggestion("amp wiki match").with_score(0.1),
1624 store.fetch_suggestions(SuggestionQuery::all_providers("amp wiki match").limit(2)),
1626 los_pollos_suggestion("amp wiki match").with_score(0.3),
1627 california_suggestion("amp wiki match"),
1631 store.fetch_suggestions(SuggestionQuery::all_providers("pocket wiki match")),
1633 multimatch_pocket_suggestion(true).with_score(0.88),
1634 california_suggestion("pocket wiki match"),
1635 burnout_suggestion(false).with_score(0.05),
1639 store.fetch_suggestions(SuggestionQuery::all_providers("pocket wiki match").limit(1)),
1640 vec![multimatch_pocket_suggestion(true).with_score(0.88),]
1642 // test duplicate providers
1644 store.fetch_suggestions(SuggestionQuery::with_providers(
1645 "work-life balance",
1646 vec![SuggestionProvider::Pocket, SuggestionProvider::Pocket],
1648 vec![burnout_suggestion(false).with_score(0.05),]
1654 // Tests querying multiple suggestions with multiple keywords with same prefix keyword
1656 fn query_with_amp_mobile_provider() -> anyhow::Result<()> {
1659 // Use the exact same data for both the Amp and AmpMobile record
1660 let store = TestStore::new(
1661 MockRemoteSettingsClient::default()
1663 "amp-mobile-suggestions",
1665 json!([good_place_eats_amp()]),
1667 .with_record("data", "data-1", json!([good_place_eats_amp()]))
1668 // This icon is shared by both records which is kind of weird and probably not how
1669 // things would work in practice, but it's okay for the tests.
1670 .with_icon(good_place_eats_icon()),
1672 store.ingest(SuggestIngestionConstraints::default());
1673 // The query results should be exactly the same for both the Amp and AmpMobile data
1675 store.fetch_suggestions(SuggestionQuery::amp_mobile("las")),
1676 vec![good_place_eats_suggestion("lasagna")]
1679 store.fetch_suggestions(SuggestionQuery::amp("las")),
1680 vec![good_place_eats_suggestion("lasagna")]
1685 /// Tests ingesting malformed Remote Settings records that we understand,
1686 /// but that are missing fields, or aren't in the format we expect.
1688 fn ingest_malformed() -> anyhow::Result<()> {
1691 let store = TestStore::new(
1692 MockRemoteSettingsClient::default()
1693 // Amp/Wikipedia record without an attachment.
1694 .with_record_but_no_attachment("data", "data-1")
1695 // Icon record without an attachment.
1696 .with_record_but_no_attachment("icon", "icon-1")
1697 // Icon record with an ID that's not `icon-{id}`, so suggestions in
1698 // the data attachment won't be able to reference it.
1699 .with_record("icon", "bad-icon-id", json!("i-am-an-icon")),
1702 store.ingest(SuggestIngestionConstraints::default());
1706 dao.get_meta::<u64>(SuggestRecordType::Icon.last_ingest_meta_key().as_str())?,
1707 Some(store.last_modified_timestamp())
1711 .query_one::<i64>("SELECT count(*) FROM suggestions")?,
1714 assert_eq!(dao.conn.query_one::<i64>("SELECT count(*) FROM icons")?, 0);
1722 /// Tests that we only ingest providers that we're concerned with.
1724 fn ingest_constraints_provider() -> anyhow::Result<()> {
1727 let store = TestStore::new(
1728 MockRemoteSettingsClient::default()
1729 .with_record("data", "data-1", json!([los_pollos_amp()]))
1730 .with_record("yelp", "yelp-1", json!([ramen_yelp()]))
1731 .with_icon(los_pollos_icon()),
1734 // Write a last ingestion times to test that we overwrite it properly
1736 // Check that existing data is updated properly.
1738 SuggestRecordType::AmpWikipedia
1739 .last_ingest_meta_key()
1746 let constraints = SuggestIngestionConstraints {
1747 max_suggestions: Some(100),
1748 providers: Some(vec![SuggestionProvider::Amp, SuggestionProvider::Pocket]),
1749 ..SuggestIngestionConstraints::default()
1751 store.ingest(constraints);
1753 // This should have been ingested
1755 store.fetch_suggestions(SuggestionQuery::amp("lo")),
1756 vec![los_pollos_suggestion("los")]
1758 // This should not have been ingested, since it wasn't in the providers list
1760 store.fetch_suggestions(SuggestionQuery::yelp("best ramen")),
1765 // This should have its last_modified_timestamp updated, since we ingested an amp
1768 dao.get_meta::<u64>(
1769 SuggestRecordType::AmpWikipedia
1770 .last_ingest_meta_key()
1773 Some(store.last_modified_timestamp())
1775 // This should have its last_modified_timestamp updated, since we ingested an icon
1777 dao.get_meta::<u64>(SuggestRecordType::Icon.last_ingest_meta_key().as_str())?,
1778 Some(store.last_modified_timestamp())
1780 // This should not have its last_modified_timestamp updated, since there were no pocket
1783 dao.get_meta::<u64>(SuggestRecordType::Pocket.last_ingest_meta_key().as_str())?,
1786 // This should not have its last_modified_timestamp updated, since we did not ask to
1787 // ingest yelp items.
1789 dao.get_meta::<u64>(SuggestRecordType::Yelp.last_ingest_meta_key().as_str())?,
1793 dao.get_meta::<u64>(SuggestRecordType::Amo.last_ingest_meta_key().as_str())?,
1797 dao.get_meta::<u64>(SuggestRecordType::Mdn.last_ingest_meta_key().as_str())?,
1801 dao.get_meta::<u64>(SuggestRecordType::AmpMobile.last_ingest_meta_key().as_str())?,
1805 dao.get_meta::<u64>(
1806 SuggestRecordType::GlobalConfig
1807 .last_ingest_meta_key()
1818 /// Tests that records with invalid attachments are ignored
1820 fn skip_over_invalid_records() -> anyhow::Result<()> {
1823 let store = TestStore::new(
1824 MockRemoteSettingsClient::default()
1826 .with_record("data", "data-1", json!([good_place_eats_amp()]))
1827 // This attachment is missing the `title` field and is invalid
1833 "advertiser": "Los Pollos Hermanos",
1834 "iab_category": "8 - Food & Drink",
1835 "keywords": ["lo", "los", "los pollos"],
1836 "url": "https://www.lph-nm.biz",
1838 "impression_url": "https://example.com/impression_url",
1839 "click_url": "https://example.com/click_url",
1843 .with_icon(good_place_eats_icon()),
1846 store.ingest(SuggestIngestionConstraints::default());
1848 // Test that the valid record was read
1850 store.fetch_suggestions(SuggestionQuery::amp("la")),
1851 vec![good_place_eats_suggestion("lasagna")]
1853 // Test that the invalid record was skipped
1854 assert_eq!(store.fetch_suggestions(SuggestionQuery::amp("lo")), vec![]);
1860 fn query_mdn() -> anyhow::Result<()> {
1863 let store = TestStore::new(MockRemoteSettingsClient::default().with_record(
1866 json!([array_mdn()]),
1868 store.ingest(SuggestIngestionConstraints::default());
1871 store.fetch_suggestions(SuggestionQuery::mdn("array")),
1872 vec![array_suggestion(),]
1874 // prefix + partial suffix
1876 store.fetch_suggestions(SuggestionQuery::mdn("array java")),
1877 vec![array_suggestion(),]
1879 // prefix + entire suffix
1881 store.fetch_suggestions(SuggestionQuery::mdn("javascript array")),
1882 vec![array_suggestion(),]
1884 // partial prefix word
1886 store.fetch_suggestions(SuggestionQuery::mdn("wild")),
1891 store.fetch_suggestions(SuggestionQuery::mdn("wildcard")),
1892 vec![array_suggestion()]
1898 fn query_no_yelp_icon_data() -> anyhow::Result<()> {
1901 let store = TestStore::new(
1902 MockRemoteSettingsClient::default().with_record(
1905 json!([ramen_yelp()]),
1906 ), // Note: yelp_favicon() is missing
1908 store.ingest(SuggestIngestionConstraints::default());
1910 store.fetch_suggestions(SuggestionQuery::yelp("ramen")).as_slice(),
1911 [Suggestion::Yelp { icon, icon_mimetype, .. }] if icon.is_none() && icon_mimetype.is_none()
1918 fn weather() -> anyhow::Result<()> {
1921 let store = TestStore::new(MockRemoteSettingsClient::default().with_inline_record(
1925 "min_keyword_length": 3,
1926 "keywords": ["ab", "xyz", "weather"],
1930 store.ingest(SuggestIngestionConstraints::default());
1931 // No match since the query doesn't match any keyword
1933 store.fetch_suggestions(SuggestionQuery::weather("xab")),
1937 store.fetch_suggestions(SuggestionQuery::weather("abx")),
1941 store.fetch_suggestions(SuggestionQuery::weather("xxyz")),
1945 store.fetch_suggestions(SuggestionQuery::weather("xyzx")),
1949 store.fetch_suggestions(SuggestionQuery::weather("weatherx")),
1953 store.fetch_suggestions(SuggestionQuery::weather("xweather")),
1957 store.fetch_suggestions(SuggestionQuery::weather("xwea")),
1961 store.fetch_suggestions(SuggestionQuery::weather("x weather")),
1965 store.fetch_suggestions(SuggestionQuery::weather(" weather x")),
1968 // No match since the query is too short
1970 store.fetch_suggestions(SuggestionQuery::weather("xy")),
1974 store.fetch_suggestions(SuggestionQuery::weather("ab")),
1978 store.fetch_suggestions(SuggestionQuery::weather("we")),
1983 store.fetch_suggestions(SuggestionQuery::weather("xyz")),
1984 vec![Suggestion::Weather { score: 0.24 },]
1987 store.fetch_suggestions(SuggestionQuery::weather("wea")),
1988 vec![Suggestion::Weather { score: 0.24 },]
1991 store.fetch_suggestions(SuggestionQuery::weather("weat")),
1992 vec![Suggestion::Weather { score: 0.24 },]
1995 store.fetch_suggestions(SuggestionQuery::weather("weath")),
1996 vec![Suggestion::Weather { score: 0.24 },]
1999 store.fetch_suggestions(SuggestionQuery::weather("weathe")),
2000 vec![Suggestion::Weather { score: 0.24 },]
2003 store.fetch_suggestions(SuggestionQuery::weather("weather")),
2004 vec![Suggestion::Weather { score: 0.24 },]
2007 store.fetch_suggestions(SuggestionQuery::weather(" weather ")),
2008 vec![Suggestion::Weather { score: 0.24 },]
2012 store.fetch_provider_config(SuggestionProvider::Weather),
2013 Some(SuggestProviderConfig::Weather {
2014 min_keyword_length: 3,
2022 fn fetch_global_config() -> anyhow::Result<()> {
2025 let store = TestStore::new(MockRemoteSettingsClient::default().with_inline_record(
2029 "show_less_frequently_cap": 3,
2032 store.ingest(SuggestIngestionConstraints::default());
2034 store.fetch_global_config(),
2035 SuggestGlobalConfig {
2036 show_less_frequently_cap: 3,
2044 fn fetch_global_config_default() -> anyhow::Result<()> {
2047 let store = TestStore::new(MockRemoteSettingsClient::default());
2048 store.ingest(SuggestIngestionConstraints::default());
2050 store.fetch_global_config(),
2051 SuggestGlobalConfig {
2052 show_less_frequently_cap: 0,
2060 fn fetch_provider_config_none() -> anyhow::Result<()> {
2063 let store = TestStore::new(MockRemoteSettingsClient::default());
2064 store.ingest(SuggestIngestionConstraints::default());
2065 assert_eq!(store.fetch_provider_config(SuggestionProvider::Amp), None);
2067 store.fetch_provider_config(SuggestionProvider::Weather),
2075 fn fetch_provider_config_other() -> anyhow::Result<()> {
2078 let store = TestStore::new(MockRemoteSettingsClient::default().with_inline_record(
2082 "min_keyword_length": 3,
2083 "keywords": ["weather"],
2087 store.ingest(SuggestIngestionConstraints::default());
2088 // Getting the config for a different provider should return None.
2089 assert_eq!(store.fetch_provider_config(SuggestionProvider::Amp), None);
2094 fn remove_dismissed_suggestions() -> anyhow::Result<()> {
2097 let store = TestStore::new(
2098 MockRemoteSettingsClient::default()
2103 good_place_eats_amp().merge(json!({"keywords": ["cats"]})),
2104 california_wiki().merge(json!({"keywords": ["cats"]})),
2110 json!([relay_amo().merge(json!({"keywords": ["cats"]})),]),
2113 "pocket-suggestions",
2115 json!([burnout_pocket().merge(json!({
2116 "lowConfidenceKeywords": ["cats"],
2122 json!([array_mdn().merge(json!({"keywords": ["cats"]})),]),
2125 "amp-mobile-suggestions",
2127 json!([a1a_amp_mobile().merge(json!({"keywords": ["cats"]})),]),
2129 .with_icon(good_place_eats_icon())
2130 .with_icon(caltech_icon()),
2132 store.ingest(SuggestIngestionConstraints::default());
2134 // A query for cats should return all suggestions
2135 let query = SuggestionQuery::all_providers("cats");
2136 let results = store.fetch_suggestions(query.clone());
2137 assert_eq!(results.len(), 6);
2139 for result in results {
2142 .dismiss_suggestion(result.raw_url().unwrap().to_string())?;
2145 // After dismissing the suggestions, the next query shouldn't return them
2146 assert_eq!(store.fetch_suggestions(query.clone()).len(), 0);
2148 // Clearing the dismissals should cause them to be returned again
2149 store.inner.clear_dismissed_suggestions()?;
2150 assert_eq!(store.fetch_suggestions(query.clone()).len(), 6);