1use super::record::{HistoryRecord, HistoryRecordVisit};
6use super::{MAX_OUTGOING_PLACES, MAX_VISITS};
7use crate::api::history::can_add_url;
8use crate::db::PlacesDb;
9use crate::error::*;
10use crate::storage::{
11 delete_pending_temp_tables,
12 history::history_sync::{
13 apply_synced_deletion, apply_synced_reconciliation, apply_synced_visits, fetch_outgoing,
14 fetch_visits, finish_outgoing, FetchedVisit, FetchedVisitPage,
15 },
16};
17use crate::types::{UnknownFields, VisitType};
18use interrupt_support::Interruptee;
19use std::collections::HashSet;
20use std::time::{SystemTime, UNIX_EPOCH};
21use sync15::bso::{IncomingBso, IncomingKind, OutgoingBso};
22use sync15::telemetry;
23use sync_guid::Guid as SyncGuid;
24use types::Timestamp;
25use url::Url;
26
27fn clamp_visit_date(visit_date: Timestamp) -> std::result::Result<Timestamp, ()> {
30 let now = Timestamp::now();
31 if visit_date > now {
32 return Ok(now);
33 }
34 if visit_date < Timestamp::EARLIEST {
35 return Err(());
36 }
37 Ok(visit_date)
38}
39
40#[allow(clippy::large_enum_variant)]
44#[derive(Debug)]
45pub enum IncomingPlan {
46 Skip,
48 Invalid(Error),
50 Failed(Error),
52 Delete,
54 Apply {
56 url: Url,
57 new_title: Option<String>,
58 visits: Vec<HistoryRecordVisit>,
59 unknown_fields: UnknownFields,
60 },
61 Reconciled,
65}
66
67fn plan_incoming_record(conn: &PlacesDb, record: HistoryRecord, max_visits: usize) -> IncomingPlan {
68 let url = match Url::parse(&record.hist_uri) {
69 Ok(u) => u,
70 Err(e) => return IncomingPlan::Invalid(e.into()),
71 };
72
73 if !record.id.is_valid_for_places() {
74 return IncomingPlan::Invalid(InvalidPlaceInfo::InvalidGuid.into());
75 }
76
77 match can_add_url(&url) {
78 Ok(can) => {
79 if !can {
80 return IncomingPlan::Skip;
81 }
82 }
83 Err(e) => return IncomingPlan::Failed(e),
84 }
85 let visit_tuple = match fetch_visits(conn, &url, max_visits) {
87 Ok(v) => v,
88 Err(e) => return IncomingPlan::Failed(e),
89 };
90
91 let (existing_page, existing_visits): (Option<FetchedVisitPage>, Vec<FetchedVisit>) =
97 match visit_tuple {
98 None => (None, Vec::new()),
99 Some((p, v)) => (Some(p), v),
100 };
101
102 let guid_changed = match existing_page {
103 Some(p) => p.guid != record.id,
104 None => false,
105 };
106
107 let mut cur_visit_map: HashSet<(VisitType, Timestamp)> =
108 HashSet::with_capacity(existing_visits.len());
109 for visit in &existing_visits {
110 let transition = match visit.visit_type {
112 Some(t) => t,
113 None => continue,
114 };
115 match clamp_visit_date(visit.visit_date) {
116 Ok(date_use) => {
117 cur_visit_map.insert((transition, date_use));
118 }
119 Err(_) => {
120 warn!("Ignored visit before 1993-01-23");
121 }
122 }
123 }
124 let earliest_allowed: SystemTime = if existing_visits.len() == max_visits {
131 existing_visits[existing_visits.len() - 1].visit_date.into()
132 } else {
133 UNIX_EPOCH
134 };
135
136 let mut to_apply = Vec::with_capacity(record.visits.len());
138 for incoming_visit in record.visits {
139 let transition = match VisitType::from_primitive(incoming_visit.transition) {
140 Some(v) => v,
141 None => continue,
142 };
143 match clamp_visit_date(incoming_visit.date.into()) {
144 Ok(timestamp) => {
145 if earliest_allowed > timestamp.into() {
146 continue;
147 }
148 let key = (transition, timestamp);
150 if !cur_visit_map.contains(&key) {
151 to_apply.push(HistoryRecordVisit {
152 date: timestamp.into(),
153 transition: transition as u8,
154 unknown_fields: incoming_visit.unknown_fields,
155 });
156 cur_visit_map.insert(key);
157 }
158 }
159 Err(()) => {
160 warn!("Ignored visit before 1993-01-23");
161 }
162 }
163 }
164 if guid_changed || !to_apply.is_empty() {
168 let new_title = Some(record.title);
169 IncomingPlan::Apply {
170 url,
171 new_title,
172 visits: to_apply,
173 unknown_fields: record.unknown_fields,
174 }
175 } else {
176 IncomingPlan::Reconciled
177 }
178}
179
180pub fn apply_plan(
181 db: &PlacesDb,
182 inbound: Vec<IncomingBso>,
183 telem: &mut telemetry::EngineIncoming,
184 interruptee: &impl Interruptee,
185) -> Result<()> {
186 let mut plans: Vec<(SyncGuid, IncomingPlan)> = Vec::with_capacity(inbound.len());
188 for incoming in inbound {
189 interruptee.err_if_interrupted()?;
190 let content = incoming.into_content::<HistoryRecord>();
191 let plan = match content.kind {
192 IncomingKind::Tombstone => IncomingPlan::Delete,
193 IncomingKind::Content(record) => plan_incoming_record(db, record, MAX_VISITS),
194 IncomingKind::Malformed => {
195 warn!(
198 "Error deserializing incoming record: {}",
199 content.envelope.id
200 );
201 telem.failed(1);
202 continue;
203 }
204 };
205 plans.push((content.envelope.id.clone(), plan));
206 }
207
208 let mut tx = db.begin_transaction()?;
209
210 for (guid, plan) in plans {
211 interruptee.err_if_interrupted()?;
212 match &plan {
213 IncomingPlan::Skip => {
214 trace!("incoming: skipping item {:?}", guid);
215 }
217 IncomingPlan::Invalid(err) => {
218 warn!(
219 "incoming: record {:?} skipped because it is invalid: {}",
220 guid, err
221 );
222 telem.failed(1);
223 }
224 IncomingPlan::Failed(err) => {
225 error_support::report_error!(
226 "places-failed-to-apply",
227 "incoming: record {:?} failed to apply: {}",
228 guid,
229 err
230 );
231 telem.failed(1);
232 }
233 IncomingPlan::Delete => {
234 trace!("incoming: deleting {:?}", guid);
235 apply_synced_deletion(db, &guid)?;
236 telem.applied(1);
237 }
238 IncomingPlan::Apply {
239 url,
240 new_title,
241 visits,
242 unknown_fields,
243 } => {
244 trace!(
245 "incoming: will apply {guid:?}: url={url:?}, title={new_title:?}, to_add={visits:?}, unknown_fields={unknown_fields:?}"
246 );
247 apply_synced_visits(db, &guid, url, new_title, visits, unknown_fields)?;
248 telem.applied(1);
249 }
250 IncomingPlan::Reconciled => {
251 telem.reconciled(1);
252 trace!("incoming: reconciled {:?}", guid);
253 apply_synced_reconciliation(db, &guid)?;
254 }
255 };
256 if tx.should_commit() {
257 delete_pending_temp_tables(db)?;
261 }
262 tx.maybe_commit()?;
263 }
264 delete_pending_temp_tables(db)?;
267 tx.commit()?;
268 info!("incoming: {}", serde_json::to_string(&telem).unwrap());
269 Ok(())
270}
271
272pub fn get_planned_outgoing(db: &PlacesDb) -> Result<Vec<OutgoingBso>> {
273 let tx = db.begin_transaction()?;
278 let outgoing = fetch_outgoing(db, MAX_OUTGOING_PLACES, MAX_VISITS)?;
279 tx.commit()?;
280 Ok(outgoing)
281}
282
283pub fn finish_plan(db: &PlacesDb) -> Result<()> {
284 let tx = db.begin_transaction()?;
285 finish_outgoing(db)?;
286 trace!("Committing final sync plan");
287 tx.commit()?;
288 Ok(())
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::api::matcher::{search_frecent, SearchParams};
295 use crate::api::places_api::ConnectionType;
296 use crate::db::PlacesDb;
297 use crate::history_sync::ServerVisitTimestamp;
298 use crate::observation::VisitObservation;
299 use crate::storage::history::history_sync::fetch_visits;
300 use crate::storage::history::{apply_observation, delete_visits_for, url_to_guid};
301 use crate::types::SyncStatus;
302 use interrupt_support::NeverInterrupts;
303 use serde_json::json;
304 use sql_support::ConnExt;
305 use std::time::Duration;
306 use sync15::bso::IncomingBso;
307 use types::Timestamp;
308 use url::Url;
309
310 fn get_existing_guid(conn: &PlacesDb, url: &Url) -> SyncGuid {
311 url_to_guid(conn, url)
312 .expect("should have worked")
313 .expect("should have got a value")
314 }
315
316 fn get_tombstone_count(conn: &PlacesDb) -> u32 {
317 let result: Result<Option<u32>> = conn.try_query_row(
318 "SELECT COUNT(*) from moz_places_tombstones;",
319 [],
320 |row| Ok(row.get::<_, u32>(0)?),
321 true,
322 );
323 result
324 .expect("should have worked")
325 .expect("should have got a value")
326 }
327
328 fn get_sync(conn: &PlacesDb, url: &Url) -> (SyncStatus, u32) {
329 let guid_result: Result<Option<(SyncStatus, u32)>> = conn.try_query_row(
330 "SELECT sync_status, sync_change_counter
331 FROM moz_places
332 WHERE url = :url;",
333 &[(":url", &String::from(url.clone()))],
334 |row| {
335 Ok((
336 SyncStatus::from_u8(row.get::<_, u8>(0)?),
337 row.get::<_, u32>(1)?,
338 ))
339 },
340 true,
341 );
342 guid_result
343 .expect("should have worked")
344 .expect("should have got values")
345 }
346
347 fn apply_and_get_outgoing(db: &PlacesDb, incoming: Vec<IncomingBso>) -> Vec<OutgoingBso> {
348 apply_plan(
349 db,
350 incoming,
351 &mut telemetry::EngineIncoming::new(),
352 &NeverInterrupts,
353 )
354 .expect("should apply");
355 get_planned_outgoing(db).expect("should get outgoing")
356 }
357
358 #[test]
359 fn test_invalid_guid() -> Result<()> {
360 error_support::init_for_tests();
361 let conn = PlacesDb::open_in_memory(ConnectionType::Sync)?;
362 let record = HistoryRecord {
363 id: "foo".into(),
364 title: "title".into(),
365 hist_uri: "http://example.com".into(),
366 visits: vec![],
367 unknown_fields: UnknownFields::new(),
368 };
369
370 assert!(matches!(
371 plan_incoming_record(&conn, record, 10),
372 IncomingPlan::Invalid(_)
373 ));
374 Ok(())
375 }
376
377 #[test]
378 fn test_invalid_url() -> Result<()> {
379 error_support::init_for_tests();
380 let conn = PlacesDb::open_in_memory(ConnectionType::Sync)?;
381 let record = HistoryRecord {
382 id: "aaaaaaaaaaaa".into(),
383 title: "title".into(),
384 hist_uri: "invalid".into(),
385 visits: vec![],
386 unknown_fields: UnknownFields::new(),
387 };
388
389 assert!(matches!(
390 plan_incoming_record(&conn, record, 10),
391 IncomingPlan::Invalid(_)
392 ));
393 Ok(())
394 }
395
396 #[test]
397 fn test_new() -> Result<()> {
398 error_support::init_for_tests();
399 let conn = PlacesDb::open_in_memory(ConnectionType::Sync)?;
400 let visits = vec![HistoryRecordVisit {
401 date: SystemTime::now().into(),
402 transition: 1,
403 unknown_fields: UnknownFields::new(),
404 }];
405 let record = HistoryRecord {
406 id: "aaaaaaaaaaaa".into(),
407 title: "title".into(),
408 hist_uri: "https://example.com".into(),
409 visits,
410 unknown_fields: UnknownFields::new(),
411 };
412
413 assert!(matches!(
414 plan_incoming_record(&conn, record, 10),
415 IncomingPlan::Apply { .. }
416 ));
417 Ok(())
418 }
419
420 #[test]
421 fn test_plan_dupe_visit_same_guid() {
422 error_support::init_for_tests();
423 let conn = PlacesDb::open_in_memory(ConnectionType::Sync).expect("no memory db");
424 let now = SystemTime::now();
425 let url = Url::parse("https://example.com").expect("is valid");
426 let obs = VisitObservation::new(url.clone())
428 .with_visit_type(VisitType::Link)
429 .with_at(Some(now.into()));
430 apply_observation(&conn, obs).expect("should apply");
431 assert_eq!(get_sync(&conn, &url), (SyncStatus::New, 1));
433
434 let guid = get_existing_guid(&conn, &url);
435
436 let visits = vec![HistoryRecordVisit {
438 date: now.into(),
439 transition: 1,
440 unknown_fields: UnknownFields::new(),
441 }];
442 let record = HistoryRecord {
443 id: guid,
444 title: "title".into(),
445 hist_uri: "https://example.com".into(),
446 visits,
447 unknown_fields: UnknownFields::new(),
448 };
449 assert!(matches!(
451 plan_incoming_record(&conn, record, 10),
452 IncomingPlan::Reconciled
453 ));
454 }
455
456 #[test]
457 fn test_plan_dupe_visit_different_guid_no_visits() {
458 error_support::init_for_tests();
459 let conn = PlacesDb::open_in_memory(ConnectionType::Sync).expect("no memory db");
460 let now = SystemTime::now();
461 let url = Url::parse("https://example.com").expect("is valid");
462 let obs = VisitObservation::new(url.clone())
464 .with_visit_type(VisitType::Link)
465 .with_at(Some(now.into()));
466 apply_observation(&conn, obs).expect("should apply");
467
468 assert_eq!(get_sync(&conn, &url), (SyncStatus::New, 1));
469
470 let record = HistoryRecord {
472 id: SyncGuid::random(),
473 title: "title".into(),
474 hist_uri: "https://example.com".into(),
475 visits: vec![],
476 unknown_fields: UnknownFields::new(),
477 };
478 assert!(matches!(
481 plan_incoming_record(&conn, record, 10),
482 IncomingPlan::Apply { .. }
483 ));
484 }
485
486 #[test]
489 fn test_apply_dupe_no_local_visits() -> Result<()> {
490 error_support::init_for_tests();
494 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
495 let guid1 = SyncGuid::random();
496 let ts1: Timestamp = (SystemTime::now() - Duration::new(5, 0)).into();
497
498 let guid2 = SyncGuid::random();
499 let ts2: Timestamp = SystemTime::now().into();
500 let url = Url::parse("https://example.com")?;
501
502 let incoming = vec![
504 IncomingBso::from_test_content(json!({
505 "id": guid1,
506 "title": "title",
507 "histUri": url.as_str(),
508 "visits": [ {"date": ServerVisitTimestamp::from(ts1), "type": 1}]
509 })),
510 IncomingBso::from_test_content(json!({
511 "id": guid2,
512 "title": "title",
513 "histUri": url.as_str(),
514 "visits": [ {"date": ServerVisitTimestamp::from(ts2), "type": 1}]
515 })),
516 IncomingBso::from_test_content(json!({
517 "id": guid2,
518 "title": "title2",
519 "histUri": url.as_str(),
520 "visits": [ {"date": ServerVisitTimestamp::from(ts2), "type": 1}]
521 })),
522 ];
523
524 let outgoing = apply_and_get_outgoing(&db, incoming);
525 assert_eq!(
526 outgoing.len(),
527 1,
528 "should have guid1 as outgoing with both visits."
529 );
530 assert_eq!(outgoing[0].envelope.id, guid1);
531
532 let (page, visits) = fetch_visits(&db, &url, 3)?.expect("page exists");
534 assert_eq!(
535 page.guid, guid1,
536 "page should have the guid from the first record"
537 );
538 assert_eq!(
539 page.title, "title2",
540 "page should have the title from the second record"
541 );
542 assert_eq!(visits.len(), 2, "page should have 2 visits");
543
544 Ok(())
545 }
546
547 #[test]
548 fn test_apply_dupe_local_unsynced_visits() -> Result<()> {
549 error_support::init_for_tests();
555 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
556
557 let guid1 = SyncGuid::random();
558 let ts1: Timestamp = (SystemTime::now() - Duration::new(5, 0)).into();
559
560 let guid2 = SyncGuid::random();
561 let ts2: Timestamp = SystemTime::now().into();
562 let url = Url::parse("https://example.com")?;
563
564 let ts_local: Timestamp = (SystemTime::now() - Duration::new(10, 0)).into();
565 let obs = VisitObservation::new(url.clone())
566 .with_visit_type(VisitType::Link)
567 .with_at(Some(ts_local));
568 apply_observation(&db, obs)?;
569
570 let incoming = vec![
572 IncomingBso::from_test_content(json!({
573 "id": guid1,
574 "title": "title",
575 "histUri": url.as_str(),
576 "visits": [ {"date": ServerVisitTimestamp::from(ts1), "type": 1}]
577 })),
578 IncomingBso::from_test_content(json!({
579 "id": guid2,
580 "title": "title",
581 "histUri": url.as_str(),
582 "visits": [ {"date": ServerVisitTimestamp::from(ts2), "type": 1}]
583 })),
584 ];
585
586 let outgoing = apply_and_get_outgoing(&db, incoming);
587 assert_eq!(outgoing.len(), 1, "should have guid1 as outgoing");
588 assert_eq!(outgoing[0].envelope.id, guid1);
589
590 let (page, visits) = fetch_visits(&db, &url, 3)?.expect("page exists");
592 assert_eq!(page.guid, guid1, "should have the expected guid");
593 assert_eq!(visits.len(), 3, "should have all visits");
594
595 Ok(())
596 }
597
598 #[test]
599 fn test_apply_dupe_local_synced_visits() -> Result<()> {
600 error_support::init_for_tests();
606 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
607
608 let guid1 = SyncGuid::random();
609 let ts1: Timestamp = (SystemTime::now() - Duration::new(5, 0)).into();
610
611 let guid2 = SyncGuid::random();
612 let ts2: Timestamp = SystemTime::now().into();
613 let url = Url::parse("https://example.com")?;
614
615 let ts_local: Timestamp = (SystemTime::now() - Duration::new(10, 0)).into();
616 let obs = VisitObservation::new(url.clone())
617 .with_visit_type(VisitType::Link)
618 .with_at(Some(ts_local));
619 apply_observation(&db, obs)?;
620
621 let incoming = vec![
623 IncomingBso::from_test_content(json!({
624 "id": guid1,
625 "title": "title",
626 "histUri": url.as_str(),
627 "visits": [ {"date": ServerVisitTimestamp::from(ts1), "type": 1}]
628 })),
629 IncomingBso::from_test_content(json!({
630 "id": guid2,
631 "title": "title",
632 "histUri": url.as_str(),
633 "sortindex": 0,
634 "ttl": 100,
635 "visits": [ {"date": ServerVisitTimestamp::from(ts2), "type": 1}]
636 })),
637 ];
638
639 let outgoing = apply_and_get_outgoing(&db, incoming);
640 assert_eq!(
641 outgoing.len(),
642 1,
643 "should have guid1 as outgoing with both visits."
644 );
645
646 let (page, visits) = fetch_visits(&db, &url, 3)?.expect("page exists");
648 assert_eq!(page.guid, guid1, "should have the expected guid");
649 assert_eq!(visits.len(), 3, "should have all visits");
650
651 Ok(())
652 }
653
654 #[test]
655 fn test_apply_plan_incoming_invalid_timestamp() -> Result<()> {
656 error_support::init_for_tests();
657 let json = json!({
658 "id": "aaaaaaaaaaaa",
659 "title": "title",
660 "histUri": "http://example.com",
661 "visits": [ {"date": 15_423_493_234_840_000_000u64, "type": 1}]
662 });
663 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
664 let outgoing = apply_and_get_outgoing(&db, vec![IncomingBso::from_test_content(json)]);
665 assert_eq!(outgoing.len(), 0, "nothing outgoing");
666
667 let now: Timestamp = SystemTime::now().into();
668 let (_page, visits) =
669 fetch_visits(&db, &Url::parse("http://example.com").unwrap(), 2)?.expect("page exists");
670 assert_eq!(visits.len(), 1);
671 assert!(
672 visits[0].visit_date <= now,
673 "should have clamped the timestamp"
674 );
675 Ok(())
676 }
677
678 #[test]
679 fn test_apply_plan_incoming_invalid_negative_timestamp() -> Result<()> {
680 error_support::init_for_tests();
681 let json = json!({
682 "id": "aaaaaaaaaaaa",
683 "title": "title",
684 "histUri": "http://example.com",
685 "visits": [ {"date": -123, "type": 1}]
686 });
687 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
688 let outgoing = apply_and_get_outgoing(&db, vec![IncomingBso::from_test_content(json)]);
689 assert_eq!(outgoing.len(), 0, "should skip the invalid entry");
690 Ok(())
691 }
692
693 #[test]
694 fn test_apply_plan_incoming_invalid_visit_type() -> Result<()> {
695 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
696 let visits = vec![HistoryRecordVisit {
697 date: SystemTime::now().into(),
698 transition: 99,
699 unknown_fields: UnknownFields::new(),
700 }];
701 let record = HistoryRecord {
702 id: "aaaaaaaaaaaa".into(),
703 title: "title".into(),
704 hist_uri: "http://example.com".into(),
705 visits,
706 unknown_fields: UnknownFields::new(),
707 };
708 let plan = plan_incoming_record(&db, record, 10);
709 assert!(matches!(plan, IncomingPlan::Reconciled));
712 Ok(())
713 }
714
715 #[test]
716 fn test_apply_plan_incoming_new() -> Result<()> {
717 error_support::init_for_tests();
718 let now: Timestamp = SystemTime::now().into();
719 let json = json!({
720 "id": "aaaaaaaaaaaa",
721 "title": "title",
722 "histUri": "http://example.com",
723 "visits": [ {"date": ServerVisitTimestamp::from(now), "type": 1}]
724 });
725 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
726 let outgoing = apply_and_get_outgoing(&db, vec![IncomingBso::from_test_content(json)]);
727
728 let (page, visits) =
730 fetch_visits(&db, &Url::parse("http://example.com").unwrap(), 2)?.expect("page exists");
731 assert_eq!(page.title, "title");
732 assert_eq!(visits.len(), 1);
733 let visit = visits.into_iter().next().unwrap();
734 assert_eq!(visit.visit_date, now);
735
736 let found = search_frecent(
740 &db,
741 SearchParams {
742 search_string: "http://example.com".into(),
743 limit: 2,
744 },
745 )?;
746 assert_eq!(found.len(), 1);
747 let result = found.into_iter().next().unwrap();
748 assert!(result.frecency > 0, "should have frecency");
749
750 assert_eq!(outgoing.len(), 0);
752 Ok(())
753 }
754
755 #[test]
756 fn test_apply_plan_outgoing_new() -> Result<()> {
757 error_support::init_for_tests();
758 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
759 let url = Url::parse("https://example.com")?;
760 let now = SystemTime::now();
761 let obs = VisitObservation::new(url)
762 .with_visit_type(VisitType::Link)
763 .with_at(Some(now.into()));
764 apply_observation(&db, obs)?;
765
766 let outgoing = apply_and_get_outgoing(&db, vec![]);
767
768 assert_eq!(outgoing.len(), 1);
769 Ok(())
770 }
771
772 #[test]
773 fn test_simple_visit_reconciliation() -> Result<()> {
774 error_support::init_for_tests();
775 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
776 let ts: Timestamp = (SystemTime::now() - Duration::new(5, 0)).into();
777 let url = Url::parse("https://example.com")?;
778
779 let obs = VisitObservation::new(url.clone())
781 .with_visit_type(VisitType::Link)
782 .with_at(Some(ts));
783 apply_observation(&db, obs)?;
784 assert_eq!(get_sync(&db, &url), (SyncStatus::New, 1));
786
787 let guid = get_existing_guid(&db, &url);
788
789 let json = json!({
791 "id": guid,
792 "title": "title",
793 "histUri": url.as_str(),
794 "visits": [ {"date": ServerVisitTimestamp::from(ts), "type": 1}]
795 });
796
797 apply_and_get_outgoing(&db, vec![IncomingBso::from_test_content(json)]);
798
799 let (_page, visits) = fetch_visits(&db, &url, 2)?.expect("page exists");
801 assert_eq!(visits.len(), 1);
802 assert!(visits[0].is_local);
803 assert_eq!(get_sync(&db, &url), (SyncStatus::Normal, 0));
805 Ok(())
806 }
807
808 #[test]
809 fn test_simple_visit_incoming_and_outgoing() -> Result<()> {
810 error_support::init_for_tests();
811 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
812 let ts1: Timestamp = (SystemTime::now() - Duration::new(5, 0)).into();
813 let ts2: Timestamp = SystemTime::now().into();
814 let url = Url::parse("https://example.com")?;
815
816 let obs = VisitObservation::new(url.clone())
818 .with_visit_type(VisitType::Link)
819 .with_at(Some(ts1));
820 apply_observation(&db, obs)?;
821
822 let guid = get_existing_guid(&db, &url);
823
824 let json = json!({
826 "id": guid,
827 "title": "title",
828 "histUri": url.as_str(),
829 "visits": [ {"date": ServerVisitTimestamp::from(ts2), "type": 1}]
830 });
831
832 let outgoing = apply_and_get_outgoing(&db, vec![IncomingBso::from_test_content(json)]);
833
834 let (_page, visits) = fetch_visits(&db, &url, 3)?.expect("page exists");
836 assert_eq!(visits.len(), 2);
837
838 assert_eq!(outgoing.len(), 1);
840 let record = outgoing[0].to_test_incoming_t::<HistoryRecord>();
841 assert_eq!(record.id, guid);
842 assert_eq!(record.visits.len(), 2, "should have both visits outgoing");
843 assert_eq!(
844 record.visits[0].date,
845 ts2.into(),
846 "most recent timestamp should be first"
847 );
848 assert_eq!(
849 record.visits[1].date,
850 ts1.into(),
851 "both timestamps should appear"
852 );
853 Ok(())
854 }
855
856 #[test]
857 fn test_incoming_tombstone_local_new() -> Result<()> {
858 error_support::init_for_tests();
859 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
860 let url = Url::parse("https://example.com")?;
861 let obs = VisitObservation::new(url.clone())
862 .with_visit_type(VisitType::Link)
863 .with_at(Some(SystemTime::now().into()));
864 apply_observation(&db, obs)?;
865 assert_eq!(get_sync(&db, &url), (SyncStatus::New, 1));
866
867 let guid = get_existing_guid(&db, &url);
868
869 let json = json!({
871 "id": guid,
872 "deleted": true,
873 });
874 let outgoing = apply_and_get_outgoing(&db, vec![IncomingBso::from_test_content(json)]);
875 assert_eq!(outgoing.len(), 0, "should be nothing outgoing");
876 assert_eq!(get_tombstone_count(&db), 0, "should be no tombstones");
877 Ok(())
878 }
879
880 #[test]
881 fn test_incoming_tombstone_local_normal() -> Result<()> {
882 error_support::init_for_tests();
883 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
884 let url = Url::parse("https://example.com")?;
885 let obs = VisitObservation::new(url.clone())
886 .with_visit_type(VisitType::Link)
887 .with_at(Some(SystemTime::now().into()));
888 apply_observation(&db, obs)?;
889 let guid = get_existing_guid(&db, &url);
890
891 apply_and_get_outgoing(&db, vec![]);
893 assert_eq!(get_sync(&db, &url), (SyncStatus::Normal, 1));
895
896 let json = json!({
898 "id": guid,
899 "deleted": true,
900 });
901
902 let outgoing = apply_and_get_outgoing(&db, vec![IncomingBso::from_test_content(json)]);
903 assert_eq!(outgoing.len(), 0, "should be nothing outgoing");
904 Ok(())
905 }
906
907 #[test]
908 fn test_outgoing_tombstone() -> Result<()> {
909 error_support::init_for_tests();
910 let db = PlacesDb::open_in_memory(ConnectionType::Sync)?;
911 let url = Url::parse("https://example.com")?;
912 let obs = VisitObservation::new(url.clone())
913 .with_visit_type(VisitType::Link)
914 .with_at(Some(SystemTime::now().into()));
915 apply_observation(&db, obs)?;
916 let guid = get_existing_guid(&db, &url);
917
918 apply_and_get_outgoing(&db, vec![]);
920 assert_eq!(get_sync(&db, &url), (SyncStatus::Normal, 1));
922
923 delete_visits_for(&db, &guid)?;
925
926 assert_eq!(get_tombstone_count(&db), 1);
928
929 let outgoing = apply_and_get_outgoing(&db, vec![]);
930 assert_eq!(outgoing.len(), 1, "tombstone should be uploaded");
931 finish_plan(&db)?;
932 assert_eq!(get_tombstone_count(&db), 0);
934
935 Ok(())
936 }
937
938 #[test]
939 fn test_clamp_visit_date() {
940 let ts = Timestamp::from(727_747_199_999);
941 assert!(clamp_visit_date(ts).is_err());
942
943 let ts = Timestamp::now();
944 assert_eq!(clamp_visit_date(ts), Ok(ts));
945 }
946}