1use 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#[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 pub fn cache_path(self: Arc<Self>, _path: String) -> Arc<Self> {
69 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, uniffi::Enum)]
133pub enum InterruptKind {
134 Read,
136 Write,
139 ReadWrite,
141}
142
143#[derive(uniffi::Object)]
172pub struct SuggestStore {
173 inner: SuggestStoreInner<SuggestRemoteSettingsClient>,
174}
175
176#[uniffi::export]
177impl SuggestStore {
178 #[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 #[handle_error(Error)]
189 pub fn query(&self, query: SuggestionQuery) -> SuggestApiResult<Vec<Suggestion>> {
190 Ok(self.inner.query(query)?.suggestions)
191 }
192
193 #[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 #[handle_error(Error)]
206 pub fn dismiss_by_suggestion(&self, suggestion: &Suggestion) -> SuggestApiResult<()> {
207 self.inner.dismiss_by_suggestion(suggestion)
208 }
209
210 #[handle_error(Error)]
218 pub fn dismiss_by_key(&self, key: &str) -> SuggestApiResult<()> {
219 self.inner.dismiss_by_key(key)
220 }
221
222 #[handle_error(Error)]
229 pub fn dismiss_suggestion(&self, suggestion_url: String) -> SuggestApiResult<()> {
230 self.inner.dismiss_suggestion(suggestion_url)
231 }
232
233 #[handle_error(Error)]
235 pub fn clear_dismissed_suggestions(&self) -> SuggestApiResult<()> {
236 self.inner.clear_dismissed_suggestions()
237 }
238
239 #[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 #[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 #[handle_error(Error)]
262 pub fn any_dismissed_suggestions(&self) -> SuggestApiResult<bool> {
263 self.inner.any_dismissed_suggestions()
264 }
265
266 #[uniffi::method(default(kind = None))]
272 pub fn interrupt(&self, kind: Option<InterruptKind>) {
273 self.inner.interrupt(kind)
274 }
275
276 #[handle_error(Error)]
278 pub fn ingest(
279 &self,
280 constraints: SuggestIngestionConstraints,
281 ) -> SuggestApiResult<SuggestIngestionMetrics> {
282 self.inner.ingest(constraints)
283 }
284
285 #[handle_error(Error)]
287 pub fn clear(&self) -> SuggestApiResult<()> {
288 self.inner.clear()
289 }
290
291 #[handle_error(Error)]
293 pub fn fetch_global_config(&self) -> SuggestApiResult<SuggestGlobalConfig> {
294 self.inner.fetch_global_config()
295 }
296
297 #[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 #[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 #[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 pub fn checkpoint(&self) {
344 self.inner.checkpoint();
345 }
346}
347
348#[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 #[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
402pub(crate) struct SuggestStoreInner<S> {
406 #[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 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 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 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 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 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 let mut write_scope = writer.write_scope()?;
626
627 let ingested_records = write_scope.read(|dao| dao.get_ingested_records())?;
629
630 let mut has_changes = false;
632
633 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 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 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 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 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 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
845struct 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 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 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 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
980struct SuggestStoreDbs {
982 writer: SuggestDb,
984 reader: SuggestDb,
986}
987
988impl SuggestStoreDbs {
989 fn open(path: &Path, extensions_to_load: &[Sqlite3Extension]) -> Result<Self> {
990 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 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 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 #[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 #[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 #[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 assert!(store.read(|dao| dao.suggestions_table_empty())?);
1139 store.ingest(SuggestIngestionConstraints {
1141 empty_only: true,
1142 ..SuggestIngestionConstraints::all_providers()
1143 });
1144 assert!(!store.read(|dao| dao.suggestions_table_empty())?);
1146
1147 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 assert_eq!(store.fetch_suggestions(SuggestionQuery::amp("la")), vec![]);
1160
1161 Ok(())
1162 }
1163
1164 #[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 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 los_pollos_amp().merge(json!({
1202 "keywords": ["lo", "los", "los p", "los pollos", "los pollos h", "los pollos hermanos"],
1203 "full_keywords": [
1204 ("los pollos", 4),
1206 ("los pollos hermanos (restaurant)", 2),
1208 ],
1209 })),
1210 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 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 let suggestions = store.fetch_suggestions(SuggestionQuery::amp(query));
1254 assert_eq!(suggestions, vec![expected_suggestion.clone()]);
1255
1256 assert_eq!(suggestions[0].dismissal_key(), expected_dismissal_key);
1258
1259 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 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 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 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 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 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 california_wiki(),
1328 ]),
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 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 .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 ..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 ..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 .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 ..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 ..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 #[test]
1470 fn ingest_one_suggestion_in_data_attachment() -> anyhow::Result<()> {
1471 before_each();
1472
1473 let store = TestStore::new(
1474 MockRemoteSettingsClient::default()
1475 .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 #[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 store.ingest(SuggestIngestionConstraints::all_providers());
1501 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 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 store.ingest(SuggestIngestionConstraints::amp_without_fts());
1552 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 ..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 #[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 store.ingest(SuggestIngestionConstraints::all_providers());
1592
1593 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 #[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 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 #[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 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 #[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 #[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 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 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 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 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 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 #[test]
2271 fn query_with_multiple_providers_and_diff_scores() -> anyhow::Result<()> {
2272 before_each();
2273
2274 let store = TestStore::new(
2275 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 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 #[test]
2333 fn ingest_malformed() -> anyhow::Result<()> {
2334 before_each();
2335
2336 let store = TestStore::new(
2337 MockRemoteSettingsClient::default()
2338 .with_record(SuggestionProvider::Amp.empty_record("data-1"))
2340 .with_record(SuggestionProvider::Wikipedia.empty_record("wikipedia-1"))
2342 .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 .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 #[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 assert_eq!(
2405 store.fetch_suggestions(SuggestionQuery::amp("lo")),
2406 vec![los_pollos_suggestion("los pollos", None)]
2407 );
2408 assert_eq!(
2410 store.fetch_suggestions(SuggestionQuery::yelp("best ramen")),
2411 vec![]
2412 );
2413
2414 Ok(())
2415 }
2416
2417 #[test]
2419 fn skip_over_invalid_records() -> anyhow::Result<()> {
2420 before_each();
2421
2422 let store = TestStore::new(
2423 MockRemoteSettingsClient::default()
2424 .with_record(
2426 SuggestionProvider::Amp.record("data-1", json!([good_place_eats_amp()])),
2427 )
2428 .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 assert_eq!(
2450 store.fetch_suggestions(SuggestionQuery::amp("la")),
2451 vec![good_place_eats_suggestion("lasagna", None)]
2452 );
2453 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 assert_eq!(
2470 store.fetch_suggestions(SuggestionQuery::mdn("array")),
2471 vec![array_suggestion(),]
2472 );
2473 assert_eq!(
2475 store.fetch_suggestions(SuggestionQuery::mdn("array java")),
2476 vec![array_suggestion(),]
2477 );
2478 assert_eq!(
2480 store.fetch_suggestions(SuggestionQuery::mdn("javascript array")),
2481 vec![array_suggestion(),]
2482 );
2483 assert_eq!(
2485 store.fetch_suggestions(SuggestionQuery::mdn("wild")),
2486 vec![]
2487 );
2488 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()])), ));
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 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 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 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 assert_eq!(store.fetch_suggestions(query.clone()), vec![]);
2649
2650 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 .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 .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 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 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 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 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 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 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 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 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 let mut store = TestStore::new(
3053 MockRemoteSettingsClient::default()
3054 .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 .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 .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 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 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 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 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 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 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 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 for query in record_0_queries {
3249 assert_eq!(
3250 store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3251 vec![],
3252 );
3253 }
3254
3255 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 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 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 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 for query in record_0_queries {
3354 assert_eq!(
3355 store.fetch_suggestions(SuggestionQuery::dynamic(query, &["aaa"])),
3356 vec![],
3357 );
3358 }
3359
3360 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}