suggest/
store.rs

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/.
4 */
5
6use std::{
7    collections::{hash_map::Entry, HashMap, HashSet},
8    path::{Path, PathBuf},
9    sync::Arc,
10};
11
12use error_support::{breadcrumb, handle_error, trace};
13use once_cell::sync::OnceCell;
14use parking_lot::Mutex;
15use remote_settings::{self, RemoteSettingsError, RemoteSettingsServer, RemoteSettingsService};
16
17use serde::de::DeserializeOwned;
18
19use crate::{
20    config::{SuggestGlobalConfig, SuggestProviderConfig},
21    db::{ConnectionType, IngestedRecord, Sqlite3Extension, SuggestDao, SuggestDb},
22    error::Error,
23    geoname::{Geoname, GeonameAlternates, GeonameMatch},
24    metrics::{MetricsContext, SuggestIngestionMetrics, SuggestQueryMetrics},
25    provider::{SuggestionProvider, SuggestionProviderConstraints, DEFAULT_INGEST_PROVIDERS},
26    rs::{
27        Client, Collection, DownloadedDynamicRecord, Record, SuggestAttachment, SuggestRecord,
28        SuggestRecordId, SuggestRecordType, SuggestRemoteSettingsClient,
29    },
30    QueryWithMetricsResult, Result, SuggestApiResult, Suggestion, SuggestionQuery,
31};
32
33/// Builder for [SuggestStore]
34///
35/// Using a builder is preferred to calling the constructor directly since it's harder to confuse
36/// the data_path and cache_path strings.
37#[derive(uniffi::Object)]
38pub struct SuggestStoreBuilder(Mutex<SuggestStoreBuilderInner>);
39
40#[derive(Default)]
41struct SuggestStoreBuilderInner {
42    data_path: Option<String>,
43    remote_settings_server: Option<RemoteSettingsServer>,
44    remote_settings_service: Option<Arc<RemoteSettingsService>>,
45    remote_settings_bucket_name: Option<String>,
46    extensions_to_load: Vec<Sqlite3Extension>,
47}
48
49impl Default for SuggestStoreBuilder {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55#[uniffi::export]
56impl SuggestStoreBuilder {
57    #[uniffi::constructor]
58    pub fn new() -> SuggestStoreBuilder {
59        Self(Mutex::new(SuggestStoreBuilderInner::default()))
60    }
61
62    pub fn data_path(self: Arc<Self>, path: String) -> Arc<Self> {
63        self.0.lock().data_path = Some(path);
64        self
65    }
66
67    /// Deprecated: this is no longer used by the suggest component.
68    pub fn cache_path(self: Arc<Self>, _path: String) -> Arc<Self> {
69        // We used to use this, but we're not using it anymore, just ignore the call
70        self
71    }
72
73    pub fn remote_settings_server(self: Arc<Self>, server: RemoteSettingsServer) -> Arc<Self> {
74        self.0.lock().remote_settings_server = Some(server);
75        self
76    }
77
78    pub fn remote_settings_bucket_name(self: Arc<Self>, bucket_name: String) -> Arc<Self> {
79        self.0.lock().remote_settings_bucket_name = Some(bucket_name);
80        self
81    }
82
83    pub fn remote_settings_service(
84        self: Arc<Self>,
85        rs_service: Arc<RemoteSettingsService>,
86    ) -> Arc<Self> {
87        self.0.lock().remote_settings_service = Some(rs_service);
88        self
89    }
90
91    /// Add an sqlite3 extension to load
92    ///
93    /// library_name should be the name of the library without any extension, for example `libmozsqlite3`.
94    /// entrypoint should be the entry point, for example `sqlite3_fts5_init`.  If `null` (the default)
95    /// entry point will be used (see https://sqlite.org/loadext.html for details).
96    pub fn load_extension(
97        self: Arc<Self>,
98        library: String,
99        entry_point: Option<String>,
100    ) -> Arc<Self> {
101        self.0.lock().extensions_to_load.push(Sqlite3Extension {
102            library,
103            entry_point,
104        });
105        self
106    }
107
108    #[handle_error(Error)]
109    pub fn build(&self) -> SuggestApiResult<Arc<SuggestStore>> {
110        let inner = self.0.lock();
111        let extensions_to_load = inner.extensions_to_load.clone();
112        let data_path = inner
113            .data_path
114            .clone()
115            .ok_or_else(|| Error::SuggestStoreBuilder("data_path not specified".to_owned()))?;
116        let rs_service = inner.remote_settings_service.clone().ok_or_else(|| {
117            Error::RemoteSettings(RemoteSettingsError::Other {
118                reason: "remote_settings_service_not_specified".to_string(),
119            })
120        })?;
121        Ok(Arc::new(SuggestStore {
122            inner: SuggestStoreInner::new(
123                data_path,
124                extensions_to_load,
125                SuggestRemoteSettingsClient::new(&rs_service),
126            ),
127        }))
128    }
129}
130
131/// What should be interrupted when [SuggestStore::interrupt] is called?
132#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, uniffi::Enum)]
133pub enum InterruptKind {
134    /// Interrupt read operations like [SuggestStore::query]
135    Read,
136    /// Interrupt write operations.  This mostly means [SuggestStore::ingest], but
137    /// other operations may also be interrupted.
138    Write,
139    /// Interrupt both read and write operations,
140    ReadWrite,
141}
142
143/// The store is the entry point to the Suggest component. It incrementally
144/// downloads suggestions from the Remote Settings service, stores them in a
145/// local database, and returns them in response to user queries.
146///
147/// Your application should create a single store, and manage it as a singleton.
148/// The store is thread-safe, and supports concurrent queries and ingests. We
149/// expect that your application will call [`SuggestStore::query()`] to show
150/// suggestions as the user types into the address bar, and periodically call
151/// [`SuggestStore::ingest()`] in the background to update the database with
152/// new suggestions from Remote Settings.
153///
154/// For responsiveness, we recommend always calling `query()` on a worker
155/// thread. When the user types new input into the address bar, call
156/// [`SuggestStore::interrupt()`] on the main thread to cancel the query
157/// for the old input, and unblock the worker thread for the new query.
158///
159/// The store keeps track of the state needed to support incremental ingestion,
160/// but doesn't schedule the ingestion work itself, or decide how many
161/// suggestions to ingest at once. This is for two reasons:
162///
163/// 1. The primitives for scheduling background work vary between platforms, and
164///    aren't available to the lower-level Rust layer. You might use an idle
165///    timer on Desktop, `WorkManager` on Android, or `BGTaskScheduler` on iOS.
166/// 2. Ingestion constraints can change, depending on the platform and the needs
167///    of your application. A mobile device on a metered connection might want
168///    to request a small subset of the Suggest data and download the rest
169///    later, while a desktop on a fast link might download the entire dataset
170///    on the first launch.
171#[derive(uniffi::Object)]
172pub struct SuggestStore {
173    inner: SuggestStoreInner<SuggestRemoteSettingsClient>,
174}
175
176#[uniffi::export]
177impl SuggestStore {
178    /// Creates a Suggest store.
179    #[uniffi::constructor()]
180    pub fn new(path: &str, remote_settings_service: Arc<RemoteSettingsService>) -> Self {
181        let client = SuggestRemoteSettingsClient::new(&remote_settings_service);
182        Self {
183            inner: SuggestStoreInner::new(path.to_owned(), vec![], client),
184        }
185    }
186
187    /// Queries the database for suggestions.
188    #[handle_error(Error)]
189    pub fn query(&self, query: SuggestionQuery) -> SuggestApiResult<Vec<Suggestion>> {
190        Ok(self.inner.query(query)?.suggestions)
191    }
192
193    /// Queries the database for suggestions.
194    #[handle_error(Error)]
195    pub fn query_with_metrics(
196        &self,
197        query: SuggestionQuery,
198    ) -> SuggestApiResult<QueryWithMetricsResult> {
199        self.inner.query(query)
200    }
201
202    /// Dismiss a suggestion.
203    ///
204    /// Dismissed suggestions cannot be fetched again.
205    #[handle_error(Error)]
206    pub fn dismiss_by_suggestion(&self, suggestion: &Suggestion) -> SuggestApiResult<()> {
207        self.inner.dismiss_by_suggestion(suggestion)
208    }
209
210    /// Dismiss a suggestion by its dismissal key.
211    ///
212    /// Dismissed suggestions cannot be fetched again.
213    ///
214    /// Prefer [SuggestStore::dismiss_by_suggestion] if you have a
215    /// `crate::Suggestion`. This method is intended for cases where a
216    /// suggestion originates outside this component.
217    #[handle_error(Error)]
218    pub fn dismiss_by_key(&self, key: &str) -> SuggestApiResult<()> {
219        self.inner.dismiss_by_key(key)
220    }
221
222    /// Deprecated, use [SuggestStore::dismiss_by_suggestion] or
223    /// [SuggestStore::dismiss_by_key] instead.
224    ///
225    /// Dismiss a suggestion
226    ///
227    /// Dismissed suggestions will not be returned again
228    #[handle_error(Error)]
229    pub fn dismiss_suggestion(&self, suggestion_url: String) -> SuggestApiResult<()> {
230        self.inner.dismiss_suggestion(suggestion_url)
231    }
232
233    /// Clear dismissed suggestions
234    #[handle_error(Error)]
235    pub fn clear_dismissed_suggestions(&self) -> SuggestApiResult<()> {
236        self.inner.clear_dismissed_suggestions()
237    }
238
239    /// Return whether a suggestion has been dismissed.
240    ///
241    /// [SuggestStore::query] will never return dismissed suggestions, so
242    /// normally you never need to know whether a `Suggestion` has been
243    /// dismissed, but this method can be used to do so.
244    #[handle_error(Error)]
245    pub fn is_dismissed_by_suggestion(&self, suggestion: &Suggestion) -> SuggestApiResult<bool> {
246        self.inner.is_dismissed_by_suggestion(suggestion)
247    }
248
249    /// Return whether a suggestion has been dismissed given its dismissal key.
250    ///
251    /// [SuggestStore::query] will never return dismissed suggestions, so
252    /// normally you never need to know whether a suggestion has been dismissed.
253    /// This method is intended for cases where a dismissal key originates
254    /// outside this component.
255    #[handle_error(Error)]
256    pub fn is_dismissed_by_key(&self, key: &str) -> SuggestApiResult<bool> {
257        self.inner.is_dismissed_by_key(key)
258    }
259
260    /// Return whether any suggestions have been dismissed.
261    #[handle_error(Error)]
262    pub fn any_dismissed_suggestions(&self) -> SuggestApiResult<bool> {
263        self.inner.any_dismissed_suggestions()
264    }
265
266    /// Interrupts any ongoing queries.
267    ///
268    /// This should be called when the user types new input into the address
269    /// bar, to ensure that they see fresh suggestions as they type. This
270    /// method does not interrupt any ongoing ingests.
271    #[uniffi::method(default(kind = None))]
272    pub fn interrupt(&self, kind: Option<InterruptKind>) {
273        self.inner.interrupt(kind)
274    }
275
276    /// Ingests new suggestions from Remote Settings.
277    #[handle_error(Error)]
278    pub fn ingest(
279        &self,
280        constraints: SuggestIngestionConstraints,
281    ) -> SuggestApiResult<SuggestIngestionMetrics> {
282        self.inner.ingest(constraints)
283    }
284
285    /// Removes all content from the database.
286    #[handle_error(Error)]
287    pub fn clear(&self) -> SuggestApiResult<()> {
288        self.inner.clear()
289    }
290
291    /// Returns global Suggest configuration data.
292    #[handle_error(Error)]
293    pub fn fetch_global_config(&self) -> SuggestApiResult<SuggestGlobalConfig> {
294        self.inner.fetch_global_config()
295    }
296
297    /// Returns per-provider Suggest configuration data.
298    #[handle_error(Error)]
299    pub fn fetch_provider_config(
300        &self,
301        provider: SuggestionProvider,
302    ) -> SuggestApiResult<Option<SuggestProviderConfig>> {
303        self.inner.fetch_provider_config(provider)
304    }
305
306    /// Fetches geonames stored in the database. A geoname represents a
307    /// geographic place.
308    ///
309    /// See `fetch_geonames` in `geoname.rs` for documentation.
310    #[handle_error(Error)]
311    pub fn fetch_geonames(
312        &self,
313        query: &str,
314        match_name_prefix: bool,
315        filter: Option<Vec<Geoname>>,
316    ) -> SuggestApiResult<Vec<GeonameMatch>> {
317        self.inner.fetch_geonames(query, match_name_prefix, filter)
318    }
319
320    /// Fetches a geoname's names stored in the database.
321    ///
322    /// See `fetch_geoname_alternates` in `geoname.rs` for documentation.
323    #[handle_error(Error)]
324    pub fn fetch_geoname_alternates(
325        &self,
326        geoname: &Geoname,
327    ) -> SuggestApiResult<GeonameAlternates> {
328        self.inner.fetch_geoname_alternates(geoname)
329    }
330}
331
332impl SuggestStore {
333    pub fn force_reingest(&self) {
334        self.inner.force_reingest()
335    }
336}
337
338#[cfg(feature = "benchmark_api")]
339impl SuggestStore {
340    /// Creates a WAL checkpoint. This will cause changes in the write-ahead log
341    /// to be written to the DB. See:
342    /// https://sqlite.org/pragma.html#pragma_wal_checkpoint
343    pub fn checkpoint(&self) {
344        self.inner.checkpoint();
345    }
346}
347
348/// Constraints limit which suggestions to ingest from Remote Settings.
349#[derive(Clone, Default, Debug, uniffi::Record)]
350pub struct SuggestIngestionConstraints {
351    #[uniffi(default = None)]
352    pub providers: Option<Vec<SuggestionProvider>>,
353    #[uniffi(default = None)]
354    pub provider_constraints: Option<SuggestionProviderConstraints>,
355    /// Only run ingestion if the table `suggestions` is empty
356    ///
357    // This is indented to handle periodic updates.  Consumers can schedule an ingest with
358    // `empty_only=true` on startup and a regular ingest with `empty_only=false` to run on a long periodic schedule (maybe
359    // once a day). This allows ingestion to normally be run at a slow, periodic rate.  However, if
360    // there is a schema upgrade that causes the database to be thrown away, then the
361    // `empty_only=true` ingestion that runs on startup will repopulate it.
362    #[uniffi(default = false)]
363    pub empty_only: bool,
364}
365
366impl SuggestIngestionConstraints {
367    pub fn all_providers() -> Self {
368        Self {
369            providers: Some(vec![
370                SuggestionProvider::Amp,
371                SuggestionProvider::Wikipedia,
372                SuggestionProvider::Amo,
373                SuggestionProvider::Yelp,
374                SuggestionProvider::Mdn,
375                SuggestionProvider::Weather,
376                SuggestionProvider::Dynamic,
377            ]),
378            ..Self::default()
379        }
380    }
381
382    fn matches_dynamic_record(&self, record: &DownloadedDynamicRecord) -> bool {
383        match self
384            .provider_constraints
385            .as_ref()
386            .and_then(|c| c.dynamic_suggestion_types.as_ref())
387        {
388            None => false,
389            Some(suggestion_types) => suggestion_types.contains(&record.suggestion_type),
390        }
391    }
392
393    fn amp_matching_uses_fts(&self) -> bool {
394        self.provider_constraints
395            .as_ref()
396            .and_then(|c| c.amp_alternative_matching.as_ref())
397            .map(|constraints| constraints.uses_fts())
398            .unwrap_or(false)
399    }
400}
401
402/// The implementation of the store. This is generic over the Remote Settings
403/// client, and is split out from the concrete [`SuggestStore`] for testing
404/// with a mock client.
405pub(crate) struct SuggestStoreInner<S> {
406    /// Path to the persistent SQL database.
407    ///
408    /// This stores things that should persist when the user clears their cache.
409    /// It's not currently used because not all consumers pass this in yet.
410    #[allow(unused)]
411    data_path: PathBuf,
412    dbs: OnceCell<SuggestStoreDbs>,
413    extensions_to_load: Vec<Sqlite3Extension>,
414    settings_client: S,
415}
416
417impl<S> SuggestStoreInner<S> {
418    pub fn new(
419        data_path: impl Into<PathBuf>,
420        extensions_to_load: Vec<Sqlite3Extension>,
421        settings_client: S,
422    ) -> Self {
423        Self {
424            data_path: data_path.into(),
425            extensions_to_load,
426            dbs: OnceCell::new(),
427            settings_client,
428        }
429    }
430
431    /// Returns this store's database connections, initializing them if
432    /// they're not already open.
433    fn dbs(&self) -> Result<&SuggestStoreDbs> {
434        self.dbs
435            .get_or_try_init(|| SuggestStoreDbs::open(&self.data_path, &self.extensions_to_load))
436    }
437
438    fn query(&self, query: SuggestionQuery) -> Result<QueryWithMetricsResult> {
439        let mut metrics = SuggestQueryMetrics::default();
440        let mut suggestions = vec![];
441
442        let unique_providers = query.providers.iter().collect::<HashSet<_>>();
443        let reader = &self.dbs()?.reader;
444        for provider in unique_providers {
445            let new_suggestions = metrics.measure_query(provider.to_string(), || {
446                reader.read(|dao| match provider {
447                    SuggestionProvider::Amp => dao.fetch_amp_suggestions(&query),
448                    SuggestionProvider::Wikipedia => dao.fetch_wikipedia_suggestions(&query),
449                    SuggestionProvider::Amo => dao.fetch_amo_suggestions(&query),
450                    SuggestionProvider::Yelp => dao.fetch_yelp_suggestions(&query),
451                    SuggestionProvider::Mdn => dao.fetch_mdn_suggestions(&query),
452                    SuggestionProvider::Weather => dao.fetch_weather_suggestions(&query),
453                    SuggestionProvider::Dynamic => dao.fetch_dynamic_suggestions(&query),
454                })
455            })?;
456            suggestions.extend(new_suggestions);
457        }
458
459        // Note: it's important that this is a stable sort to keep the intra-provider order stable.
460        suggestions.sort();
461        if let Some(limit) = query.limit.and_then(|limit| usize::try_from(limit).ok()) {
462            suggestions.truncate(limit);
463        }
464        Ok(QueryWithMetricsResult {
465            suggestions,
466            query_times: metrics.times,
467        })
468    }
469
470    fn dismiss_by_suggestion(&self, suggestion: &Suggestion) -> Result<()> {
471        if let Some(key) = suggestion.dismissal_key() {
472            match suggestion {
473                Suggestion::Dynamic {
474                    suggestion_type, ..
475                } => self
476                    .dbs()?
477                    .writer
478                    .write(|dao| dao.insert_dynamic_dismissal(suggestion_type, key))?,
479                _ => self.dismiss_by_key(key)?,
480            }
481        }
482        Ok(())
483    }
484
485    fn dismiss_by_key(&self, key: &str) -> Result<()> {
486        self.dbs()?.writer.write(|dao| dao.insert_dismissal(key))
487    }
488
489    fn dismiss_suggestion(&self, suggestion_url: String) -> Result<()> {
490        self.dbs()?
491            .writer
492            .write(|dao| dao.insert_dismissal(&suggestion_url))
493    }
494
495    fn clear_dismissed_suggestions(&self) -> Result<()> {
496        self.dbs()?.writer.write(|dao| dao.clear_dismissals())?;
497        Ok(())
498    }
499
500    fn is_dismissed_by_suggestion(&self, suggestion: &Suggestion) -> Result<bool> {
501        if let Some(key) = suggestion.dismissal_key() {
502            match suggestion {
503                Suggestion::Dynamic {
504                    suggestion_type, ..
505                } => self
506                    .dbs()?
507                    .reader
508                    .read(|dao| dao.has_dynamic_dismissal(suggestion_type, key)),
509                _ => self.dbs()?.reader.read(|dao| dao.has_dismissal(key)),
510            }
511        } else {
512            Ok(false)
513        }
514    }
515
516    fn is_dismissed_by_key(&self, key: &str) -> Result<bool> {
517        self.dbs()?.reader.read(|dao| dao.has_dismissal(key))
518    }
519
520    fn any_dismissed_suggestions(&self) -> Result<bool> {
521        self.dbs()?.reader.read(|dao| dao.any_dismissals())
522    }
523
524    fn interrupt(&self, kind: Option<InterruptKind>) {
525        if let Some(dbs) = self.dbs.get() {
526            // Only interrupt if the databases are already open.
527            match kind.unwrap_or(InterruptKind::Read) {
528                InterruptKind::Read => {
529                    dbs.reader.interrupt_handle.interrupt();
530                }
531                InterruptKind::Write => {
532                    dbs.writer.interrupt_handle.interrupt();
533                }
534                InterruptKind::ReadWrite => {
535                    dbs.reader.interrupt_handle.interrupt();
536                    dbs.writer.interrupt_handle.interrupt();
537                }
538            }
539        }
540    }
541
542    fn clear(&self) -> Result<()> {
543        self.dbs()?.writer.write(|dao| dao.clear())
544    }
545
546    pub fn fetch_global_config(&self) -> Result<SuggestGlobalConfig> {
547        self.dbs()?.reader.read(|dao| dao.get_global_config())
548    }
549
550    pub fn fetch_provider_config(
551        &self,
552        provider: SuggestionProvider,
553    ) -> Result<Option<SuggestProviderConfig>> {
554        self.dbs()?
555            .reader
556            .read(|dao| dao.get_provider_config(provider))
557    }
558
559    // Cause the next ingestion to re-ingest all data
560    pub fn force_reingest(&self) {
561        let writer = &self.dbs().unwrap().writer;
562        writer.write(|dao| dao.force_reingest()).unwrap();
563    }
564
565    fn fetch_geonames(
566        &self,
567        query: &str,
568        match_name_prefix: bool,
569        filter: Option<Vec<Geoname>>,
570    ) -> Result<Vec<GeonameMatch>> {
571        self.dbs()?.reader.read(|dao| {
572            dao.fetch_geonames(
573                query,
574                match_name_prefix,
575                filter.as_ref().map(|f| f.iter().collect()),
576            )
577        })
578    }
579
580    pub fn fetch_geoname_alternates(&self, geoname: &Geoname) -> Result<GeonameAlternates> {
581        self.dbs()?
582            .reader
583            .read(|dao| dao.fetch_geoname_alternates(geoname))
584    }
585}
586
587impl<S> SuggestStoreInner<S>
588where
589    S: Client,
590{
591    pub fn ingest(
592        &self,
593        constraints: SuggestIngestionConstraints,
594    ) -> Result<SuggestIngestionMetrics> {
595        breadcrumb!("Ingestion starting");
596        let writer = &self.dbs()?.writer;
597        let mut metrics = SuggestIngestionMetrics::default();
598        if constraints.empty_only && !writer.read(|dao| dao.suggestions_table_empty())? {
599            return Ok(metrics);
600        }
601
602        // Figure out which record types we're ingesting and group them by
603        // collection. A record type may be used by multiple providers, but we
604        // want to ingest each one at most once. We always ingest some types
605        // like global config.
606        let mut record_types_by_collection = HashMap::from([(
607            Collection::Other,
608            HashSet::from([SuggestRecordType::GlobalConfig]),
609        )]);
610        for provider in constraints
611            .providers
612            .as_ref()
613            .unwrap_or(&DEFAULT_INGEST_PROVIDERS.to_vec())
614            .iter()
615        {
616            for (collection, provider_rts) in provider.record_types_by_collection() {
617                record_types_by_collection
618                    .entry(collection)
619                    .or_default()
620                    .extend(provider_rts.into_iter());
621            }
622        }
623
624        // Create a single write scope for all DB operations
625        let mut write_scope = writer.write_scope()?;
626
627        // Read the previously ingested records.  We use this to calculate what's changed
628        let ingested_records = write_scope.read(|dao| dao.get_ingested_records())?;
629
630        // Record whether any changes are ingested.
631        let mut has_changes = false;
632
633        // For each collection, fetch all records
634        for (collection, record_types) in record_types_by_collection {
635            breadcrumb!("Ingesting collection {}", collection.name());
636            let records = self.settings_client.get_records(collection)?;
637
638            // For each record type in that collection, calculate the changes and pass them to
639            // [Self::ingest_records]
640            for record_type in record_types {
641                breadcrumb!("Ingesting record_type: {record_type}");
642                let changes = RecordChanges::new(
643                    records.iter().filter(|r| r.record_type() == record_type),
644                    ingested_records.iter().filter(|i| {
645                        i.record_type == record_type.as_str() && i.collection == collection.name()
646                    }),
647                );
648                has_changes |= changes.has_changes();
649                metrics.measure_ingest(record_type.to_string(), |context| {
650                    write_scope.write(|dao| {
651                        self.process_changes(dao, collection, changes, &constraints, context)
652                    })
653                })?;
654                write_scope.err_if_interrupted()?;
655            }
656        }
657
658        // Truncate the WAL if the DB is updated without interruption.
659        // This avoids the overhead iccurred by handling a large SQLite WAL.
660        // See https://bugzilla.mozilla.org/show_bug.cgi?id=2005613
661        if has_changes {
662            write_scope.err_if_interrupted()?;
663            breadcrumb!("Truncating WAL on changes");
664            write_scope
665                .conn
666                .pragma_update(None, "wal_checkpoint", "TRUNCATE")?;
667        }
668
669        breadcrumb!("Ingestion complete");
670
671        Ok(metrics)
672    }
673
674    fn process_changes(
675        &self,
676        dao: &mut SuggestDao,
677        collection: Collection,
678        changes: RecordChanges<'_>,
679        constraints: &SuggestIngestionConstraints,
680        context: &mut MetricsContext,
681    ) -> Result<()> {
682        for record in &changes.new {
683            trace!("Ingesting record ID: {}", record.id.as_str());
684            self.process_record(dao, record, constraints, context)?;
685        }
686        for record in &changes.updated {
687            // Drop any data that we previously ingested from this record.
688            // Suggestions in particular don't have a stable identifier, and
689            // determining which suggestions in the record actually changed is
690            // more complicated than dropping and re-ingesting all of them.
691            trace!("Reingesting updated record ID: {}", record.id.as_str());
692            dao.delete_record_data(&record.id)?;
693            self.process_record(dao, record, constraints, context)?;
694        }
695        for record in &changes.unchanged {
696            if self.should_reprocess_record(dao, record, constraints)? {
697                trace!("Reingesting unchanged record ID: {}", record.id.as_str());
698                self.process_record(dao, record, constraints, context)?;
699            } else {
700                trace!("Skipping unchanged record ID: {}", record.id.as_str());
701            }
702        }
703        for record in &changes.deleted {
704            trace!("Deleting record ID: {:?}", record.id);
705            dao.delete_record_data(&record.id)?;
706        }
707        dao.update_ingested_records(
708            collection.name(),
709            &changes.new,
710            &changes.updated,
711            &changes.deleted,
712        )?;
713        Ok(())
714    }
715
716    fn process_record(
717        &self,
718        dao: &mut SuggestDao,
719        record: &Record,
720        constraints: &SuggestIngestionConstraints,
721        context: &mut MetricsContext,
722    ) -> Result<()> {
723        match &record.payload {
724            SuggestRecord::Amp => {
725                self.download_attachment(dao, record, context, |dao, record_id, suggestions| {
726                    dao.insert_amp_suggestions(
727                        record_id,
728                        suggestions,
729                        constraints.amp_matching_uses_fts(),
730                    )
731                })?;
732            }
733            SuggestRecord::Wikipedia => {
734                self.download_attachment(dao, record, context, |dao, record_id, suggestions| {
735                    dao.insert_wikipedia_suggestions(record_id, suggestions)
736                })?;
737            }
738            SuggestRecord::Icon => {
739                let (Some(icon_id), Some(attachment)) =
740                    (record.id.as_icon_id(), record.attachment.as_ref())
741                else {
742                    // An icon record should have an icon ID and an
743                    // attachment. Icons that don't have these are
744                    // malformed, so skip to the next record.
745                    return Ok(());
746                };
747                let data = context
748                    .measure_download(|| self.settings_client.download_attachment(record))?;
749                dao.put_icon(icon_id, &data, &attachment.mimetype)?;
750            }
751            SuggestRecord::Amo => {
752                self.download_attachment(dao, record, context, |dao, record_id, suggestions| {
753                    dao.insert_amo_suggestions(record_id, suggestions)
754                })?;
755            }
756            SuggestRecord::Yelp => {
757                self.download_attachment(dao, record, context, |dao, record_id, suggestions| {
758                    match suggestions.first() {
759                        Some(suggestion) => dao.insert_yelp_suggestions(record_id, suggestion),
760                        None => Ok(()),
761                    }
762                })?;
763            }
764            SuggestRecord::Mdn => {
765                self.download_attachment(dao, record, context, |dao, record_id, suggestions| {
766                    dao.insert_mdn_suggestions(record_id, suggestions)
767                })?;
768            }
769            SuggestRecord::Weather => self.process_weather_record(dao, record, context)?,
770            SuggestRecord::GlobalConfig(config) => {
771                dao.put_global_config(&SuggestGlobalConfig::from(config))?
772            }
773            SuggestRecord::Dynamic(r) => {
774                if constraints.matches_dynamic_record(r) {
775                    self.download_attachment(
776                        dao,
777                        record,
778                        context,
779                        |dao, record_id, suggestions| {
780                            dao.insert_dynamic_suggestions(record_id, r, suggestions)
781                        },
782                    )?;
783                }
784            }
785            SuggestRecord::Geonames => self.process_geonames_record(dao, record, context)?,
786            SuggestRecord::GeonamesAlternates => {
787                self.process_geonames_alternates_record(dao, record, context)?
788            }
789        }
790        Ok(())
791    }
792
793    pub(crate) fn download_attachment<T>(
794        &self,
795        dao: &mut SuggestDao,
796        record: &Record,
797        context: &mut MetricsContext,
798        ingestion_handler: impl FnOnce(&mut SuggestDao<'_>, &SuggestRecordId, &[T]) -> Result<()>,
799    ) -> Result<()>
800    where
801        T: DeserializeOwned,
802    {
803        if record.attachment.is_none() {
804            return Ok(());
805        };
806
807        let attachment_data =
808            context.measure_download(|| self.settings_client.download_attachment(record))?;
809        match serde_json::from_slice::<SuggestAttachment<T>>(&attachment_data) {
810            Ok(attachment) => ingestion_handler(dao, &record.id, attachment.suggestions()),
811            // If the attachment doesn't match our expected schema, just skip it and emit an error.
812            // It's possible that we're using an older version. If so, we'll get the data when we
813            // re-ingest after updating the schema.
814            Err(e) => {
815                error_support::report_error!(
816                    "suggest-attachment-deserialize",
817                    "Failed to deserialize attachment for record {}: {}",
818                    record.id,
819                    e
820                );
821                Ok(())
822            }
823        }
824    }
825
826    fn should_reprocess_record(
827        &self,
828        dao: &mut SuggestDao,
829        record: &Record,
830        constraints: &SuggestIngestionConstraints,
831    ) -> Result<bool> {
832        match &record.payload {
833            SuggestRecord::Dynamic(r) => Ok(!dao
834                .are_suggestions_ingested_for_record(&record.id)?
835                && constraints.matches_dynamic_record(r)),
836            SuggestRecord::Amp => {
837                Ok(constraints.amp_matching_uses_fts()
838                    && !dao.is_amp_fts_data_ingested(&record.id)?)
839            }
840            _ => Ok(false),
841        }
842    }
843}
844
845/// Tracks changes in suggest records since the last ingestion
846struct RecordChanges<'a> {
847    new: Vec<&'a Record>,
848    updated: Vec<&'a Record>,
849    deleted: Vec<&'a IngestedRecord>,
850    unchanged: Vec<&'a Record>,
851}
852
853impl<'a> RecordChanges<'a> {
854    fn new(
855        current: impl Iterator<Item = &'a Record>,
856        previously_ingested: impl Iterator<Item = &'a IngestedRecord>,
857    ) -> Self {
858        let mut ingested_map: HashMap<&str, &IngestedRecord> =
859            previously_ingested.map(|i| (i.id.as_str(), i)).collect();
860        // Iterate through current, finding new/updated records.
861        // Remove existing records from ingested_map.
862        let mut new = vec![];
863        let mut updated = vec![];
864        let mut unchanged = vec![];
865        for r in current {
866            match ingested_map.entry(r.id.as_str()) {
867                Entry::Vacant(_) => new.push(r),
868                Entry::Occupied(e) => {
869                    if e.remove().last_modified != r.last_modified {
870                        updated.push(r);
871                    } else {
872                        unchanged.push(r);
873                    }
874                }
875            }
876        }
877        // Anything left in ingested_map is a deleted record
878        let deleted = ingested_map.into_values().collect();
879        Self {
880            new,
881            deleted,
882            updated,
883            unchanged,
884        }
885    }
886
887    fn has_changes(&self) -> bool {
888        !self.new.is_empty() || !self.updated.is_empty() || !self.deleted.is_empty()
889    }
890}
891
892#[cfg(feature = "benchmark_api")]
893impl<S> SuggestStoreInner<S>
894where
895    S: Client,
896{
897    pub fn into_settings_client(self) -> S {
898        self.settings_client
899    }
900
901    pub fn ensure_db_initialized(&self) {
902        self.dbs().unwrap();
903    }
904
905    fn checkpoint(&self) {
906        let conn = self.dbs().unwrap().writer.conn.lock();
907        conn.pragma_update(None, "wal_checkpoint", "TRUNCATE")
908            .expect("Error performing checkpoint");
909    }
910
911    pub fn ingest_records_by_type(
912        &self,
913        collection: Collection,
914        ingest_record_type: SuggestRecordType,
915    ) {
916        let writer = &self.dbs().unwrap().writer;
917        let mut context = MetricsContext::default();
918        let ingested_records = writer.read(|dao| dao.get_ingested_records()).unwrap();
919        let records = self.settings_client.get_records(collection).unwrap();
920
921        let changes = RecordChanges::new(
922            records
923                .iter()
924                .filter(|r| r.record_type() == ingest_record_type),
925            ingested_records
926                .iter()
927                .filter(|i| i.record_type == ingest_record_type.as_str()),
928        );
929        writer
930            .write(|dao| {
931                self.process_changes(
932                    dao,
933                    collection,
934                    changes,
935                    &SuggestIngestionConstraints::default(),
936                    &mut context,
937                )
938            })
939            .unwrap();
940    }
941
942    pub fn table_row_counts(&self) -> Vec<(String, u32)> {
943        use sql_support::ConnExt;
944
945        // Note: since this is just used for debugging, use unwrap to simplify the error handling.
946        let reader = &self.dbs().unwrap().reader;
947        let conn = reader.conn.lock();
948        let table_names: Vec<String> = conn
949            .query_rows_and_then(
950                "SELECT name FROM sqlite_master where type = 'table'",
951                (),
952                |row| row.get(0),
953            )
954            .unwrap();
955        let mut table_names_with_counts: Vec<(String, u32)> = table_names
956            .into_iter()
957            .map(|name| {
958                let count: u32 = conn
959                    .conn_ext_query_one(&format!("SELECT COUNT(*) FROM {name}"))
960                    .unwrap();
961                (name, count)
962            })
963            .collect();
964        table_names_with_counts.sort_by(|a, b| b.1.cmp(&a.1));
965        table_names_with_counts
966    }
967
968    pub fn db_size(&self) -> usize {
969        use sql_support::ConnExt;
970
971        let reader = &self.dbs().unwrap().reader;
972        let conn = reader.conn.lock();
973        conn.conn_ext_query_one(
974            "SELECT page_size * page_count FROM pragma_page_count(), pragma_page_size()",
975        )
976        .unwrap()
977    }
978}
979
980/// Holds a store's open connections to the Suggest database.
981struct SuggestStoreDbs {
982    /// A read-write connection used to update the database with new data.
983    writer: SuggestDb,
984    /// A read-only connection used to query the database.
985    reader: SuggestDb,
986}
987
988impl SuggestStoreDbs {
989    fn open(path: &Path, extensions_to_load: &[Sqlite3Extension]) -> Result<Self> {
990        // Order is important here: the writer must be opened first, so that it
991        // can set up the database and run any migrations.
992        let writer = SuggestDb::open(path, extensions_to_load, ConnectionType::ReadWrite)?;
993        let reader = SuggestDb::open(path, extensions_to_load, ConnectionType::ReadOnly)?;
994        Ok(Self { writer, reader })
995    }
996}
997
998#[cfg(test)]
999pub(crate) mod tests {
1000    use super::*;
1001    use crate::suggestion::YelpSubjectType;
1002
1003    use std::sync::atomic::{AtomicUsize, Ordering};
1004
1005    use crate::{
1006        db::DEFAULT_SUGGESTION_SCORE, provider::AmpMatchingStrategy, suggestion::FtsMatchInfo,
1007        testing::*, SuggestionProvider,
1008    };
1009
1010    // Extra methods for the tests
1011    impl SuggestIngestionConstraints {
1012        fn amp_with_fts() -> Self {
1013            Self {
1014                providers: Some(vec![SuggestionProvider::Amp]),
1015                provider_constraints: Some(SuggestionProviderConstraints {
1016                    amp_alternative_matching: Some(AmpMatchingStrategy::FtsAgainstFullKeywords),
1017                    ..SuggestionProviderConstraints::default()
1018                }),
1019                ..Self::default()
1020            }
1021        }
1022        fn amp_without_fts() -> Self {
1023            Self {
1024                providers: Some(vec![SuggestionProvider::Amp]),
1025                ..Self::default()
1026            }
1027        }
1028    }
1029
1030    /// In-memory Suggest store for testing
1031    pub(crate) struct TestStore {
1032        pub inner: SuggestStoreInner<MockRemoteSettingsClient>,
1033    }
1034
1035    impl TestStore {
1036        pub fn new(client: MockRemoteSettingsClient) -> Self {
1037            static COUNTER: AtomicUsize = AtomicUsize::new(0);
1038            let db_path = format!(
1039                "file:test_store_data_{}?mode=memory&cache=shared",
1040                COUNTER.fetch_add(1, Ordering::Relaxed),
1041            );
1042            Self {
1043                inner: SuggestStoreInner::new(db_path, vec![], client),
1044            }
1045        }
1046
1047        pub fn client_mut(&mut self) -> &mut MockRemoteSettingsClient {
1048            &mut self.inner.settings_client
1049        }
1050
1051        pub fn read<T>(&self, op: impl FnOnce(&SuggestDao) -> Result<T>) -> Result<T> {
1052            self.inner.dbs().unwrap().reader.read(op)
1053        }
1054
1055        pub fn write<T>(&self, op: impl FnMut(&mut SuggestDao) -> Result<T>) -> Result<T> {
1056            self.inner.dbs().unwrap().writer.write(op)
1057        }
1058
1059        pub fn count_rows(&self, table_name: &str) -> u64 {
1060            let sql = format!("SELECT count(*) FROM {table_name}");
1061            self.read(|dao| Ok(dao.conn.conn_ext_query_one(&sql)?))
1062                .unwrap_or_else(|e| panic!("SQL error in count: {e}"))
1063        }
1064
1065        pub fn ingest(&self, constraints: SuggestIngestionConstraints) {
1066            self.inner.ingest(constraints).unwrap();
1067        }
1068
1069        pub fn fetch_suggestions(&self, query: SuggestionQuery) -> Vec<Suggestion> {
1070            self.inner.query(query).unwrap().suggestions
1071        }
1072
1073        pub fn fetch_global_config(&self) -> SuggestGlobalConfig {
1074            self.inner
1075                .fetch_global_config()
1076                .expect("Error fetching global config")
1077        }
1078
1079        pub fn fetch_provider_config(
1080            &self,
1081            provider: SuggestionProvider,
1082        ) -> Option<SuggestProviderConfig> {
1083            self.inner
1084                .fetch_provider_config(provider)
1085                .expect("Error fetching provider config")
1086        }
1087
1088        pub fn fetch_geonames(
1089            &self,
1090            query: &str,
1091            match_name_prefix: bool,
1092            filter: Option<Vec<Geoname>>,
1093        ) -> Vec<GeonameMatch> {
1094            self.inner
1095                .fetch_geonames(query, match_name_prefix, filter)
1096                .expect("Error fetching geonames")
1097        }
1098    }
1099
1100    /// Tests that `SuggestStore` is usable with UniFFI, which requires exposed
1101    /// interfaces to be `Send` and `Sync`.
1102    #[test]
1103    fn is_thread_safe() {
1104        before_each();
1105
1106        fn is_send_sync<T: Send + Sync>() {}
1107        is_send_sync::<SuggestStore>();
1108    }
1109
1110    /// Tests ingesting suggestions into an empty database.
1111    #[test]
1112    fn ingest_suggestions() -> anyhow::Result<()> {
1113        before_each();
1114
1115        let store = TestStore::new(
1116            MockRemoteSettingsClient::default()
1117                .with_record(SuggestionProvider::Amp.record("1234", json![los_pollos_amp()]))
1118                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon())),
1119        );
1120        store.ingest(SuggestIngestionConstraints::all_providers());
1121        assert_eq!(
1122            store.fetch_suggestions(SuggestionQuery::amp("lo")),
1123            vec![los_pollos_suggestion("los pollos", None)],
1124        );
1125        Ok(())
1126    }
1127
1128    /// Tests ingesting suggestions into an empty database.
1129    #[test]
1130    fn ingest_empty_only() -> anyhow::Result<()> {
1131        before_each();
1132
1133        let mut store = TestStore::new(
1134            MockRemoteSettingsClient::default()
1135                .with_record(SuggestionProvider::Amp.record("1234", json![los_pollos_amp()])),
1136        );
1137        // suggestions_table_empty returns true before the ingestion is complete
1138        assert!(store.read(|dao| dao.suggestions_table_empty())?);
1139        // This ingestion should run, since the DB is empty
1140        store.ingest(SuggestIngestionConstraints {
1141            empty_only: true,
1142            ..SuggestIngestionConstraints::all_providers()
1143        });
1144        // suggestions_table_empty returns false after the ingestion is complete
1145        assert!(!store.read(|dao| dao.suggestions_table_empty())?);
1146
1147        // This ingestion should not run since the DB is no longer empty
1148        store.client_mut().update_record(
1149            SuggestionProvider::Amp
1150                .record("1234", json!([los_pollos_amp(), good_place_eats_amp()])),
1151        );
1152
1153        store.ingest(SuggestIngestionConstraints {
1154            empty_only: true,
1155            ..SuggestIngestionConstraints::all_providers()
1156        });
1157        // "la" should not match the good place eats suggestion, since that should not have been
1158        // ingested.
1159        assert_eq!(store.fetch_suggestions(SuggestionQuery::amp("la")), vec![]);
1160
1161        Ok(())
1162    }
1163
1164    /// Tests ingesting suggestions with icons.
1165    #[test]
1166    fn ingest_amp_icons() -> anyhow::Result<()> {
1167        before_each();
1168
1169        let store = TestStore::new(
1170            MockRemoteSettingsClient::default()
1171                .with_record(
1172                    SuggestionProvider::Amp
1173                        .record("1234", json!([los_pollos_amp(), good_place_eats_amp()])),
1174                )
1175                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon()))
1176                .with_record(SuggestionProvider::Amp.icon(good_place_eats_icon())),
1177        );
1178        // This ingestion should run, since the DB is empty
1179        store.ingest(SuggestIngestionConstraints::all_providers());
1180
1181        assert_eq!(
1182            store.fetch_suggestions(SuggestionQuery::amp("lo")),
1183            vec![los_pollos_suggestion("los pollos", None)]
1184        );
1185        assert_eq!(
1186            store.fetch_suggestions(SuggestionQuery::amp("la")),
1187            vec![good_place_eats_suggestion("lasagna", None)]
1188        );
1189
1190        Ok(())
1191    }
1192
1193    #[test]
1194    fn ingest_amp_full_keywords() -> anyhow::Result<()> {
1195        before_each();
1196
1197        let store = TestStore::new(MockRemoteSettingsClient::default()
1198            .with_record(
1199                SuggestionProvider::Amp.record("1234", json!([
1200                // AMP attachment with full keyword data
1201                los_pollos_amp().merge(json!({
1202                    "keywords": ["lo", "los", "los p", "los pollos", "los pollos h", "los pollos hermanos"],
1203                    "full_keywords": [
1204                        // Full keyword for the first 4 keywords
1205                        ("los pollos", 4),
1206                        // Full keyword for the next 2 keywords
1207                        ("los pollos hermanos (restaurant)", 2),
1208                    ],
1209                })),
1210                // AMP attachment without full keyword data
1211                good_place_eats_amp().remove("full_keywords"),
1212            ])))
1213            .with_record(SuggestionProvider::Amp.icon(los_pollos_icon()))
1214            .with_record(SuggestionProvider::Amp.icon(good_place_eats_icon()))
1215        );
1216        store.ingest(SuggestIngestionConstraints::all_providers());
1217
1218        // (query string, expected suggestion, expected dismissal key)
1219        let tests = [
1220            (
1221                "lo",
1222                los_pollos_suggestion("los pollos", None),
1223                Some("los pollos"),
1224            ),
1225            (
1226                "los pollos",
1227                los_pollos_suggestion("los pollos", None),
1228                Some("los pollos"),
1229            ),
1230            (
1231                "los pollos h",
1232                los_pollos_suggestion("los pollos hermanos (restaurant)", None),
1233                Some("los pollos hermanos (restaurant)"),
1234            ),
1235            (
1236                "la",
1237                good_place_eats_suggestion("", None),
1238                Some("https://www.lasagna.restaurant"),
1239            ),
1240            (
1241                "lasagna",
1242                good_place_eats_suggestion("", None),
1243                Some("https://www.lasagna.restaurant"),
1244            ),
1245            (
1246                "lasagna come out tomorrow",
1247                good_place_eats_suggestion("", None),
1248                Some("https://www.lasagna.restaurant"),
1249            ),
1250        ];
1251        for (query, expected_suggestion, expected_dismissal_key) in tests {
1252            // Do a query and check the returned suggestions.
1253            let suggestions = store.fetch_suggestions(SuggestionQuery::amp(query));
1254            assert_eq!(suggestions, vec![expected_suggestion.clone()]);
1255
1256            // Check the returned suggestion's dismissal key.
1257            assert_eq!(suggestions[0].dismissal_key(), expected_dismissal_key);
1258
1259            // Dismiss the suggestion.
1260            let dismissal_key = suggestions[0].dismissal_key().unwrap();
1261            store.inner.dismiss_by_suggestion(&suggestions[0])?;
1262            assert_eq!(store.fetch_suggestions(SuggestionQuery::amp(query)), vec![]);
1263            assert!(store.inner.is_dismissed_by_suggestion(&suggestions[0])?);
1264            assert!(store.inner.is_dismissed_by_key(dismissal_key)?);
1265            assert!(store.inner.any_dismissed_suggestions()?);
1266
1267            // Clear dismissals and fetch again.
1268            store.inner.clear_dismissed_suggestions()?;
1269            assert_eq!(
1270                store.fetch_suggestions(SuggestionQuery::amp(query)),
1271                vec![expected_suggestion.clone()]
1272            );
1273            assert!(!store.inner.is_dismissed_by_suggestion(&suggestions[0])?);
1274            assert!(!store.inner.is_dismissed_by_key(dismissal_key)?);
1275            assert!(!store.inner.any_dismissed_suggestions()?);
1276
1277            // Dismiss the suggestion by its dismissal key.
1278            store.inner.dismiss_by_key(dismissal_key)?;
1279            assert_eq!(store.fetch_suggestions(SuggestionQuery::amp(query)), vec![]);
1280            assert!(store.inner.is_dismissed_by_suggestion(&suggestions[0])?);
1281            assert!(store.inner.is_dismissed_by_key(dismissal_key)?);
1282            assert!(store.inner.any_dismissed_suggestions()?);
1283
1284            // Clear dismissals and fetch again.
1285            store.inner.clear_dismissed_suggestions()?;
1286            assert_eq!(
1287                store.fetch_suggestions(SuggestionQuery::amp(query)),
1288                vec![expected_suggestion.clone()]
1289            );
1290            assert!(!store.inner.is_dismissed_by_suggestion(&suggestions[0])?);
1291            assert!(!store.inner.is_dismissed_by_key(dismissal_key)?);
1292            assert!(!store.inner.any_dismissed_suggestions()?);
1293
1294            // Dismiss the suggestion by its raw URL using the deprecated API.
1295            let raw_url = expected_suggestion.raw_url().unwrap();
1296            store.inner.dismiss_suggestion(raw_url.to_string())?;
1297            assert_eq!(store.fetch_suggestions(SuggestionQuery::amp(query)), vec![]);
1298            assert!(store.inner.is_dismissed_by_key(raw_url)?);
1299            assert!(store.inner.any_dismissed_suggestions()?);
1300
1301            // Clear dismissals and fetch again.
1302            store.inner.clear_dismissed_suggestions()?;
1303            assert_eq!(
1304                store.fetch_suggestions(SuggestionQuery::amp(query)),
1305                vec![expected_suggestion.clone()]
1306            );
1307            assert!(!store.inner.is_dismissed_by_suggestion(&suggestions[0])?);
1308            assert!(!store.inner.is_dismissed_by_key(dismissal_key)?);
1309            assert!(!store.inner.is_dismissed_by_key(raw_url)?);
1310            assert!(!store.inner.any_dismissed_suggestions()?);
1311        }
1312
1313        Ok(())
1314    }
1315
1316    #[test]
1317    fn ingest_wikipedia_full_keywords() -> anyhow::Result<()> {
1318        before_each();
1319
1320        let store = TestStore::new(
1321            MockRemoteSettingsClient::default()
1322                .with_record(SuggestionProvider::Wikipedia.record(
1323                    "1234",
1324                    json!([
1325                        // Wikipedia attachment with full keyword data.  We should ignore the full
1326                        // keyword data for Wikipedia suggestions
1327                        california_wiki(),
1328                        // california_wiki().merge(json!({
1329                        //     "keywords": ["cal", "cali", "california"],
1330                        //     "full_keywords": [("california institute of technology", 3)],
1331                        // })),
1332                    ]),
1333                ))
1334                .with_record(SuggestionProvider::Wikipedia.icon(california_icon())),
1335        );
1336        store.ingest(SuggestIngestionConstraints::all_providers());
1337
1338        assert_eq!(
1339            store.fetch_suggestions(SuggestionQuery::wikipedia("cal")),
1340            // Even though this had a full_keywords field, we should ignore it since it's a
1341            // wikipedia suggestion and use the keywords.rs code instead
1342            vec![california_suggestion("california")],
1343        );
1344
1345        Ok(())
1346    }
1347
1348    #[test]
1349    fn amp_no_keyword_expansion() -> anyhow::Result<()> {
1350        before_each();
1351
1352        let store = TestStore::new(
1353            MockRemoteSettingsClient::default()
1354                // Setup the keywords such that:
1355                //   * There's a `chicken` keyword, which is not a substring of any full
1356                //     keywords (i.e. it was the result of keyword expansion).
1357                //   * There's a `los pollos ` keyword with an extra space
1358                .with_record(
1359                    SuggestionProvider::Amp.record(
1360                    "1234",
1361                    los_pollos_amp().merge(json!({
1362                        "keywords": ["los", "los pollos", "los pollos ", "los pollos hermanos", "chicken"],
1363                        "full_keywords": [("los pollos", 3), ("los pollos hermanos", 2)],
1364                    }))
1365                ))
1366                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon())),
1367        );
1368        store.ingest(SuggestIngestionConstraints::all_providers());
1369        assert_eq!(
1370            store.fetch_suggestions(SuggestionQuery {
1371                provider_constraints: Some(SuggestionProviderConstraints {
1372                    amp_alternative_matching: Some(AmpMatchingStrategy::NoKeywordExpansion),
1373                    ..SuggestionProviderConstraints::default()
1374                }),
1375                // Should not match, because `chicken` is not a substring of a full keyword.
1376                // i.e. it was added because of keyword expansion.
1377                ..SuggestionQuery::amp("chicken")
1378            }),
1379            vec![],
1380        );
1381        assert_eq!(
1382            store.fetch_suggestions(SuggestionQuery {
1383                provider_constraints: Some(SuggestionProviderConstraints {
1384                    amp_alternative_matching: Some(AmpMatchingStrategy::NoKeywordExpansion),
1385                    ..SuggestionProviderConstraints::default()
1386                }),
1387                // Should match, even though "los pollos " technically is not a substring
1388                // because there's an extra space.  The reason these keywords are in the DB is
1389                // because we want to keep showing the current suggestion when the user types
1390                // the space key.
1391                ..SuggestionQuery::amp("los pollos ")
1392            }),
1393            vec![los_pollos_suggestion("los pollos", None)],
1394        );
1395        Ok(())
1396    }
1397
1398    #[test]
1399    fn amp_fts_against_full_keywords() -> anyhow::Result<()> {
1400        before_each();
1401
1402        let store = TestStore::new(
1403            MockRemoteSettingsClient::default()
1404                // Make sure there's full keywords to match against
1405                .with_record(SuggestionProvider::Amp.record(
1406                    "1234",
1407                    los_pollos_amp().merge(json!({
1408                        "keywords": ["los", "los pollos", "los pollos ", "los pollos hermanos"],
1409                        "full_keywords": [("los pollos", 3), ("los pollos hermanos", 1)],
1410                    })),
1411                ))
1412                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon())),
1413        );
1414        store.ingest(SuggestIngestionConstraints::amp_with_fts());
1415        assert_eq!(
1416            store.fetch_suggestions(SuggestionQuery {
1417                provider_constraints: Some(SuggestionProviderConstraints {
1418                    amp_alternative_matching: Some(AmpMatchingStrategy::FtsAgainstFullKeywords),
1419                    ..SuggestionProviderConstraints::default()
1420                }),
1421                // "Hermanos" should match, even though it's not listed in the keywords,
1422                // because this strategy uses an FTS match against the full keyword list.
1423                ..SuggestionQuery::amp("hermanos")
1424            }),
1425            vec![los_pollos_suggestion(
1426                "hermanos",
1427                Some(FtsMatchInfo {
1428                    prefix: false,
1429                    stemming: false,
1430                })
1431            )],
1432        );
1433        Ok(())
1434    }
1435
1436    #[test]
1437    fn amp_fts_against_title() -> anyhow::Result<()> {
1438        before_each();
1439
1440        let store = TestStore::new(
1441            MockRemoteSettingsClient::default()
1442                .with_record(SuggestionProvider::Amp.record("1234", los_pollos_amp()))
1443                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon())),
1444        );
1445        store.ingest(SuggestIngestionConstraints::amp_with_fts());
1446        assert_eq!(
1447            store.fetch_suggestions(SuggestionQuery {
1448                provider_constraints: Some(SuggestionProviderConstraints {
1449                    amp_alternative_matching: Some(AmpMatchingStrategy::FtsAgainstTitle),
1450                    ..SuggestionProviderConstraints::default()
1451                }),
1452                // "Albuquerque" should match, even though it's not listed in the keywords,
1453                // because this strategy uses an FTS match against the title
1454                ..SuggestionQuery::amp("albuquerque")
1455            }),
1456            vec![los_pollos_suggestion(
1457                "albuquerque",
1458                Some(FtsMatchInfo {
1459                    prefix: false,
1460                    stemming: false,
1461                })
1462            )],
1463        );
1464        Ok(())
1465    }
1466
1467    /// Tests ingesting a data attachment containing a single suggestion,
1468    /// instead of an array of suggestions.
1469    #[test]
1470    fn ingest_one_suggestion_in_data_attachment() -> anyhow::Result<()> {
1471        before_each();
1472
1473        let store = TestStore::new(
1474            MockRemoteSettingsClient::default()
1475                // This record contains just one JSON object, rather than an array of them
1476                .with_record(SuggestionProvider::Amp.record("1234", los_pollos_amp()))
1477                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon())),
1478        );
1479        store.ingest(SuggestIngestionConstraints::all_providers());
1480        assert_eq!(
1481            store.fetch_suggestions(SuggestionQuery::amp("lo")),
1482            vec![los_pollos_suggestion("los pollos", None)],
1483        );
1484
1485        Ok(())
1486    }
1487
1488    /// Tests re-ingesting suggestions from an updated attachment.
1489    #[test]
1490    fn reingest_amp_suggestions() -> anyhow::Result<()> {
1491        before_each();
1492
1493        let mut store = TestStore::new(
1494            MockRemoteSettingsClient::default().with_record(
1495                SuggestionProvider::Amp
1496                    .record("1234", json!([los_pollos_amp(), good_place_eats_amp()])),
1497            ),
1498        );
1499        // Ingest once
1500        store.ingest(SuggestIngestionConstraints::all_providers());
1501        // Update the snapshot with new suggestions: Los pollos has a new name and Good place eats
1502        // is now serving Penne
1503        store
1504            .client_mut()
1505            .update_record(SuggestionProvider::Amp.record(
1506                "1234",
1507                json!([
1508                    los_pollos_amp().merge(json!({
1509                        "title": "Los Pollos Hermanos - Now Serving at 14 Locations!",
1510                    })),
1511                    good_place_eats_amp().merge(json!({
1512                        "keywords": ["pe", "pen", "penne", "penne for your thoughts"],
1513                        "title": "Penne for Your Thoughts",
1514                        "url": "https://penne.biz",
1515                    }))
1516                ]),
1517            ));
1518        store.ingest(SuggestIngestionConstraints::all_providers());
1519
1520        assert!(matches!(
1521            store.fetch_suggestions(SuggestionQuery::amp("lo")).as_slice(),
1522            [ Suggestion::Amp { title, .. } ] if title == "Los Pollos Hermanos - Now Serving at 14 Locations!",
1523        ));
1524
1525        assert_eq!(store.fetch_suggestions(SuggestionQuery::amp("la")), vec![]);
1526        assert!(matches!(
1527            store.fetch_suggestions(SuggestionQuery::amp("pe")).as_slice(),
1528            [ Suggestion::Amp { title, url, .. } ] if title == "Penne for Your Thoughts" && url == "https://penne.biz"
1529        ));
1530
1531        Ok(())
1532    }
1533
1534    #[test]
1535    fn reingest_amp_after_fts_constraint_changes() -> anyhow::Result<()> {
1536        before_each();
1537
1538        // Ingest with FTS enabled, this will populate the FTS table
1539        let store = TestStore::new(
1540            MockRemoteSettingsClient::default()
1541                .with_record(SuggestionProvider::Amp.record(
1542                    "data-1",
1543                    json!([los_pollos_amp().merge(json!({
1544                        "keywords": ["los", "los pollos", "los pollos ", "los pollos hermanos"],
1545                        "full_keywords": [("los pollos", 3), ("los pollos hermanos", 1)],
1546                    }))]),
1547                ))
1548                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon())),
1549        );
1550        // Ingest without FTS
1551        store.ingest(SuggestIngestionConstraints::amp_without_fts());
1552        // Ingest again with FTS
1553        store.ingest(SuggestIngestionConstraints::amp_with_fts());
1554
1555        assert_eq!(
1556            store.fetch_suggestions(SuggestionQuery {
1557                provider_constraints: Some(SuggestionProviderConstraints {
1558                    amp_alternative_matching: Some(AmpMatchingStrategy::FtsAgainstFullKeywords),
1559                    ..SuggestionProviderConstraints::default()
1560                }),
1561                // "Hermanos" should match, even though it's not listed in the keywords,
1562                // because this strategy uses an FTS match against the full keyword list.
1563                ..SuggestionQuery::amp("hermanos")
1564            }),
1565            vec![los_pollos_suggestion(
1566                "hermanos",
1567                Some(FtsMatchInfo {
1568                    prefix: false,
1569                    stemming: false,
1570                }),
1571            )],
1572        );
1573        Ok(())
1574    }
1575
1576    /// Tests re-ingesting icons from an updated attachment.
1577    #[test]
1578    fn reingest_icons() -> anyhow::Result<()> {
1579        before_each();
1580
1581        let mut store = TestStore::new(
1582            MockRemoteSettingsClient::default()
1583                .with_record(
1584                    SuggestionProvider::Amp
1585                        .record("1234", json!([los_pollos_amp(), good_place_eats_amp()])),
1586                )
1587                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon()))
1588                .with_record(SuggestionProvider::Amp.icon(good_place_eats_icon())),
1589        );
1590        // This ingestion should run, since the DB is empty
1591        store.ingest(SuggestIngestionConstraints::all_providers());
1592
1593        // Reingest with updated icon data
1594        //  - Los pollos gets new data and a new id
1595        //  - Good place eats gets new data only
1596        store
1597            .client_mut()
1598            .update_record(SuggestionProvider::Amp.record(
1599                "1234",
1600                json!([
1601                    los_pollos_amp().merge(json!({"icon": "1000"})),
1602                    good_place_eats_amp()
1603                ]),
1604            ))
1605            .delete_record(SuggestionProvider::Amp.icon(los_pollos_icon()))
1606            .add_record(SuggestionProvider::Amp.icon(MockIcon {
1607                id: "1000",
1608                data: "new-los-pollos-icon",
1609                ..los_pollos_icon()
1610            }))
1611            .update_record(SuggestionProvider::Amp.icon(MockIcon {
1612                data: "new-good-place-eats-icon",
1613                ..good_place_eats_icon()
1614            }));
1615        store.ingest(SuggestIngestionConstraints::all_providers());
1616
1617        assert!(matches!(
1618            store.fetch_suggestions(SuggestionQuery::amp("lo")).as_slice(),
1619            [ Suggestion::Amp { icon, .. } ] if *icon == Some("new-los-pollos-icon".as_bytes().to_vec())
1620        ));
1621
1622        assert!(matches!(
1623            store.fetch_suggestions(SuggestionQuery::amp("la")).as_slice(),
1624            [ Suggestion::Amp { icon, .. } ] if *icon == Some("new-good-place-eats-icon".as_bytes().to_vec())
1625        ));
1626
1627        Ok(())
1628    }
1629
1630    /// Tests re-ingesting AMO suggestions from an updated attachment.
1631    #[test]
1632    fn reingest_amo_suggestions() -> anyhow::Result<()> {
1633        before_each();
1634
1635        let mut store = TestStore::new(
1636            MockRemoteSettingsClient::default()
1637                .with_record(SuggestionProvider::Amo.record("data-1", json!([relay_amo()])))
1638                .with_record(
1639                    SuggestionProvider::Amo
1640                        .record("data-2", json!([dark_mode_amo(), foxy_guestures_amo()])),
1641                ),
1642        );
1643
1644        store.ingest(SuggestIngestionConstraints::all_providers());
1645
1646        assert_eq!(
1647            store.fetch_suggestions(SuggestionQuery::amo("masking e")),
1648            vec![relay_suggestion()],
1649        );
1650        assert_eq!(
1651            store.fetch_suggestions(SuggestionQuery::amo("night")),
1652            vec![dark_mode_suggestion()],
1653        );
1654        assert_eq!(
1655            store.fetch_suggestions(SuggestionQuery::amo("grammar")),
1656            vec![foxy_guestures_suggestion()],
1657        );
1658
1659        // Update the snapshot with new suggestions: update the second, drop the
1660        // third, and add the fourth.
1661        store
1662            .client_mut()
1663            .update_record(SuggestionProvider::Amo.record("data-1", json!([relay_amo()])))
1664            .update_record(SuggestionProvider::Amo.record(
1665                "data-2",
1666                json!([
1667                    dark_mode_amo().merge(json!({"title": "Updated second suggestion"})),
1668                    new_tab_override_amo(),
1669                ]),
1670            ));
1671        store.ingest(SuggestIngestionConstraints::all_providers());
1672
1673        assert_eq!(
1674            store.fetch_suggestions(SuggestionQuery::amo("masking e")),
1675            vec![relay_suggestion()],
1676        );
1677        assert!(matches!(
1678            store.fetch_suggestions(SuggestionQuery::amo("night")).as_slice(),
1679            [Suggestion::Amo { title, .. } ] if title == "Updated second suggestion"
1680        ));
1681        assert_eq!(
1682            store.fetch_suggestions(SuggestionQuery::amo("grammar")),
1683            vec![],
1684        );
1685        assert_eq!(
1686            store.fetch_suggestions(SuggestionQuery::amo("image search")),
1687            vec![new_tab_override_suggestion()],
1688        );
1689
1690        Ok(())
1691    }
1692
1693    /// Tests ingestion when previously-ingested suggestions/icons have been deleted.
1694    #[test]
1695    fn ingest_with_deletions() -> anyhow::Result<()> {
1696        before_each();
1697
1698        let mut store = TestStore::new(
1699            MockRemoteSettingsClient::default()
1700                .with_record(SuggestionProvider::Amp.record("data-1", json!([los_pollos_amp()])))
1701                .with_record(
1702                    SuggestionProvider::Amp.record("data-2", json!([good_place_eats_amp()])),
1703                )
1704                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon()))
1705                .with_record(SuggestionProvider::Amp.icon(good_place_eats_icon())),
1706        );
1707        store.ingest(SuggestIngestionConstraints::all_providers());
1708        assert_eq!(
1709            store.fetch_suggestions(SuggestionQuery::amp("lo")),
1710            vec![los_pollos_suggestion("los pollos", None)],
1711        );
1712        assert_eq!(
1713            store.fetch_suggestions(SuggestionQuery::amp("la")),
1714            vec![good_place_eats_suggestion("lasagna", None)],
1715        );
1716        // Re-ingest without los-pollos and good place eat's icon.  The suggest store should
1717        // recognize that they're missing and delete them.
1718        store
1719            .client_mut()
1720            .delete_record(SuggestionProvider::Amp.empty_record("data-1"))
1721            .delete_record(SuggestionProvider::Amp.icon(good_place_eats_icon()));
1722        store.ingest(SuggestIngestionConstraints::all_providers());
1723
1724        assert_eq!(store.fetch_suggestions(SuggestionQuery::amp("lo")), vec![]);
1725        assert!(matches!(
1726            store.fetch_suggestions(SuggestionQuery::amp("la")).as_slice(),
1727            [
1728                Suggestion::Amp { icon, icon_mimetype, .. }
1729            ] if icon.is_none() && icon_mimetype.is_none(),
1730        ));
1731        Ok(())
1732    }
1733
1734    /// Tests clearing the store.
1735    #[test]
1736    fn clear() -> anyhow::Result<()> {
1737        before_each();
1738
1739        let store = TestStore::new(
1740            MockRemoteSettingsClient::default()
1741                .with_record(SuggestionProvider::Amp.record("data-1", json!([los_pollos_amp()])))
1742                .with_record(
1743                    SuggestionProvider::Amp.record("data-2", json!([good_place_eats_amp()])),
1744                )
1745                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon()))
1746                .with_record(SuggestionProvider::Amp.icon(good_place_eats_icon()))
1747                .with_record(
1748                    SuggestionProvider::Weather
1749                        .record("weather-1", json!({ "keywords": ["abcde"], })),
1750                ),
1751        );
1752        store.ingest(SuggestIngestionConstraints::all_providers());
1753        assert!(store.count_rows("suggestions") > 0);
1754        assert!(store.count_rows("keywords") > 0);
1755        assert!(store.count_rows("keywords_i18n") > 0);
1756        assert!(store.count_rows("keywords_metrics") > 0);
1757        assert!(store.count_rows("icons") > 0);
1758
1759        store.inner.clear()?;
1760        assert!(store.count_rows("suggestions") == 0);
1761        assert!(store.count_rows("keywords") == 0);
1762        assert!(store.count_rows("keywords_i18n") == 0);
1763        assert!(store.count_rows("keywords_metrics") == 0);
1764        assert!(store.count_rows("icons") == 0);
1765
1766        Ok(())
1767    }
1768
1769    /// Tests querying suggestions.
1770    #[test]
1771    fn query() -> anyhow::Result<()> {
1772        before_each();
1773
1774        let store = TestStore::new(
1775            MockRemoteSettingsClient::default()
1776                .with_record(
1777                    SuggestionProvider::Amp.record("data-1", json!([good_place_eats_amp(),])),
1778                )
1779                .with_record(SuggestionProvider::Wikipedia.record(
1780                    "wikipedia-1",
1781                    json!([california_wiki(), caltech_wiki(), multimatch_wiki(),]),
1782                ))
1783                .with_record(
1784                    SuggestionProvider::Amo
1785                        .record("data-2", json!([relay_amo(), multimatch_amo(),])),
1786                )
1787                .with_record(SuggestionProvider::Yelp.record("data-4", json!([ramen_yelp(),])))
1788                .with_record(SuggestionProvider::Mdn.record("data-5", json!([array_mdn(),])))
1789                .with_record(SuggestionProvider::Amp.icon(good_place_eats_icon()))
1790                .with_record(SuggestionProvider::Wikipedia.icon(california_icon()))
1791                .with_record(SuggestionProvider::Wikipedia.icon(caltech_icon()))
1792                .with_record(SuggestionProvider::Yelp.icon(yelp_favicon()))
1793                .with_record(SuggestionProvider::Wikipedia.icon(multimatch_wiki_icon())),
1794        );
1795
1796        store.ingest(SuggestIngestionConstraints::all_providers());
1797
1798        assert_eq!(
1799            store.fetch_suggestions(SuggestionQuery::all_providers("")),
1800            vec![]
1801        );
1802        assert_eq!(
1803            store.fetch_suggestions(SuggestionQuery::all_providers("la")),
1804            vec![good_place_eats_suggestion("lasagna", None),]
1805        );
1806        assert_eq!(
1807            store.fetch_suggestions(SuggestionQuery::all_providers("multimatch")),
1808            vec![multimatch_amo_suggestion(), multimatch_wiki_suggestion(),]
1809        );
1810        assert_eq!(
1811            store.fetch_suggestions(SuggestionQuery::all_providers("MultiMatch")),
1812            vec![multimatch_amo_suggestion(), multimatch_wiki_suggestion(),]
1813        );
1814        assert_eq!(
1815            store.fetch_suggestions(SuggestionQuery::all_providers("multimatch").limit(1)),
1816            vec![multimatch_amo_suggestion(),],
1817        );
1818        assert_eq!(
1819            store.fetch_suggestions(SuggestionQuery::amp("la")),
1820            vec![good_place_eats_suggestion("lasagna", None)],
1821        );
1822        assert_eq!(
1823            store.fetch_suggestions(SuggestionQuery::all_providers_except(
1824                "la",
1825                SuggestionProvider::Amp
1826            )),
1827            vec![],
1828        );
1829        assert_eq!(
1830            store.fetch_suggestions(SuggestionQuery::with_providers("la", vec![])),
1831            vec![],
1832        );
1833        assert_eq!(
1834            store.fetch_suggestions(SuggestionQuery::with_providers(
1835                "cal",
1836                vec![SuggestionProvider::Amp, SuggestionProvider::Amo,]
1837            )),
1838            vec![],
1839        );
1840        assert_eq!(
1841            store.fetch_suggestions(SuggestionQuery::wikipedia("cal")),
1842            vec![
1843                california_suggestion("california"),
1844                caltech_suggestion("california"),
1845            ],
1846        );
1847        assert_eq!(
1848            store.fetch_suggestions(SuggestionQuery::wikipedia("cal").limit(1)),
1849            vec![california_suggestion("california"),],
1850        );
1851        assert_eq!(
1852            store.fetch_suggestions(SuggestionQuery::with_providers("cal", vec![])),
1853            vec![],
1854        );
1855        assert_eq!(
1856            store.fetch_suggestions(SuggestionQuery::amo("spam")),
1857            vec![relay_suggestion()],
1858        );
1859        assert_eq!(
1860            store.fetch_suggestions(SuggestionQuery::amo("masking")),
1861            vec![relay_suggestion()],
1862        );
1863        assert_eq!(
1864            store.fetch_suggestions(SuggestionQuery::amo("masking e")),
1865            vec![relay_suggestion()],
1866        );
1867        assert_eq!(
1868            store.fetch_suggestions(SuggestionQuery::amo("masking s")),
1869            vec![],
1870        );
1871        assert_eq!(
1872            store.fetch_suggestions(SuggestionQuery::with_providers(
1873                "soft",
1874                vec![SuggestionProvider::Amp, SuggestionProvider::Wikipedia]
1875            )),
1876            vec![],
1877        );
1878        assert_eq!(
1879            store.fetch_suggestions(SuggestionQuery::yelp("best spicy ramen delivery in tokyo")),
1880            vec![ramen_suggestion(
1881                "best spicy ramen delivery in tokyo",
1882                "https://www.yelp.com/search?find_desc=best+spicy+ramen+delivery&find_loc=tokyo"
1883            ),],
1884        );
1885        assert_eq!(
1886            store.fetch_suggestions(SuggestionQuery::yelp("BeSt SpIcY rAmEn DeLiVeRy In ToKyO")),
1887            vec![ramen_suggestion(
1888                "BeSt SpIcY rAmEn DeLiVeRy In ToKyO",
1889                "https://www.yelp.com/search?find_desc=BeSt+SpIcY+rAmEn+DeLiVeRy&find_loc=ToKyO"
1890            ),],
1891        );
1892        assert_eq!(
1893            store.fetch_suggestions(SuggestionQuery::yelp("best ramen delivery in tokyo")),
1894            vec![ramen_suggestion(
1895                "best ramen delivery in tokyo",
1896                "https://www.yelp.com/search?find_desc=best+ramen+delivery&find_loc=tokyo"
1897            ),],
1898        );
1899        assert_eq!(
1900            store.fetch_suggestions(SuggestionQuery::yelp(
1901                "best invalid_ramen delivery in tokyo"
1902            )),
1903            vec![],
1904        );
1905        assert_eq!(
1906            store.fetch_suggestions(SuggestionQuery::yelp("best in tokyo")),
1907            vec![],
1908        );
1909        assert_eq!(
1910            store.fetch_suggestions(SuggestionQuery::yelp("super best ramen in tokyo")),
1911            vec![ramen_suggestion(
1912                "super best ramen in tokyo",
1913                "https://www.yelp.com/search?find_desc=super+best+ramen&find_loc=tokyo"
1914            ),],
1915        );
1916        assert_eq!(
1917            store.fetch_suggestions(SuggestionQuery::yelp("invalid_best ramen in tokyo")),
1918            vec![],
1919        );
1920        assert_eq!(
1921            store.fetch_suggestions(SuggestionQuery::yelp("ramen delivery in tokyo")),
1922            vec![ramen_suggestion(
1923                "ramen delivery in tokyo",
1924                "https://www.yelp.com/search?find_desc=ramen+delivery&find_loc=tokyo"
1925            ),],
1926        );
1927        assert_eq!(
1928            store.fetch_suggestions(SuggestionQuery::yelp("ramen super delivery in tokyo")),
1929            vec![ramen_suggestion(
1930                "ramen super delivery in tokyo",
1931                "https://www.yelp.com/search?find_desc=ramen+super+delivery&find_loc=tokyo"
1932            ),],
1933        );
1934        assert_eq!(
1935            store.fetch_suggestions(SuggestionQuery::yelp("ramen invalid_delivery")),
1936            vec![ramen_suggestion(
1937                "ramen invalid_delivery",
1938                "https://www.yelp.com/search?find_desc=ramen&find_loc=invalid_delivery"
1939            )
1940            .has_location_sign(false),],
1941        );
1942        assert_eq!(
1943            store.fetch_suggestions(SuggestionQuery::yelp("ramen invalid_delivery in tokyo")),
1944            vec![ramen_suggestion(
1945                "ramen invalid_delivery in tokyo",
1946                "https://www.yelp.com/search?find_desc=ramen&find_loc=invalid_delivery+in+tokyo"
1947            )
1948            .has_location_sign(false),],
1949        );
1950        assert_eq!(
1951            store.fetch_suggestions(SuggestionQuery::yelp("ramen in tokyo")),
1952            vec![ramen_suggestion(
1953                "ramen in tokyo",
1954                "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo"
1955            ),],
1956        );
1957        assert_eq!(
1958            store.fetch_suggestions(SuggestionQuery::yelp("ramen near tokyo")),
1959            vec![ramen_suggestion(
1960                "ramen near tokyo",
1961                "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo"
1962            ),],
1963        );
1964        assert_eq!(
1965            store.fetch_suggestions(SuggestionQuery::yelp("ramen invalid_in tokyo")),
1966            vec![ramen_suggestion(
1967                "ramen invalid_in tokyo",
1968                "https://www.yelp.com/search?find_desc=ramen&find_loc=invalid_in+tokyo"
1969            )
1970            .has_location_sign(false),],
1971        );
1972        assert_eq!(
1973            store.fetch_suggestions(SuggestionQuery::yelp("ramen in San Francisco")),
1974            vec![ramen_suggestion(
1975                "ramen in San Francisco",
1976                "https://www.yelp.com/search?find_desc=ramen&find_loc=San+Francisco"
1977            ),],
1978        );
1979        assert_eq!(
1980            store.fetch_suggestions(SuggestionQuery::yelp("ramen in")),
1981            vec![ramen_suggestion(
1982                "ramen in",
1983                "https://www.yelp.com/search?find_desc=ramen"
1984            ),],
1985        );
1986        assert_eq!(
1987            store.fetch_suggestions(SuggestionQuery::yelp("ramen near by")),
1988            vec![ramen_suggestion(
1989                "ramen near by",
1990                "https://www.yelp.com/search?find_desc=ramen"
1991            )],
1992        );
1993        assert_eq!(
1994            store.fetch_suggestions(SuggestionQuery::yelp("ramen near me")),
1995            vec![ramen_suggestion(
1996                "ramen near me",
1997                "https://www.yelp.com/search?find_desc=ramen"
1998            )],
1999        );
2000        assert_eq!(
2001            store.fetch_suggestions(SuggestionQuery::yelp("ramen near by tokyo")),
2002            vec![ramen_suggestion(
2003                "ramen near by tokyo",
2004                "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo"
2005            )],
2006        );
2007        assert_eq!(
2008            store.fetch_suggestions(SuggestionQuery::yelp("ramen")),
2009            vec![
2010                ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
2011                    .has_location_sign(false),
2012            ],
2013        );
2014        // Test an extremely long yelp query
2015        assert_eq!(
2016            store.fetch_suggestions(SuggestionQuery::yelp(
2017                "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"
2018            )),
2019            vec![
2020                ramen_suggestion(
2021                    "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789",
2022                    "https://www.yelp.com/search?find_desc=012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"
2023                ).has_location_sign(false),
2024            ],
2025        );
2026        // This query is over the limit and no suggestions should be returned
2027        assert_eq!(
2028            store.fetch_suggestions(SuggestionQuery::yelp(
2029                "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789Z"
2030            )),
2031            vec![],
2032        );
2033        assert_eq!(
2034            store.fetch_suggestions(SuggestionQuery::yelp("best delivery")),
2035            vec![],
2036        );
2037        assert_eq!(
2038            store.fetch_suggestions(SuggestionQuery::yelp("same_modifier same_modifier")),
2039            vec![],
2040        );
2041        assert_eq!(
2042            store.fetch_suggestions(SuggestionQuery::yelp("same_modifier ")),
2043            vec![],
2044        );
2045        assert_eq!(
2046            store.fetch_suggestions(SuggestionQuery::yelp("yelp ramen")),
2047            vec![
2048                ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
2049                    .has_location_sign(false),
2050            ],
2051        );
2052        assert_eq!(
2053            store.fetch_suggestions(SuggestionQuery::yelp("yelp keyword ramen")),
2054            vec![
2055                ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
2056                    .has_location_sign(false),
2057            ],
2058        );
2059        assert_eq!(
2060            store.fetch_suggestions(SuggestionQuery::yelp("ramen in tokyo yelp")),
2061            vec![ramen_suggestion(
2062                "ramen in tokyo",
2063                "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo"
2064            )],
2065        );
2066        assert_eq!(
2067            store.fetch_suggestions(SuggestionQuery::yelp("ramen in tokyo yelp keyword")),
2068            vec![ramen_suggestion(
2069                "ramen in tokyo",
2070                "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo"
2071            )],
2072        );
2073        assert_eq!(
2074            store.fetch_suggestions(SuggestionQuery::yelp("yelp ramen yelp")),
2075            vec![
2076                ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
2077                    .has_location_sign(false)
2078            ],
2079        );
2080        assert_eq!(
2081            store.fetch_suggestions(SuggestionQuery::yelp("best yelp ramen")),
2082            vec![],
2083        );
2084        assert_eq!(
2085            store.fetch_suggestions(SuggestionQuery::yelp("Spicy R")),
2086            vec![ramen_suggestion(
2087                "Spicy Ramen",
2088                "https://www.yelp.com/search?find_desc=Spicy+Ramen"
2089            )
2090            .has_location_sign(false)
2091            .subject_exact_match(false)],
2092        );
2093        assert_eq!(
2094            store.fetch_suggestions(SuggestionQuery::yelp("spi")),
2095            vec![ramen_suggestion(
2096                "spicy ramen",
2097                "https://www.yelp.com/search?find_desc=spicy+ramen"
2098            )
2099            .has_location_sign(false)
2100            .subject_exact_match(false)],
2101        );
2102        assert_eq!(
2103            store.fetch_suggestions(SuggestionQuery::yelp("BeSt             Ramen")),
2104            vec![ramen_suggestion(
2105                "BeSt Ramen",
2106                "https://www.yelp.com/search?find_desc=BeSt+Ramen"
2107            )
2108            .has_location_sign(false)],
2109        );
2110        assert_eq!(
2111            store.fetch_suggestions(SuggestionQuery::yelp("BeSt             Spicy R")),
2112            vec![ramen_suggestion(
2113                "BeSt Spicy Ramen",
2114                "https://www.yelp.com/search?find_desc=BeSt+Spicy+Ramen"
2115            )
2116            .has_location_sign(false)
2117            .subject_exact_match(false)],
2118        );
2119        assert_eq!(
2120            store.fetch_suggestions(SuggestionQuery::yelp("BeSt             R")),
2121            vec![],
2122        );
2123        assert_eq!(store.fetch_suggestions(SuggestionQuery::yelp("r")), vec![],);
2124        assert_eq!(
2125            store.fetch_suggestions(SuggestionQuery::yelp("ra")),
2126            vec![
2127                ramen_suggestion("rats", "https://www.yelp.com/search?find_desc=rats")
2128                    .has_location_sign(false)
2129                    .subject_exact_match(false)
2130            ],
2131        );
2132        assert_eq!(
2133            store.fetch_suggestions(SuggestionQuery::yelp("ram")),
2134            vec![
2135                ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
2136                    .has_location_sign(false)
2137                    .subject_exact_match(false)
2138            ],
2139        );
2140        assert_eq!(
2141            store.fetch_suggestions(SuggestionQuery::yelp("rac")),
2142            vec![
2143                ramen_suggestion("raccoon", "https://www.yelp.com/search?find_desc=raccoon")
2144                    .has_location_sign(false)
2145                    .subject_exact_match(false)
2146            ],
2147        );
2148        assert_eq!(
2149            store.fetch_suggestions(SuggestionQuery::yelp("best r")),
2150            vec![],
2151        );
2152        assert_eq!(
2153            store.fetch_suggestions(SuggestionQuery::yelp("best ra")),
2154            vec![ramen_suggestion(
2155                "best rats",
2156                "https://www.yelp.com/search?find_desc=best+rats"
2157            )
2158            .has_location_sign(false)
2159            .subject_exact_match(false)],
2160        );
2161        assert_eq!(
2162            store.fetch_suggestions(SuggestionQuery::yelp("best sp")),
2163            vec![ramen_suggestion(
2164                "best spicy ramen",
2165                "https://www.yelp.com/search?find_desc=best+spicy+ramen"
2166            )
2167            .has_location_sign(false)
2168            .subject_exact_match(false)],
2169        );
2170        assert_eq!(
2171            store.fetch_suggestions(SuggestionQuery::yelp("ramenabc")),
2172            vec![],
2173        );
2174        assert_eq!(
2175            store.fetch_suggestions(SuggestionQuery::yelp("ramenabc xyz")),
2176            vec![],
2177        );
2178        assert_eq!(
2179            store.fetch_suggestions(SuggestionQuery::yelp("best ramenabc")),
2180            vec![],
2181        );
2182        assert_eq!(
2183            store.fetch_suggestions(SuggestionQuery::yelp("bestabc ra")),
2184            vec![],
2185        );
2186        assert_eq!(
2187            store.fetch_suggestions(SuggestionQuery::yelp("bestabc ramen")),
2188            vec![],
2189        );
2190        assert_eq!(
2191            store.fetch_suggestions(SuggestionQuery::yelp("bestabc ramen xyz")),
2192            vec![],
2193        );
2194        assert_eq!(
2195            store.fetch_suggestions(SuggestionQuery::yelp("best spi ram")),
2196            vec![],
2197        );
2198        assert_eq!(
2199            store.fetch_suggestions(SuggestionQuery::yelp("bes ram")),
2200            vec![],
2201        );
2202        assert_eq!(
2203            store.fetch_suggestions(SuggestionQuery::yelp("bes ramen")),
2204            vec![],
2205        );
2206        // Test for prefix match.
2207        assert_eq!(
2208            store.fetch_suggestions(SuggestionQuery::yelp("ramen D")),
2209            vec![ramen_suggestion(
2210                "ramen Delivery",
2211                "https://www.yelp.com/search?find_desc=ramen+Delivery"
2212            )
2213            .has_location_sign(false)],
2214        );
2215        assert_eq!(
2216            store.fetch_suggestions(SuggestionQuery::yelp("ramen I")),
2217            vec![ramen_suggestion(
2218                "ramen In",
2219                "https://www.yelp.com/search?find_desc=ramen"
2220            )],
2221        );
2222        assert_eq!(
2223            store.fetch_suggestions(SuggestionQuery::yelp("ramen Y")),
2224            vec![
2225                ramen_suggestion("ramen", "https://www.yelp.com/search?find_desc=ramen")
2226                    .has_location_sign(false)
2227            ],
2228        );
2229        // Prefix match is available only for last words.
2230        assert_eq!(
2231            store.fetch_suggestions(SuggestionQuery::yelp("ramen D Yelp")),
2232            vec![ramen_suggestion(
2233                "ramen D",
2234                "https://www.yelp.com/search?find_desc=ramen&find_loc=D"
2235            )
2236            .has_location_sign(false)],
2237        );
2238        assert_eq!(
2239            store.fetch_suggestions(SuggestionQuery::yelp("ramen I Tokyo")),
2240            vec![ramen_suggestion(
2241                "ramen I Tokyo",
2242                "https://www.yelp.com/search?find_desc=ramen&find_loc=I+Tokyo"
2243            )
2244            .has_location_sign(false)],
2245        );
2246        // Business subject.
2247        assert_eq!(
2248            store.fetch_suggestions(SuggestionQuery::yelp("the shop tokyo")),
2249            vec![ramen_suggestion(
2250                "the shop tokyo",
2251                "https://www.yelp.com/search?find_desc=the+shop&find_loc=tokyo"
2252            )
2253            .has_location_sign(false)
2254            .subject_type(YelpSubjectType::Business)]
2255        );
2256        assert_eq!(
2257            store.fetch_suggestions(SuggestionQuery::yelp("the sho")),
2258            vec![
2259                ramen_suggestion("the shop", "https://www.yelp.com/search?find_desc=the+shop")
2260                    .has_location_sign(false)
2261                    .subject_exact_match(false)
2262                    .subject_type(YelpSubjectType::Business)
2263            ]
2264        );
2265
2266        Ok(())
2267    }
2268
2269    // Tests querying AMP / Wikipedia
2270    #[test]
2271    fn query_with_multiple_providers_and_diff_scores() -> anyhow::Result<()> {
2272        before_each();
2273
2274        let store = TestStore::new(
2275            // Create a data set where one keyword matches multiple suggestions from each provider
2276            // where the scores are manually set.  We will test that the fetched suggestions are in
2277            // the correct order.
2278            MockRemoteSettingsClient::default()
2279                .with_record(SuggestionProvider::Amp.record(
2280                    "data-1",
2281                    json!([
2282                        los_pollos_amp().merge(json!({
2283                            "keywords": ["amp wiki match"],
2284                            "full_keywords": [("amp wiki match", 1)],
2285                            "score": 0.3,
2286                        })),
2287                        good_place_eats_amp().merge(json!({
2288                            "keywords": ["amp wiki match"],
2289                            "full_keywords": [("amp wiki match", 1)],
2290                            "score": 0.1,
2291                        })),
2292                    ]),
2293                ))
2294                .with_record(SuggestionProvider::Wikipedia.record(
2295                    "wikipedia-1",
2296                    json!([california_wiki().merge(json!({
2297                        "keywords": ["amp wiki match", "wiki match"],
2298                    })),]),
2299                ))
2300                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon()))
2301                .with_record(SuggestionProvider::Amp.icon(good_place_eats_icon()))
2302                .with_record(SuggestionProvider::Wikipedia.icon(california_icon())),
2303        );
2304
2305        store.ingest(SuggestIngestionConstraints::all_providers());
2306        assert_eq!(
2307            store.fetch_suggestions(SuggestionQuery::all_providers("amp wiki match")),
2308            vec![
2309                los_pollos_suggestion("amp wiki match", None).with_score(0.3),
2310                // Wikipedia entries default to a 0.2 score
2311                california_suggestion("amp wiki match"),
2312                good_place_eats_suggestion("amp wiki match", None).with_score(0.1),
2313            ]
2314        );
2315        assert_eq!(
2316            store.fetch_suggestions(SuggestionQuery::all_providers("amp wiki match").limit(2)),
2317            vec![
2318                los_pollos_suggestion("amp wiki match", None).with_score(0.3),
2319                california_suggestion("amp wiki match"),
2320            ]
2321        );
2322        assert_eq!(
2323            store.fetch_suggestions(SuggestionQuery::all_providers("wiki match")),
2324            vec![california_suggestion("wiki match"),]
2325        );
2326
2327        Ok(())
2328    }
2329
2330    /// Tests ingesting malformed Remote Settings records that we understand,
2331    /// but that are missing fields, or aren't in the format we expect.
2332    #[test]
2333    fn ingest_malformed() -> anyhow::Result<()> {
2334        before_each();
2335
2336        let store = TestStore::new(
2337            MockRemoteSettingsClient::default()
2338                // Amp record without an attachment.
2339                .with_record(SuggestionProvider::Amp.empty_record("data-1"))
2340                // Wikipedia record without an attachment.
2341                .with_record(SuggestionProvider::Wikipedia.empty_record("wikipedia-1"))
2342                // Icon record without an attachment.
2343                .with_record(MockRecord {
2344                    collection: Collection::Amp,
2345                    record_type: SuggestRecordType::Icon,
2346                    id: "icon-1".to_string(),
2347                    inline_data: None,
2348                    attachment: None,
2349                })
2350                // Icon record with an ID that's not `icon-{id}`, so suggestions in
2351                // the data attachment won't be able to reference it.
2352                .with_record(MockRecord {
2353                    collection: Collection::Amp,
2354                    record_type: SuggestRecordType::Icon,
2355                    id: "bad-icon-id".to_string(),
2356                    inline_data: None,
2357                    attachment: Some(MockAttachment::Icon(MockIcon {
2358                        id: "bad-icon-id",
2359                        data: "",
2360                        mimetype: "image/png",
2361                    })),
2362                }),
2363        );
2364
2365        store.ingest(SuggestIngestionConstraints::all_providers());
2366
2367        store.read(|dao| {
2368            assert_eq!(
2369                dao.conn
2370                    .conn_ext_query_one::<i64>("SELECT count(*) FROM suggestions")?,
2371                0
2372            );
2373            assert_eq!(
2374                dao.conn
2375                    .conn_ext_query_one::<i64>("SELECT count(*) FROM icons")?,
2376                0
2377            );
2378
2379            Ok(())
2380        })?;
2381
2382        Ok(())
2383    }
2384
2385    /// Tests that we only ingest providers that we're concerned with.
2386    #[test]
2387    fn ingest_constraints_provider() -> anyhow::Result<()> {
2388        before_each();
2389
2390        let store = TestStore::new(
2391            MockRemoteSettingsClient::default()
2392                .with_record(SuggestionProvider::Amp.record("data-1", json!([los_pollos_amp()])))
2393                .with_record(SuggestionProvider::Yelp.record("yelp-1", json!([ramen_yelp()])))
2394                .with_record(SuggestionProvider::Amp.icon(los_pollos_icon())),
2395        );
2396
2397        let constraints = SuggestIngestionConstraints {
2398            providers: Some(vec![SuggestionProvider::Amp]),
2399            ..SuggestIngestionConstraints::all_providers()
2400        };
2401        store.ingest(constraints);
2402
2403        // This should have been ingested
2404        assert_eq!(
2405            store.fetch_suggestions(SuggestionQuery::amp("lo")),
2406            vec![los_pollos_suggestion("los pollos", None)]
2407        );
2408        // This should not have been ingested, since it wasn't in the providers list
2409        assert_eq!(
2410            store.fetch_suggestions(SuggestionQuery::yelp("best ramen")),
2411            vec![]
2412        );
2413
2414        Ok(())
2415    }
2416
2417    /// Tests that records with invalid attachments are ignored
2418    #[test]
2419    fn skip_over_invalid_records() -> anyhow::Result<()> {
2420        before_each();
2421
2422        let store = TestStore::new(
2423            MockRemoteSettingsClient::default()
2424                // valid record
2425                .with_record(
2426                    SuggestionProvider::Amp.record("data-1", json!([good_place_eats_amp()])),
2427                )
2428                // This attachment is missing the `title` field and is invalid
2429                .with_record(SuggestionProvider::Amp.record(
2430                    "data-2",
2431                    json!([{
2432                            "id": 1,
2433                            "advertiser": "Los Pollos Hermanos",
2434                            "iab_category": "8 - Food & Drink",
2435                            "keywords": ["lo", "los", "los pollos"],
2436                            "url": "https://www.lph-nm.biz",
2437                            "icon": "5678",
2438                            "impression_url": "https://example.com/impression_url",
2439                            "click_url": "https://example.com/click_url",
2440                            "score": 0.3
2441                    }]),
2442                ))
2443                .with_record(SuggestionProvider::Amp.icon(good_place_eats_icon())),
2444        );
2445
2446        store.ingest(SuggestIngestionConstraints::all_providers());
2447
2448        // Test that the valid record was read
2449        assert_eq!(
2450            store.fetch_suggestions(SuggestionQuery::amp("la")),
2451            vec![good_place_eats_suggestion("lasagna", None)]
2452        );
2453        // Test that the invalid record was skipped
2454        assert_eq!(store.fetch_suggestions(SuggestionQuery::amp("lo")), vec![]);
2455
2456        Ok(())
2457    }
2458
2459    #[test]
2460    fn query_mdn() -> anyhow::Result<()> {
2461        before_each();
2462
2463        let store = TestStore::new(
2464            MockRemoteSettingsClient::default()
2465                .with_record(SuggestionProvider::Mdn.record("mdn-1", json!([array_mdn()]))),
2466        );
2467        store.ingest(SuggestIngestionConstraints::all_providers());
2468        // prefix
2469        assert_eq!(
2470            store.fetch_suggestions(SuggestionQuery::mdn("array")),
2471            vec![array_suggestion(),]
2472        );
2473        // prefix + partial suffix
2474        assert_eq!(
2475            store.fetch_suggestions(SuggestionQuery::mdn("array java")),
2476            vec![array_suggestion(),]
2477        );
2478        // prefix + entire suffix
2479        assert_eq!(
2480            store.fetch_suggestions(SuggestionQuery::mdn("javascript array")),
2481            vec![array_suggestion(),]
2482        );
2483        // partial prefix word
2484        assert_eq!(
2485            store.fetch_suggestions(SuggestionQuery::mdn("wild")),
2486            vec![]
2487        );
2488        // single word
2489        assert_eq!(
2490            store.fetch_suggestions(SuggestionQuery::mdn("wildcard")),
2491            vec![array_suggestion()]
2492        );
2493        Ok(())
2494    }
2495
2496    #[test]
2497    fn query_no_yelp_icon_data() -> anyhow::Result<()> {
2498        before_each();
2499
2500        let store = TestStore::new(MockRemoteSettingsClient::default().with_record(
2501            SuggestionProvider::Yelp.record("yelp-1", json!([ramen_yelp()])), // Note: yelp_favicon() is missing
2502        ));
2503        store.ingest(SuggestIngestionConstraints::all_providers());
2504        assert!(matches!(
2505            store.fetch_suggestions(SuggestionQuery::yelp("ramen")).as_slice(),
2506            [Suggestion::Yelp { icon, icon_mimetype, .. }] if icon.is_none() && icon_mimetype.is_none()
2507        ));
2508
2509        Ok(())
2510    }
2511
2512    #[test]
2513    fn fetch_global_config() -> anyhow::Result<()> {
2514        before_each();
2515
2516        let store = TestStore::new(MockRemoteSettingsClient::default().with_record(MockRecord {
2517            collection: Collection::Other,
2518            record_type: SuggestRecordType::GlobalConfig,
2519            id: "configuration-1".to_string(),
2520            inline_data: Some(json!({
2521                "configuration": {
2522                    "show_less_frequently_cap": 3,
2523                },
2524            })),
2525            attachment: None,
2526        }));
2527
2528        store.ingest(SuggestIngestionConstraints::all_providers());
2529        assert_eq!(
2530            store.fetch_global_config(),
2531            SuggestGlobalConfig {
2532                show_less_frequently_cap: 3,
2533            }
2534        );
2535
2536        Ok(())
2537    }
2538
2539    #[test]
2540    fn fetch_global_config_default() -> anyhow::Result<()> {
2541        before_each();
2542
2543        let store = TestStore::new(MockRemoteSettingsClient::default());
2544        store.ingest(SuggestIngestionConstraints::all_providers());
2545        assert_eq!(
2546            store.fetch_global_config(),
2547            SuggestGlobalConfig {
2548                show_less_frequently_cap: 0,
2549            }
2550        );
2551
2552        Ok(())
2553    }
2554
2555    #[test]
2556    fn fetch_provider_config_none() -> anyhow::Result<()> {
2557        before_each();
2558
2559        let store = TestStore::new(MockRemoteSettingsClient::default());
2560        store.ingest(SuggestIngestionConstraints::all_providers());
2561        assert_eq!(store.fetch_provider_config(SuggestionProvider::Amp), None);
2562        assert_eq!(
2563            store.fetch_provider_config(SuggestionProvider::Weather),
2564            None
2565        );
2566
2567        Ok(())
2568    }
2569
2570    #[test]
2571    fn fetch_provider_config_other() -> anyhow::Result<()> {
2572        before_each();
2573
2574        let store = TestStore::new(MockRemoteSettingsClient::default().with_record(
2575            SuggestionProvider::Weather.record(
2576                "weather-1",
2577                json!({
2578                    "min_keyword_length": 3,
2579                    "score": 0.24,
2580                    "max_keyword_length": 1,
2581                    "max_keyword_word_count": 1,
2582                    "keywords": []
2583                }),
2584            ),
2585        ));
2586        store.ingest(SuggestIngestionConstraints::all_providers());
2587
2588        // Sanity-check that the weather config was ingested.
2589        assert_eq!(
2590            store.fetch_provider_config(SuggestionProvider::Weather),
2591            Some(SuggestProviderConfig::Weather {
2592                min_keyword_length: 3,
2593                score: 0.24,
2594            })
2595        );
2596
2597        // Getting the config for a different provider should return None.
2598        assert_eq!(store.fetch_provider_config(SuggestionProvider::Amp), None);
2599
2600        Ok(())
2601    }
2602
2603    #[test]
2604    fn remove_dismissed_suggestions() -> anyhow::Result<()> {
2605        before_each();
2606
2607        let store = TestStore::new(
2608            MockRemoteSettingsClient::default()
2609                .with_record(SuggestionProvider::Amp.record(
2610                    "data-1",
2611                    json!([good_place_eats_amp().merge(json!({"keywords": ["cats"]})),]),
2612                ))
2613                .with_record(SuggestionProvider::Wikipedia.record(
2614                    "wikipedia-1",
2615                    json!([california_wiki().merge(json!({"keywords": ["cats"]})),]),
2616                ))
2617                .with_record(SuggestionProvider::Amo.record(
2618                    "amo-1",
2619                    json!([relay_amo().merge(json!({"keywords": ["cats"]})),]),
2620                ))
2621                .with_record(SuggestionProvider::Mdn.record(
2622                    "mdn-1",
2623                    json!([array_mdn().merge(json!({"keywords": ["cats"]})),]),
2624                ))
2625                .with_record(SuggestionProvider::Amp.icon(good_place_eats_icon()))
2626                .with_record(SuggestionProvider::Wikipedia.icon(caltech_icon())),
2627        );
2628        store.ingest(SuggestIngestionConstraints::all_providers());
2629
2630        // A query for cats should return all suggestions
2631        let query = SuggestionQuery::all_providers("cats");
2632        let results = store.fetch_suggestions(query.clone());
2633        assert_eq!(results.len(), 4);
2634
2635        assert!(!store.inner.any_dismissed_suggestions()?);
2636
2637        for result in &results {
2638            let dismissal_key = result.dismissal_key().unwrap();
2639            assert!(!store.inner.is_dismissed_by_suggestion(result)?);
2640            assert!(!store.inner.is_dismissed_by_key(dismissal_key)?);
2641            store.inner.dismiss_by_suggestion(result)?;
2642            assert!(store.inner.is_dismissed_by_suggestion(result)?);
2643            assert!(store.inner.is_dismissed_by_key(dismissal_key)?);
2644            assert!(store.inner.any_dismissed_suggestions()?);
2645        }
2646
2647        // After dismissing the suggestions, the next query shouldn't return them
2648        assert_eq!(store.fetch_suggestions(query.clone()), vec![]);
2649
2650        // Clearing the dismissals should cause them to be returned again
2651        store.inner.clear_dismissed_suggestions()?;
2652        assert_eq!(store.fetch_suggestions(query.clone()).len(), 4);
2653
2654        for result in &results {
2655            let dismissal_key = result.dismissal_key().unwrap();
2656            assert!(!store.inner.is_dismissed_by_suggestion(result)?);
2657            assert!(!store.inner.is_dismissed_by_key(dismissal_key)?);
2658        }
2659        assert!(!store.inner.any_dismissed_suggestions()?);
2660
2661        Ok(())
2662    }
2663
2664    #[test]
2665    fn dynamic_basic() -> anyhow::Result<()> {
2666        before_each();
2667
2668        let store = TestStore::new(
2669            MockRemoteSettingsClient::default()
2670                // A dynamic record whose attachment is a JSON object that only
2671                // contains keywords
2672                .with_record(SuggestionProvider::Dynamic.full_record(
2673                    "dynamic-0",
2674                    Some(json!({
2675                        "suggestion_type": "aaa",
2676                    })),
2677                    Some(MockAttachment::Json(json!({
2678                        "keywords": [
2679                            "aaa keyword",
2680                            "common keyword",
2681                            ["common prefix", [" aaa"]],
2682                            ["choco", ["bo", "late"]],
2683                            ["dup", ["licate 1", "licate 2"]],
2684                        ],
2685                    }))),
2686                ))
2687                // A dynamic record with a score whose attachment is a JSON
2688                // array with multiple suggestions with various properties
2689                .with_record(SuggestionProvider::Dynamic.full_record(
2690                    "dynamic-1",
2691                    Some(json!({
2692                        "suggestion_type": "bbb",
2693                        "score": 1.0,
2694                    })),
2695                    Some(MockAttachment::Json(json!([
2696                        {
2697                            "keywords": [
2698                                "bbb keyword 0",
2699                                "common keyword",
2700                                "common bbb keyword",
2701                                ["common prefix", [" bbb 0"]],
2702                            ],
2703                        },
2704                        {
2705                            "keywords": [
2706                                "bbb keyword 1",
2707                                "common keyword",
2708                                "common bbb keyword",
2709                                ["common prefix", [" bbb 1"]],
2710                            ],
2711                            "dismissal_key": "bbb-1-dismissal-key",
2712                        },
2713                        {
2714                            "keywords": [
2715                                "bbb keyword 2",
2716                                "common keyword",
2717                                "common bbb keyword",
2718                                ["common prefix", [" bbb 2"]],
2719                            ],
2720                            "data": json!("bbb-2-data"),
2721                            "dismissal_key": "bbb-2-dismissal-key",
2722                        },
2723                        {
2724                            "keywords": [
2725                                "bbb keyword 3",
2726                                "common keyword",
2727                                "common bbb keyword",
2728                                ["common prefix", [" bbb 3"]],
2729                            ],
2730                            "data": json!("bbb-3-data"),
2731                        },
2732                    ]))),
2733                )),
2734        );
2735        store.ingest(SuggestIngestionConstraints {
2736            providers: Some(vec![SuggestionProvider::Dynamic]),
2737            provider_constraints: Some(SuggestionProviderConstraints {
2738                dynamic_suggestion_types: Some(vec!["aaa".to_string(), "bbb".to_string()]),
2739                ..SuggestionProviderConstraints::default()
2740            }),
2741            ..SuggestIngestionConstraints::all_providers()
2742        });
2743
2744        // queries that shouldn't match anything
2745        let no_match_queries = vec!["aaa", "common", "common prefi", "choc", "chocolate extra"];
2746        for query in &no_match_queries {
2747            assert_eq!(
2748                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
2749                vec![],
2750            );
2751            assert_eq!(
2752                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["bbb"])),
2753                vec![],
2754            );
2755            assert_eq!(
2756                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa", "bbb"])),
2757                vec![],
2758            );
2759            assert_eq!(
2760                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa", "zzz"])),
2761                vec![],
2762            );
2763            assert_eq!(
2764                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["zzz"])),
2765                vec![],
2766            );
2767        }
2768
2769        // queries that should match only the "aaa" suggestion
2770        let aaa_queries = [
2771            "aaa keyword",
2772            "common prefix a",
2773            "common prefix aa",
2774            "common prefix aaa",
2775            "choco",
2776            "chocob",
2777            "chocobo",
2778            "chocol",
2779            "chocolate",
2780            "dup",
2781            "dupl",
2782            "duplicate",
2783            "duplicate ",
2784            "duplicate 1",
2785            "duplicate 2",
2786        ];
2787        for query in aaa_queries {
2788            for suggestion_types in [
2789                ["aaa"].as_slice(),
2790                &["aaa", "bbb"],
2791                &["bbb", "aaa"],
2792                &["aaa", "zzz"],
2793                &["zzz", "aaa"],
2794            ] {
2795                assert_eq!(
2796                    store.fetch_suggestions(SuggestionQuery::dynamic(query, suggestion_types)),
2797                    vec![Suggestion::Dynamic {
2798                        suggestion_type: "aaa".into(),
2799                        data: None,
2800                        dismissal_key: None,
2801                        score: DEFAULT_SUGGESTION_SCORE,
2802                    }],
2803                );
2804            }
2805            assert_eq!(
2806                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["bbb"])),
2807                vec![],
2808            );
2809            assert_eq!(
2810                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["zzz"])),
2811                vec![],
2812            );
2813        }
2814
2815        // queries that should match only the "bbb 0" suggestion
2816        let bbb_0_queries = ["bbb keyword 0", "common prefix bbb 0"];
2817        for query in &bbb_0_queries {
2818            for suggestion_types in [
2819                ["bbb"].as_slice(),
2820                &["bbb", "aaa"],
2821                &["aaa", "bbb"],
2822                &["bbb", "zzz"],
2823                &["zzz", "bbb"],
2824            ] {
2825                assert_eq!(
2826                    store.fetch_suggestions(SuggestionQuery::dynamic(query, suggestion_types)),
2827                    vec![Suggestion::Dynamic {
2828                        suggestion_type: "bbb".into(),
2829                        data: None,
2830                        dismissal_key: None,
2831                        score: 1.0,
2832                    }],
2833                );
2834            }
2835            assert_eq!(
2836                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
2837                vec![],
2838            );
2839            assert_eq!(
2840                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["zzz"])),
2841                vec![],
2842            );
2843        }
2844
2845        // queries that should match only the "bbb 1" suggestion
2846        let bbb_1_queries = ["bbb keyword 1", "common prefix bbb 1"];
2847        for query in &bbb_1_queries {
2848            for suggestion_types in [
2849                ["bbb"].as_slice(),
2850                &["bbb", "aaa"],
2851                &["aaa", "bbb"],
2852                &["bbb", "zzz"],
2853                &["zzz", "bbb"],
2854            ] {
2855                assert_eq!(
2856                    store.fetch_suggestions(SuggestionQuery::dynamic(query, suggestion_types)),
2857                    vec![Suggestion::Dynamic {
2858                        suggestion_type: "bbb".into(),
2859                        data: None,
2860                        dismissal_key: Some("bbb-1-dismissal-key".to_string()),
2861                        score: 1.0,
2862                    }],
2863                );
2864            }
2865            assert_eq!(
2866                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
2867                vec![],
2868            );
2869            assert_eq!(
2870                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["zzz"])),
2871                vec![],
2872            );
2873        }
2874
2875        // queries that should match only the "bbb 2" suggestion
2876        let bbb_2_queries = ["bbb keyword 2", "common prefix bbb 2"];
2877        for query in &bbb_2_queries {
2878            for suggestion_types in [
2879                ["bbb"].as_slice(),
2880                &["bbb", "aaa"],
2881                &["aaa", "bbb"],
2882                &["bbb", "zzz"],
2883                &["zzz", "bbb"],
2884            ] {
2885                assert_eq!(
2886                    store.fetch_suggestions(SuggestionQuery::dynamic(query, suggestion_types)),
2887                    vec![Suggestion::Dynamic {
2888                        suggestion_type: "bbb".into(),
2889                        data: Some(json!("bbb-2-data")),
2890                        dismissal_key: Some("bbb-2-dismissal-key".to_string()),
2891                        score: 1.0,
2892                    }],
2893                );
2894            }
2895            assert_eq!(
2896                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
2897                vec![],
2898            );
2899            assert_eq!(
2900                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["zzz"])),
2901                vec![],
2902            );
2903        }
2904
2905        // queries that should match only the "bbb 3" suggestion
2906        let bbb_3_queries = ["bbb keyword 3", "common prefix bbb 3"];
2907        for query in &bbb_3_queries {
2908            for suggestion_types in [
2909                ["bbb"].as_slice(),
2910                &["bbb", "aaa"],
2911                &["aaa", "bbb"],
2912                &["bbb", "zzz"],
2913                &["zzz", "bbb"],
2914            ] {
2915                assert_eq!(
2916                    store.fetch_suggestions(SuggestionQuery::dynamic(query, suggestion_types)),
2917                    vec![Suggestion::Dynamic {
2918                        suggestion_type: "bbb".into(),
2919                        data: Some(json!("bbb-3-data")),
2920                        dismissal_key: None,
2921                        score: 1.0,
2922                    }],
2923                );
2924            }
2925            assert_eq!(
2926                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
2927                vec![],
2928            );
2929            assert_eq!(
2930                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["zzz"])),
2931                vec![],
2932            );
2933        }
2934
2935        // queries that should match only the "bbb" suggestions
2936        let bbb_queries = [
2937            "common bbb keyword",
2938            "common prefix b",
2939            "common prefix bb",
2940            "common prefix bbb",
2941            "common prefix bbb ",
2942        ];
2943        for query in &bbb_queries {
2944            for suggestion_types in [
2945                ["bbb"].as_slice(),
2946                &["bbb", "aaa"],
2947                &["aaa", "bbb"],
2948                &["bbb", "zzz"],
2949                &["zzz", "bbb"],
2950            ] {
2951                assert_eq!(
2952                    store.fetch_suggestions(SuggestionQuery::dynamic(query, suggestion_types)),
2953                    vec![
2954                        Suggestion::Dynamic {
2955                            suggestion_type: "bbb".into(),
2956                            data: None,
2957                            dismissal_key: None,
2958                            score: 1.0,
2959                        },
2960                        Suggestion::Dynamic {
2961                            suggestion_type: "bbb".into(),
2962                            data: None,
2963                            dismissal_key: Some("bbb-1-dismissal-key".to_string()),
2964                            score: 1.0,
2965                        },
2966                        Suggestion::Dynamic {
2967                            suggestion_type: "bbb".into(),
2968                            data: Some(json!("bbb-2-data")),
2969                            dismissal_key: Some("bbb-2-dismissal-key".to_string()),
2970                            score: 1.0,
2971                        },
2972                        Suggestion::Dynamic {
2973                            suggestion_type: "bbb".into(),
2974                            data: Some(json!("bbb-3-data")),
2975                            dismissal_key: None,
2976                            score: 1.0,
2977                        }
2978                    ],
2979                );
2980            }
2981            assert_eq!(
2982                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
2983                vec![],
2984            );
2985            assert_eq!(
2986                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["zzz"])),
2987                vec![],
2988            );
2989        }
2990
2991        // queries that should match all suggestions
2992        let common_queries = ["common keyword", "common prefix", "common prefix "];
2993        for query in &common_queries {
2994            for suggestion_types in [
2995                ["aaa", "bbb"].as_slice(),
2996                &["bbb", "aaa"],
2997                &["zzz", "aaa", "bbb"],
2998                &["aaa", "zzz", "bbb"],
2999                &["aaa", "bbb", "zzz"],
3000            ] {
3001                assert_eq!(
3002                    store.fetch_suggestions(SuggestionQuery::dynamic(query, suggestion_types)),
3003                    vec![
3004                        Suggestion::Dynamic {
3005                            suggestion_type: "bbb".into(),
3006                            data: None,
3007                            dismissal_key: None,
3008                            score: 1.0,
3009                        },
3010                        Suggestion::Dynamic {
3011                            suggestion_type: "bbb".into(),
3012                            data: None,
3013                            dismissal_key: Some("bbb-1-dismissal-key".to_string()),
3014                            score: 1.0,
3015                        },
3016                        Suggestion::Dynamic {
3017                            suggestion_type: "bbb".into(),
3018                            data: Some(json!("bbb-2-data")),
3019                            dismissal_key: Some("bbb-2-dismissal-key".to_string()),
3020                            score: 1.0,
3021                        },
3022                        Suggestion::Dynamic {
3023                            suggestion_type: "bbb".into(),
3024                            data: Some(json!("bbb-3-data")),
3025                            dismissal_key: None,
3026                            score: 1.0,
3027                        },
3028                        Suggestion::Dynamic {
3029                            suggestion_type: "aaa".into(),
3030                            data: None,
3031                            dismissal_key: None,
3032                            score: DEFAULT_SUGGESTION_SCORE,
3033                        },
3034                    ],
3035                );
3036                assert_eq!(
3037                    store.fetch_suggestions(SuggestionQuery::dynamic(query, &["zzz"])),
3038                    vec![],
3039                );
3040            }
3041        }
3042
3043        Ok(())
3044    }
3045
3046    #[test]
3047    fn dynamic_same_type_in_different_records() -> anyhow::Result<()> {
3048        before_each();
3049
3050        // Make a store with the same dynamic suggestion type in three different
3051        // records.
3052        let mut store = TestStore::new(
3053            MockRemoteSettingsClient::default()
3054                // A record whose attachment is a JSON object
3055                .with_record(SuggestionProvider::Dynamic.full_record(
3056                    "dynamic-0",
3057                    Some(json!({
3058                        "suggestion_type": "aaa",
3059                    })),
3060                    Some(MockAttachment::Json(json!({
3061                        "keywords": [
3062                            "record 0 keyword",
3063                            "common keyword",
3064                            ["common prefix", [" 0"]],
3065                        ],
3066                        "data": json!("record-0-data"),
3067                    }))),
3068                ))
3069                // Another record whose attachment is a JSON object
3070                .with_record(SuggestionProvider::Dynamic.full_record(
3071                    "dynamic-1",
3072                    Some(json!({
3073                        "suggestion_type": "aaa",
3074                    })),
3075                    Some(MockAttachment::Json(json!({
3076                        "keywords": [
3077                            "record 1 keyword",
3078                            "common keyword",
3079                            ["common prefix", [" 1"]],
3080                        ],
3081                        "data": json!("record-1-data"),
3082                    }))),
3083                ))
3084                // A record whose attachment is a JSON array with some
3085                // suggestions
3086                .with_record(SuggestionProvider::Dynamic.full_record(
3087                    "dynamic-2",
3088                    Some(json!({
3089                        "suggestion_type": "aaa",
3090                    })),
3091                    Some(MockAttachment::Json(json!([
3092                        {
3093                            "keywords": [
3094                                "record 2 keyword",
3095                                "record 2 keyword 0",
3096                                "common keyword",
3097                                ["common prefix", [" 2-0"]],
3098                            ],
3099                            "data": json!("record-2-data-0"),
3100                        },
3101                        {
3102                            "keywords": [
3103                                "record 2 keyword",
3104                                "record 2 keyword 1",
3105                                "common keyword",
3106                                ["common prefix", [" 2-1"]],
3107                            ],
3108                            "data": json!("record-2-data-1"),
3109                        },
3110                    ]))),
3111                )),
3112        );
3113        store.ingest(SuggestIngestionConstraints {
3114            providers: Some(vec![SuggestionProvider::Dynamic]),
3115            provider_constraints: Some(SuggestionProviderConstraints {
3116                dynamic_suggestion_types: Some(vec!["aaa".to_string()]),
3117                ..SuggestionProviderConstraints::default()
3118            }),
3119            ..SuggestIngestionConstraints::all_providers()
3120        });
3121
3122        // queries that should match only the suggestion in record 0
3123        let record_0_queries = ["record 0 keyword", "common prefix 0"];
3124        for query in record_0_queries {
3125            assert_eq!(
3126                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3127                vec![Suggestion::Dynamic {
3128                    suggestion_type: "aaa".into(),
3129                    data: Some(json!("record-0-data")),
3130                    dismissal_key: None,
3131                    score: DEFAULT_SUGGESTION_SCORE,
3132                }],
3133            );
3134        }
3135
3136        // queries that should match only the suggestion in record 1
3137        let record_1_queries = ["record 1 keyword", "common prefix 1"];
3138        for query in record_1_queries {
3139            assert_eq!(
3140                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3141                vec![Suggestion::Dynamic {
3142                    suggestion_type: "aaa".into(),
3143                    data: Some(json!("record-1-data")),
3144                    dismissal_key: None,
3145                    score: DEFAULT_SUGGESTION_SCORE,
3146                }],
3147            );
3148        }
3149
3150        // queries that should match only the suggestions in record 2
3151        let record_2_queries = ["record 2 keyword", "common prefix 2", "common prefix 2-"];
3152        for query in record_2_queries {
3153            assert_eq!(
3154                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3155                vec![
3156                    Suggestion::Dynamic {
3157                        suggestion_type: "aaa".into(),
3158                        data: Some(json!("record-2-data-0")),
3159                        dismissal_key: None,
3160                        score: DEFAULT_SUGGESTION_SCORE,
3161                    },
3162                    Suggestion::Dynamic {
3163                        suggestion_type: "aaa".into(),
3164                        data: Some(json!("record-2-data-1")),
3165                        dismissal_key: None,
3166                        score: DEFAULT_SUGGESTION_SCORE,
3167                    },
3168                ],
3169            );
3170        }
3171
3172        // queries that should match only record 2 suggestion 0
3173        let record_2_0_queries = ["record 2 keyword 0", "common prefix 2-0"];
3174        for query in record_2_0_queries {
3175            assert_eq!(
3176                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3177                vec![Suggestion::Dynamic {
3178                    suggestion_type: "aaa".into(),
3179                    data: Some(json!("record-2-data-0")),
3180                    dismissal_key: None,
3181                    score: DEFAULT_SUGGESTION_SCORE,
3182                }],
3183            );
3184        }
3185
3186        // queries that should match only record 2 suggestion 1
3187        let record_2_1_queries = ["record 2 keyword 1", "common prefix 2-1"];
3188        for query in record_2_1_queries {
3189            assert_eq!(
3190                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3191                vec![Suggestion::Dynamic {
3192                    suggestion_type: "aaa".into(),
3193                    data: Some(json!("record-2-data-1")),
3194                    dismissal_key: None,
3195                    score: DEFAULT_SUGGESTION_SCORE,
3196                }],
3197            );
3198        }
3199
3200        // queries that should match all suggestions
3201        let common_queries = ["common keyword", "common prefix", "common prefix "];
3202        for query in common_queries {
3203            assert_eq!(
3204                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3205                vec![
3206                    Suggestion::Dynamic {
3207                        suggestion_type: "aaa".into(),
3208                        data: Some(json!("record-0-data")),
3209                        dismissal_key: None,
3210                        score: DEFAULT_SUGGESTION_SCORE,
3211                    },
3212                    Suggestion::Dynamic {
3213                        suggestion_type: "aaa".into(),
3214                        data: Some(json!("record-1-data")),
3215                        dismissal_key: None,
3216                        score: DEFAULT_SUGGESTION_SCORE,
3217                    },
3218                    Suggestion::Dynamic {
3219                        suggestion_type: "aaa".into(),
3220                        data: Some(json!("record-2-data-0")),
3221                        dismissal_key: None,
3222                        score: DEFAULT_SUGGESTION_SCORE,
3223                    },
3224                    Suggestion::Dynamic {
3225                        suggestion_type: "aaa".into(),
3226                        data: Some(json!("record-2-data-1")),
3227                        dismissal_key: None,
3228                        score: DEFAULT_SUGGESTION_SCORE,
3229                    },
3230                ],
3231            );
3232        }
3233
3234        // Delete record 0.
3235        store
3236            .client_mut()
3237            .delete_record(SuggestionProvider::Dynamic.empty_record("dynamic-0"));
3238        store.ingest(SuggestIngestionConstraints {
3239            providers: Some(vec![SuggestionProvider::Dynamic]),
3240            provider_constraints: Some(SuggestionProviderConstraints {
3241                dynamic_suggestion_types: Some(vec!["aaa".to_string()]),
3242                ..SuggestionProviderConstraints::default()
3243            }),
3244            ..SuggestIngestionConstraints::all_providers()
3245        });
3246
3247        // Keywords from record 0 should not match anything.
3248        for query in record_0_queries {
3249            assert_eq!(
3250                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3251                vec![],
3252            );
3253        }
3254
3255        // The suggestion in record 1 should remain fetchable.
3256        for query in record_1_queries {
3257            assert_eq!(
3258                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3259                vec![Suggestion::Dynamic {
3260                    suggestion_type: "aaa".into(),
3261                    data: Some(json!("record-1-data")),
3262                    dismissal_key: None,
3263                    score: DEFAULT_SUGGESTION_SCORE,
3264                }],
3265            );
3266        }
3267
3268        // The suggestions in record 2 should remain fetchable.
3269        for query in record_2_queries {
3270            assert_eq!(
3271                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3272                vec![
3273                    Suggestion::Dynamic {
3274                        suggestion_type: "aaa".into(),
3275                        data: Some(json!("record-2-data-0")),
3276                        dismissal_key: None,
3277                        score: DEFAULT_SUGGESTION_SCORE,
3278                    },
3279                    Suggestion::Dynamic {
3280                        suggestion_type: "aaa".into(),
3281                        data: Some(json!("record-2-data-1")),
3282                        dismissal_key: None,
3283                        score: DEFAULT_SUGGESTION_SCORE,
3284                    },
3285                ],
3286            );
3287        }
3288        for query in record_2_0_queries {
3289            assert_eq!(
3290                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3291                vec![Suggestion::Dynamic {
3292                    suggestion_type: "aaa".into(),
3293                    data: Some(json!("record-2-data-0")),
3294                    dismissal_key: None,
3295                    score: DEFAULT_SUGGESTION_SCORE,
3296                }],
3297            );
3298        }
3299        for query in record_2_1_queries {
3300            assert_eq!(
3301                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3302                vec![Suggestion::Dynamic {
3303                    suggestion_type: "aaa".into(),
3304                    data: Some(json!("record-2-data-1")),
3305                    dismissal_key: None,
3306                    score: DEFAULT_SUGGESTION_SCORE,
3307                }],
3308            );
3309        }
3310
3311        // All remaining suggestions should remain fetchable via the common
3312        // keywords.
3313        for query in common_queries {
3314            assert_eq!(
3315                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3316                vec![
3317                    Suggestion::Dynamic {
3318                        suggestion_type: "aaa".into(),
3319                        data: Some(json!("record-1-data")),
3320                        dismissal_key: None,
3321                        score: DEFAULT_SUGGESTION_SCORE,
3322                    },
3323                    Suggestion::Dynamic {
3324                        suggestion_type: "aaa".into(),
3325                        data: Some(json!("record-2-data-0")),
3326                        dismissal_key: None,
3327                        score: DEFAULT_SUGGESTION_SCORE,
3328                    },
3329                    Suggestion::Dynamic {
3330                        suggestion_type: "aaa".into(),
3331                        data: Some(json!("record-2-data-1")),
3332                        dismissal_key: None,
3333                        score: DEFAULT_SUGGESTION_SCORE,
3334                    },
3335                ],
3336            );
3337        }
3338
3339        // Delete record 2.
3340        store
3341            .client_mut()
3342            .delete_record(SuggestionProvider::Dynamic.empty_record("dynamic-2"));
3343        store.ingest(SuggestIngestionConstraints {
3344            providers: Some(vec![SuggestionProvider::Dynamic]),
3345            provider_constraints: Some(SuggestionProviderConstraints {
3346                dynamic_suggestion_types: Some(vec!["aaa".to_string()]),
3347                ..SuggestionProviderConstraints::default()
3348            }),
3349            ..SuggestIngestionConstraints::all_providers()
3350        });
3351
3352        // Keywords from record 0 still should not match anything.
3353        for query in record_0_queries {
3354            assert_eq!(
3355                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3356                vec![],
3357            );
3358        }
3359
3360        // The suggestion in record 1 should remain fetchable.
3361        for query in record_1_queries {
3362            assert_eq!(
3363                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3364                vec![Suggestion::Dynamic {
3365                    suggestion_type: "aaa".into(),
3366                    data: Some(json!("record-1-data")),
3367                    dismissal_key: None,
3368                    score: DEFAULT_SUGGESTION_SCORE,
3369                }],
3370            );
3371        }
3372
3373        // The suggestions in record 2 should not be fetchable.
3374        for query in record_2_queries
3375            .iter()
3376            .chain(record_2_0_queries.iter().chain(record_2_1_queries.iter()))
3377        {
3378            assert_eq!(
3379                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3380                vec![]
3381            );
3382        }
3383
3384        // The one remaining suggestion, from record 1, should remain fetchable
3385        // via the common keywords.
3386        for query in common_queries {
3387            assert_eq!(
3388                store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3389                vec![Suggestion::Dynamic {
3390                    suggestion_type: "aaa".into(),
3391                    data: Some(json!("record-1-data")),
3392                    dismissal_key: None,
3393                    score: DEFAULT_SUGGESTION_SCORE,
3394                },],
3395            );
3396        }
3397
3398        Ok(())
3399    }
3400
3401    #[test]
3402    fn dynamic_ingest_provider_constraints() -> anyhow::Result<()> {
3403        before_each();
3404
3405        // Create suggestions with types "aaa" and "bbb".
3406        let store = TestStore::new(
3407            MockRemoteSettingsClient::default()
3408                .with_record(SuggestionProvider::Dynamic.full_record(
3409                    "dynamic-0",
3410                    Some(json!({
3411                        "suggestion_type": "aaa",
3412                    })),
3413                    Some(MockAttachment::Json(json!({
3414                        "keywords": ["aaa keyword", "both keyword"],
3415                    }))),
3416                ))
3417                .with_record(SuggestionProvider::Dynamic.full_record(
3418                    "dynamic-1",
3419                    Some(json!({
3420                        "suggestion_type": "bbb",
3421                    })),
3422                    Some(MockAttachment::Json(json!({
3423                        "keywords": ["bbb keyword", "both keyword"],
3424                    }))),
3425                )),
3426        );
3427
3428        // Ingest but don't pass in any provider constraints. The records will
3429        // be ingested but their attachments won't be, so fetches shouldn't
3430        // return any suggestions.
3431        store.ingest(SuggestIngestionConstraints {
3432            providers: Some(vec![SuggestionProvider::Dynamic]),
3433            provider_constraints: None,
3434            ..SuggestIngestionConstraints::all_providers()
3435        });
3436
3437        let ingest_1_queries = [
3438            ("aaa keyword", vec!["aaa"]),
3439            ("aaa keyword", vec!["bbb"]),
3440            ("aaa keyword", vec!["aaa", "bbb"]),
3441            ("bbb keyword", vec!["aaa"]),
3442            ("bbb keyword", vec!["bbb"]),
3443            ("bbb keyword", vec!["aaa", "bbb"]),
3444            ("both keyword", vec!["aaa"]),
3445            ("both keyword", vec!["bbb"]),
3446            ("both keyword", vec!["aaa", "bbb"]),
3447        ];
3448        for (query, types) in &ingest_1_queries {
3449            assert_eq!(
3450                store.fetch_suggestions(SuggestionQuery::dynamic(query, types)),
3451                vec![],
3452            );
3453        }
3454
3455        // Ingest only the "bbb" suggestion. The "bbb" attachment should be
3456        // ingested, so "bbb" fetches should return the "bbb" suggestion.
3457        store.ingest(SuggestIngestionConstraints {
3458            providers: Some(vec![SuggestionProvider::Dynamic]),
3459            provider_constraints: Some(SuggestionProviderConstraints {
3460                dynamic_suggestion_types: Some(vec!["bbb".to_string()]),
3461                ..SuggestionProviderConstraints::default()
3462            }),
3463            ..SuggestIngestionConstraints::all_providers()
3464        });
3465
3466        let ingest_2_queries = [
3467            ("aaa keyword", vec!["aaa"], vec![]),
3468            ("aaa keyword", vec!["bbb"], vec![]),
3469            ("aaa keyword", vec!["aaa", "bbb"], vec![]),
3470            ("bbb keyword", vec!["aaa"], vec![]),
3471            ("bbb keyword", vec!["bbb"], vec!["bbb"]),
3472            ("bbb keyword", vec!["aaa", "bbb"], vec!["bbb"]),
3473            ("both keyword", vec!["aaa"], vec![]),
3474            ("both keyword", vec!["bbb"], vec!["bbb"]),
3475            ("both keyword", vec!["aaa", "bbb"], vec!["bbb"]),
3476        ];
3477        for (query, types, expected_types) in &ingest_2_queries {
3478            assert_eq!(
3479                store.fetch_suggestions(SuggestionQuery::dynamic(query, types)),
3480                expected_types
3481                    .iter()
3482                    .map(|t| Suggestion::Dynamic {
3483                        suggestion_type: t.to_string(),
3484                        data: None,
3485                        dismissal_key: None,
3486                        score: DEFAULT_SUGGESTION_SCORE,
3487                    })
3488                    .collect::<Vec<Suggestion>>(),
3489            );
3490        }
3491
3492        // Now ingest the "aaa" suggestion.
3493        store.ingest(SuggestIngestionConstraints {
3494            providers: Some(vec![SuggestionProvider::Dynamic]),
3495            provider_constraints: Some(SuggestionProviderConstraints {
3496                dynamic_suggestion_types: Some(vec!["aaa".to_string()]),
3497                ..SuggestionProviderConstraints::default()
3498            }),
3499            ..SuggestIngestionConstraints::all_providers()
3500        });
3501
3502        let ingest_3_queries = [
3503            ("aaa keyword", vec!["aaa"], vec!["aaa"]),
3504            ("aaa keyword", vec!["bbb"], vec![]),
3505            ("aaa keyword", vec!["aaa", "bbb"], vec!["aaa"]),
3506            ("bbb keyword", vec!["aaa"], vec![]),
3507            ("bbb keyword", vec!["bbb"], vec!["bbb"]),
3508            ("bbb keyword", vec!["aaa", "bbb"], vec!["bbb"]),
3509            ("both keyword", vec!["aaa"], vec!["aaa"]),
3510            ("both keyword", vec!["bbb"], vec!["bbb"]),
3511            ("both keyword", vec!["aaa", "bbb"], vec!["aaa", "bbb"]),
3512        ];
3513        for (query, types, expected_types) in &ingest_3_queries {
3514            assert_eq!(
3515                store.fetch_suggestions(SuggestionQuery::dynamic(query, types)),
3516                expected_types
3517                    .iter()
3518                    .map(|t| Suggestion::Dynamic {
3519                        suggestion_type: t.to_string(),
3520                        data: None,
3521                        dismissal_key: None,
3522                        score: DEFAULT_SUGGESTION_SCORE,
3523                    })
3524                    .collect::<Vec<Suggestion>>(),
3525            );
3526        }
3527
3528        Ok(())
3529    }
3530
3531    #[test]
3532    fn dynamic_ingest_new_record() -> anyhow::Result<()> {
3533        before_each();
3534
3535        // Create a dynamic suggestion and ingest it.
3536        let mut store = TestStore::new(MockRemoteSettingsClient::default().with_record(
3537            SuggestionProvider::Dynamic.full_record(
3538                "dynamic-0",
3539                Some(json!({
3540                    "suggestion_type": "aaa",
3541                })),
3542                Some(MockAttachment::Json(json!({
3543                    "keywords": ["old keyword"],
3544                }))),
3545            ),
3546        ));
3547        store.ingest(SuggestIngestionConstraints {
3548            providers: Some(vec![SuggestionProvider::Dynamic]),
3549            provider_constraints: Some(SuggestionProviderConstraints {
3550                dynamic_suggestion_types: Some(vec!["aaa".to_string()]),
3551                ..SuggestionProviderConstraints::default()
3552            }),
3553            ..SuggestIngestionConstraints::all_providers()
3554        });
3555
3556        // Add a new record of the same dynamic type.
3557        store
3558            .client_mut()
3559            .add_record(SuggestionProvider::Dynamic.full_record(
3560                "dynamic-1",
3561                Some(json!({
3562                    "suggestion_type": "aaa",
3563                })),
3564                Some(MockAttachment::Json(json!({
3565                    "keywords": ["new keyword"],
3566                }))),
3567            ));
3568
3569        // Ingest, but don't ingest the dynamic type. The store will download
3570        // the new record but shouldn't ingest its attachment.
3571        store.ingest(SuggestIngestionConstraints {
3572            providers: Some(vec![SuggestionProvider::Dynamic]),
3573            provider_constraints: None,
3574            ..SuggestIngestionConstraints::all_providers()
3575        });
3576        assert_eq!(
3577            store.fetch_suggestions(SuggestionQuery::dynamic("new keyword", &["aaa"])),
3578            vec![],
3579        );
3580
3581        // Ingest again with the dynamic type. The new record will be
3582        // unchanged, but the store should now ingest its attachment.
3583        store.ingest(SuggestIngestionConstraints {
3584            providers: Some(vec![SuggestionProvider::Dynamic]),
3585            provider_constraints: Some(SuggestionProviderConstraints {
3586                dynamic_suggestion_types: Some(vec!["aaa".to_string()]),
3587                ..SuggestionProviderConstraints::default()
3588            }),
3589            ..SuggestIngestionConstraints::all_providers()
3590        });
3591
3592        // The keyword in the new attachment should match the suggestion,
3593        // confirming that the new record's attachment was ingested.
3594        assert_eq!(
3595            store.fetch_suggestions(SuggestionQuery::dynamic("new keyword", &["aaa"])),
3596            vec![Suggestion::Dynamic {
3597                suggestion_type: "aaa".to_string(),
3598                data: None,
3599                dismissal_key: None,
3600                score: DEFAULT_SUGGESTION_SCORE,
3601            }]
3602        );
3603
3604        Ok(())
3605    }
3606
3607    #[test]
3608    fn dynamic_dismissal() -> anyhow::Result<()> {
3609        before_each();
3610
3611        let store = TestStore::new(
3612            MockRemoteSettingsClient::default()
3613                .with_record(SuggestionProvider::Dynamic.full_record(
3614                    "dynamic-0",
3615                    Some(json!({
3616                        "suggestion_type": "aaa",
3617                    })),
3618                    Some(MockAttachment::Json(json!([
3619                        {
3620                            "keywords": ["aaa"],
3621                            "dismissal_key": "dk0",
3622                        },
3623                        {
3624                            "keywords": ["aaa"],
3625                            "dismissal_key": "dk1",
3626                        },
3627                        {
3628                            "keywords": ["aaa"],
3629                        },
3630                    ]))),
3631                ))
3632                .with_record(SuggestionProvider::Dynamic.full_record(
3633                    "dynamic-1",
3634                    Some(json!({
3635                        "suggestion_type": "bbb",
3636                    })),
3637                    Some(MockAttachment::Json(json!([
3638                        {
3639                            "keywords": ["bbb"],
3640                            "dismissal_key": "dk0",
3641                        },
3642                    ]))),
3643                )),
3644        );
3645
3646        store.ingest(SuggestIngestionConstraints {
3647            providers: Some(vec![SuggestionProvider::Dynamic]),
3648            provider_constraints: Some(SuggestionProviderConstraints {
3649                dynamic_suggestion_types: Some(vec!["aaa".to_string(), "bbb".to_string()]),
3650                ..SuggestionProviderConstraints::default()
3651            }),
3652            ..SuggestIngestionConstraints::all_providers()
3653        });
3654
3655        // Make sure the suggestions are initially fetchable.
3656        assert!(!store.inner.any_dismissed_suggestions()?);
3657        let suggestions_0: Vec<Suggestion> =
3658            store.fetch_suggestions(SuggestionQuery::dynamic("aaa", &["aaa"]));
3659        let suggestions_1: Vec<Suggestion> =
3660            store.fetch_suggestions(SuggestionQuery::dynamic("bbb", &["bbb"]));
3661        assert_eq!(
3662            suggestions_0,
3663            vec![
3664                Suggestion::Dynamic {
3665                    suggestion_type: "aaa".to_string(),
3666                    data: None,
3667                    dismissal_key: Some("dk0".to_string()),
3668                    score: DEFAULT_SUGGESTION_SCORE,
3669                },
3670                Suggestion::Dynamic {
3671                    suggestion_type: "aaa".to_string(),
3672                    data: None,
3673                    dismissal_key: Some("dk1".to_string()),
3674                    score: DEFAULT_SUGGESTION_SCORE,
3675                },
3676                Suggestion::Dynamic {
3677                    suggestion_type: "aaa".to_string(),
3678                    data: None,
3679                    dismissal_key: None,
3680                    score: DEFAULT_SUGGESTION_SCORE,
3681                },
3682            ],
3683        );
3684
3685        // Dismiss the first suggestion.
3686        assert_eq!(suggestions_0[0].dismissal_key(), Some("dk0"));
3687        store.inner.dismiss_by_suggestion(&suggestions_0[0])?;
3688
3689        assert!(store.inner.any_dismissed_suggestions()?);
3690        assert!(store.inner.is_dismissed_by_suggestion(&suggestions_0[0])?);
3691        assert_eq!(
3692            store.fetch_suggestions(SuggestionQuery::dynamic("aaa", &["aaa"])),
3693            vec![
3694                Suggestion::Dynamic {
3695                    suggestion_type: "aaa".to_string(),
3696                    data: None,
3697                    dismissal_key: Some("dk1".to_string()),
3698                    score: DEFAULT_SUGGESTION_SCORE,
3699                },
3700                Suggestion::Dynamic {
3701                    suggestion_type: "aaa".to_string(),
3702                    data: None,
3703                    dismissal_key: None,
3704                    score: DEFAULT_SUGGESTION_SCORE,
3705                },
3706            ],
3707        );
3708
3709        // Dismiss the second suggestion.
3710        assert_eq!(suggestions_0[1].dismissal_key(), Some("dk1"));
3711        store.inner.dismiss_by_suggestion(&suggestions_0[1])?;
3712
3713        assert!(store.inner.is_dismissed_by_suggestion(&suggestions_0[1])?);
3714        assert_eq!(
3715            store.fetch_suggestions(SuggestionQuery::dynamic("aaa", &["aaa"])),
3716            vec![Suggestion::Dynamic {
3717                suggestion_type: "aaa".to_string(),
3718                data: None,
3719                dismissal_key: None,
3720                score: DEFAULT_SUGGESTION_SCORE,
3721            },],
3722        );
3723
3724        // Make sure the bbb suggestion hasn't been dismissed even though it
3725        // has the same key as the first aaa suggestion.
3726        assert_eq!(
3727            suggestions_1[0].dismissal_key(),
3728            suggestions_0[0].dismissal_key()
3729        );
3730        assert!(!store.inner.is_dismissed_by_suggestion(&suggestions_1[0])?);
3731        assert_eq!(
3732            store.fetch_suggestions(SuggestionQuery::dynamic("bbb", &["bbb"])),
3733            vec![Suggestion::Dynamic {
3734                suggestion_type: "bbb".to_string(),
3735                data: None,
3736                dismissal_key: Some("dk0".to_string()),
3737                score: DEFAULT_SUGGESTION_SCORE,
3738            },],
3739        );
3740
3741        // Clear dismissals. All suggestions should be fetchable again.
3742        store.inner.clear_dismissed_suggestions()?;
3743        assert_eq!(
3744            store.fetch_suggestions(SuggestionQuery::dynamic("aaa", &["aaa"])),
3745            vec![
3746                Suggestion::Dynamic {
3747                    suggestion_type: "aaa".to_string(),
3748                    data: None,
3749                    dismissal_key: Some("dk0".to_string()),
3750                    score: DEFAULT_SUGGESTION_SCORE,
3751                },
3752                Suggestion::Dynamic {
3753                    suggestion_type: "aaa".to_string(),
3754                    data: None,
3755                    dismissal_key: Some("dk1".to_string()),
3756                    score: DEFAULT_SUGGESTION_SCORE,
3757                },
3758                Suggestion::Dynamic {
3759                    suggestion_type: "aaa".to_string(),
3760                    data: None,
3761                    dismissal_key: None,
3762                    score: DEFAULT_SUGGESTION_SCORE,
3763                },
3764            ],
3765        );
3766
3767        Ok(())
3768    }
3769
3770    #[test]
3771    fn record_changes_change_detection() -> anyhow::Result<()> {
3772        let mut rc = RecordChanges::new(std::iter::empty(), std::iter::empty());
3773        assert!(!rc.has_changes(), "No changes");
3774
3775        let record = Record {
3776            id: SuggestRecordId::new("42".to_string()),
3777            last_modified: 0,
3778            attachment: None,
3779            payload: SuggestRecord::Icon,
3780            collection: Collection::Other,
3781        };
3782        rc = RecordChanges::new(std::iter::once(&record), std::iter::empty());
3783        assert!(rc.has_changes(), "Has changes");
3784
3785        Ok(())
3786    }
3787}