places/storage/bookmarks/
fetch.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5use super::super::bookmarks::json_tree::{self, FetchDepth};
6use super::*;
7use rusqlite::Row;
8
9// A helper that will ensure tests fail, but in production will make log noise instead.
10fn noisy_debug_assert_eq<T: std::cmp::PartialEq + std::fmt::Debug>(a: &T, b: &T, msg: &str) {
11    debug_assert_eq!(a, b);
12    if a != b {
13        error_support::report_error!(
14            "places-bookmarks-corruption",
15            "check failed: {}: {:?} != {:?}",
16            msg,
17            a,
18            b
19        );
20    }
21}
22
23fn noisy_debug_assert(v: bool, msg: &str) {
24    debug_assert!(v);
25    if !v {
26        error_support::report_error!(
27            "places-bookmark-corruption",
28            "check failed: {}: expected true, got false",
29            msg
30        );
31    }
32}
33
34/// Structs we return when reading bookmarks
35#[derive(Debug, Clone)]
36pub struct BookmarkData {
37    pub guid: SyncGuid,
38    pub parent_guid: SyncGuid,
39    pub position: u32,
40    pub date_added: Timestamp,
41    pub last_modified: Timestamp,
42    pub url: Url,
43    pub title: Option<String>,
44}
45
46impl From<BookmarkData> for Item {
47    fn from(b: BookmarkData) -> Self {
48        Item::Bookmark { b }
49    }
50}
51
52// Only for tests because we ignore timestamps
53#[cfg(test)]
54impl PartialEq for BookmarkData {
55    fn eq(&self, other: &Self) -> bool {
56        self.guid == other.guid
57            && self.parent_guid == other.parent_guid
58            && self.position == other.position
59            && self.url == other.url
60            && self.title == other.title
61    }
62}
63
64#[derive(Debug, Clone)]
65pub struct Separator {
66    pub guid: SyncGuid,
67    pub date_added: Timestamp,
68    pub last_modified: Timestamp,
69    pub parent_guid: SyncGuid,
70    pub position: u32,
71}
72
73impl From<Separator> for Item {
74    fn from(s: Separator) -> Self {
75        Item::Separator { s }
76    }
77}
78
79#[derive(Debug, Clone, Default)]
80pub struct Folder {
81    pub guid: SyncGuid,
82    pub date_added: Timestamp,
83    pub last_modified: Timestamp,
84    pub parent_guid: Option<SyncGuid>, // Option because the root is a folder but has no parent.
85    // Always 0 if parent_guid is None
86    pub position: u32,
87    pub title: Option<String>,
88    // Depending on the specific API request, either, both, or none of these `child_*` vecs
89    // will be populated.
90    pub child_guids: Option<Vec<SyncGuid>>,
91    pub child_nodes: Option<Vec<Item>>,
92}
93
94impl From<Folder> for Item {
95    fn from(f: Folder) -> Self {
96        Item::Folder { f }
97    }
98}
99
100// The type used to update the actual item.
101#[derive(Debug, Clone)]
102pub enum Item {
103    Bookmark { b: BookmarkData },
104    Separator { s: Separator },
105    Folder { f: Folder },
106}
107
108// We allow all "common" fields from the sub-types to be getters on the
109// InsertableItem type.
110macro_rules! impl_common_bookmark_getter {
111    ($getter_name:ident, $T:ty) => {
112        pub fn $getter_name(&self) -> &$T {
113            match self {
114                Item::Bookmark { b } => &b.$getter_name,
115                Item::Separator { s } => &s.$getter_name,
116                Item::Folder { f } => &f.$getter_name,
117            }
118        }
119    };
120}
121
122impl Item {
123    impl_common_bookmark_getter!(guid, SyncGuid);
124    impl_common_bookmark_getter!(position, u32);
125    impl_common_bookmark_getter!(date_added, Timestamp);
126    impl_common_bookmark_getter!(last_modified, Timestamp);
127    pub fn parent_guid(&self) -> Option<&SyncGuid> {
128        match self {
129            Item::Bookmark { b } => Some(&b.parent_guid),
130            Item::Folder { f } => f.parent_guid.as_ref(),
131            Item::Separator { s } => Some(&s.parent_guid),
132        }
133    }
134}
135
136/// No simple `From` here, because json_tree doesn't give us the parent or position - it
137/// expects us to walk a tree, so we do.
138///
139/// Extra complication for the fact the root has a None parent_guid :)
140fn folder_from_node_with_parent_info(
141    f: json_tree::FolderNode,
142    parent_guid: Option<SyncGuid>,
143    position: u32,
144    depth_left: usize,
145) -> Folder {
146    let guid = f.guid.expect("all items have guids");
147    // We always provide child_guids, and only provide child_nodes if we are
148    // going to keep recursing.
149    let child_guids = Some(
150        f.children
151            .iter()
152            .map(|child| child.guid().clone())
153            .collect(),
154    );
155    let child_nodes = if depth_left != 0 {
156        Some(
157            f.children
158                .into_iter()
159                .enumerate()
160                .map(|(child_pos, child)| {
161                    item_from_node_with_parent_info(
162                        child,
163                        guid.clone(),
164                        child_pos as u32,
165                        depth_left - 1,
166                    )
167                })
168                .collect(),
169        )
170    } else {
171        None
172    };
173    Folder {
174        guid,
175        parent_guid,
176        position,
177        child_nodes,
178        child_guids,
179        title: f.title,
180        date_added: f.date_added.expect("always get dates"),
181        last_modified: f.last_modified.expect("always get dates"),
182    }
183}
184
185fn item_from_node_with_parent_info(
186    n: json_tree::BookmarkTreeNode,
187    parent_guid: SyncGuid,
188    position: u32,
189    depth_left: usize,
190) -> Item {
191    match n {
192        json_tree::BookmarkTreeNode::Bookmark { b } => BookmarkData {
193            guid: b.guid.expect("all items have guids"),
194            parent_guid,
195            position,
196            url: b.url,
197            title: b.title,
198            date_added: b.date_added.expect("always get dates"),
199            last_modified: b.last_modified.expect("always get dates"),
200        }
201        .into(),
202        json_tree::BookmarkTreeNode::Separator { s } => Separator {
203            guid: s.guid.expect("all items have guids"),
204            parent_guid,
205            position,
206            date_added: s.date_added.expect("always get dates"),
207            last_modified: s.last_modified.expect("always get dates"),
208        }
209        .into(),
210        json_tree::BookmarkTreeNode::Folder { f } => {
211            folder_from_node_with_parent_info(f, Some(parent_guid), position, depth_left).into()
212        }
213    }
214}
215
216/// Call fetch_tree_with_depth with FetchDepth::Deepest.
217/// This is the function called by the FFI when requesting the tree.
218pub fn fetch_tree(db: &PlacesDb, item_guid: &SyncGuid) -> Result<Option<Item>> {
219    fetch_tree_with_depth(db, item_guid, &FetchDepth::Deepest)
220}
221
222/// Call fetch_tree with a depth parameter and convert the result
223/// to an Item.
224pub fn fetch_tree_with_depth(
225    db: &PlacesDb,
226    item_guid: &SyncGuid,
227    target_depth: &FetchDepth,
228) -> Result<Option<Item>> {
229    let (tree, parent_guid, position) = if let Some((tree, parent_guid, position)) =
230        json_tree::fetch_tree(db, item_guid, target_depth)?
231    {
232        (tree, parent_guid, position)
233    } else {
234        return Ok(None);
235    };
236    // parent_guid being an Option<> is a bit if a pain :(
237    Ok(Some(match tree {
238        json_tree::BookmarkTreeNode::Folder { f } => {
239            noisy_debug_assert(
240                parent_guid.is_none() ^ (f.guid.as_ref() != Some(BookmarkRootGuid::Root.guid())),
241                "only root has no parent",
242            );
243            let depth_left = match target_depth {
244                FetchDepth::Specific(v) => *v,
245                FetchDepth::Deepest => usize::MAX,
246            };
247            folder_from_node_with_parent_info(f, parent_guid, position, depth_left).into()
248        }
249        _ => item_from_node_with_parent_info(
250            tree,
251            parent_guid.expect("must have parent"),
252            position,
253            0,
254        ),
255    }))
256}
257
258pub fn fetch_bookmarks_by_url(db: &PlacesDb, url: &Url) -> Result<Vec<BookmarkData>> {
259    let nodes = crate::storage::bookmarks::get_raw_bookmarks_for_url(db, url)?
260        .into_iter()
261        .map(|rb| {
262            // Cause tests to fail, but we'd rather not panic here
263            // for real.
264            noisy_debug_assert_eq(&rb.child_count, &0, "child count should be zero");
265            noisy_debug_assert_eq(
266                &rb.bookmark_type,
267                &BookmarkType::Bookmark,
268                "not a bookmark!",
269            );
270            // We don't log URLs so we do the comparison here.
271            noisy_debug_assert(rb.url.as_ref() == Some(url), "urls don't match");
272            noisy_debug_assert(rb.parent_guid.is_some(), "no parent guid");
273            BookmarkData {
274                guid: rb.guid,
275                parent_guid: rb
276                    .parent_guid
277                    .unwrap_or_else(|| BookmarkRootGuid::Unfiled.into()),
278                position: rb.position,
279                date_added: rb.date_added,
280                last_modified: rb.date_modified,
281                url: url.clone(),
282                title: rb.title,
283            }
284        })
285        .collect::<Vec<_>>();
286    Ok(nodes)
287}
288
289/// This is similar to fetch_tree, but does not recursively fetch children of
290/// folders.
291///
292/// If `get_direct_children` is true, it will return 1 level of folder children,
293/// otherwise it returns just their guids.
294pub fn fetch_bookmark(
295    db: &PlacesDb,
296    item_guid: &SyncGuid,
297    get_direct_children: bool,
298) -> Result<Option<Item>> {
299    let depth = if get_direct_children {
300        FetchDepth::Specific(1)
301    } else {
302        FetchDepth::Specific(0)
303    };
304    fetch_tree_with_depth(db, item_guid, &depth)
305}
306
307fn bookmark_from_row(row: &Row<'_>) -> Result<Option<BookmarkData>> {
308    Ok(
309        match row
310            .get::<_, Option<String>>("url")?
311            .and_then(|href| url::Url::parse(&href).ok())
312        {
313            Some(url) => Some(BookmarkData {
314                guid: row.get("guid")?,
315                parent_guid: row.get("parentGuid")?,
316                position: row.get("position")?,
317                date_added: row.get("dateAdded")?,
318                last_modified: row.get("lastModified")?,
319                title: row.get("title")?,
320                url,
321            }),
322            None => None,
323        },
324    )
325}
326
327pub fn search_bookmarks(db: &PlacesDb, search: &str, limit: u32) -> Result<Vec<BookmarkData>> {
328    let scope = db.begin_interrupt_scope()?;
329    Ok(db
330        .query_rows_into_cached::<Vec<Option<BookmarkData>>, _, _, _, _>(
331            &SEARCH_QUERY,
332            &[
333                (":search", &search as &dyn rusqlite::ToSql),
334                (":limit", &limit),
335            ],
336            |row| -> Result<_> {
337                scope.err_if_interrupted()?;
338                bookmark_from_row(row)
339            },
340        )?
341        .into_iter()
342        .flatten()
343        .collect())
344}
345
346pub fn recent_bookmarks(db: &PlacesDb, limit: u32) -> Result<Vec<BookmarkData>> {
347    let scope = db.begin_interrupt_scope()?;
348    Ok(db
349        .query_rows_into_cached::<Vec<Option<BookmarkData>>, _, _, _, _>(
350            &RECENT_BOOKMARKS_QUERY,
351            &[(":limit", &limit as &dyn rusqlite::ToSql)],
352            |row| -> Result<_> {
353                scope.err_if_interrupted()?;
354                bookmark_from_row(row)
355            },
356        )?
357        .into_iter()
358        .flatten()
359        .collect())
360}
361
362lazy_static::lazy_static! {
363    pub static ref SEARCH_QUERY: String = format!(
364        "SELECT
365            b.guid,
366            p.guid AS parentGuid,
367            b.position,
368            b.dateAdded,
369            b.lastModified,
370            -- Note we return null for titles with an empty string.
371            NULLIF(b.title, '') AS title,
372            h.url AS url
373        FROM moz_bookmarks b
374        JOIN moz_bookmarks p ON p.id = b.parent
375        JOIN moz_places h ON h.id = b.fk
376        WHERE b.type = {bookmark_type}
377            AND AUTOCOMPLETE_MATCH(
378                :search, h.url, IFNULL(b.title, h.title),
379                NULL, -- tags
380                -- We could pass the versions of these from history in,
381                -- but they're just used to figure out whether or not
382                -- the query fits the given behavior, and we know
383                -- we're only passing in and looking for bookmarks,
384                -- so using the args from history would be pointless
385                -- and would make things slower.
386                0, -- visit_count
387                0, -- typed
388                1, -- bookmarked
389                NULL, -- open page count
390                {match_bhvr},
391                {search_bhvr}
392            )
393        LIMIT :limit",
394        bookmark_type = BookmarkType::Bookmark as u8,
395        match_bhvr = crate::match_impl::MatchBehavior::Anywhere as u32,
396        search_bhvr = crate::match_impl::SearchBehavior::BOOKMARK.bits(),
397    );
398
399    pub static ref RECENT_BOOKMARKS_QUERY: String = format!(
400        "SELECT
401            b.guid,
402            p.guid AS parentGuid,
403            b.position,
404            b.dateAdded,
405            b.lastModified,
406            NULLIF(b.title, '') AS title,
407            h.url AS url
408        FROM moz_bookmarks b
409        JOIN moz_bookmarks p ON p.id = b.parent
410        JOIN moz_places h ON h.id = b.fk
411        WHERE b.type = {bookmark_type}
412        ORDER BY b.dateAdded DESC
413        LIMIT :limit",
414        bookmark_type = BookmarkType::Bookmark as u8
415    );
416}
417
418#[cfg(test)]
419mod test {
420    use super::*;
421    use crate::api::places_api::test::new_mem_connections;
422    use crate::tests::{append_invalid_bookmark, insert_json_tree};
423    use serde_json::json;
424    #[test]
425    fn test_get_by_url() -> Result<()> {
426        let conns = new_mem_connections();
427        insert_json_tree(
428            &conns.write,
429            json!({
430                "guid": String::from(BookmarkRootGuid::Unfiled.as_str()),
431                "children": [
432                    {
433                        "guid": "bookmark1___",
434                        "url": "https://www.example1.com/",
435                        "title": "no 1",
436                    },
437                    {
438                        "guid": "bookmark2___",
439                        "url": "https://www.example2.com/a/b/c/d?q=v#abcde",
440                        "title": "yes 1",
441                    },
442                    {
443                        "guid": "bookmark3___",
444                        "url": "https://www.example2.com/a/b/c/d",
445                        "title": "no 2",
446                    },
447                    {
448                        "guid": "bookmark4___",
449                        "url": "https://www.example2.com/a/b/c/d?q=v#abcde",
450                        "title": "yes 2",
451                    },
452                ]
453            }),
454        );
455        let url = url::Url::parse("https://www.example2.com/a/b/c/d?q=v#abcde")?;
456        let mut bmks = fetch_bookmarks_by_url(&conns.read, &url)?;
457        bmks.sort_by_key(|b| b.guid.as_str().to_string());
458        assert_eq!(bmks.len(), 2);
459        assert_eq!(
460            bmks[0],
461            BookmarkData {
462                guid: "bookmark2___".into(),
463                title: Some("yes 1".into()),
464                url: url.clone(),
465                parent_guid: BookmarkRootGuid::Unfiled.into(),
466                position: 1,
467                // Ignored by our PartialEq
468                date_added: Timestamp(0),
469                last_modified: Timestamp(0),
470            }
471        );
472        assert_eq!(
473            bmks[1],
474            BookmarkData {
475                guid: "bookmark4___".into(),
476                title: Some("yes 2".into()),
477                url,
478                parent_guid: BookmarkRootGuid::Unfiled.into(),
479                position: 3,
480                // Ignored by our PartialEq
481                date_added: Timestamp(0),
482                last_modified: Timestamp(0),
483            }
484        );
485
486        let no_url = url::Url::parse("https://no.bookmark.com")?;
487        assert!(fetch_bookmarks_by_url(&conns.read, &no_url)?.is_empty());
488
489        Ok(())
490    }
491    #[test]
492    fn test_search() -> Result<()> {
493        let conns = new_mem_connections();
494        insert_json_tree(
495            &conns.write,
496            json!({
497                "guid": String::from(BookmarkRootGuid::Unfiled.as_str()),
498                "children": [
499                    {
500                        "guid": "bookmark1___",
501                        "url": "https://www.example1.com/",
502                        "title": "",
503                    },
504                    {
505                        "guid": "bookmark2___",
506                        "url": "https://www.example2.com/a/b/c/d?q=v#example",
507                        "title": "",
508                    },
509                    {
510                        "guid": "bookmark3___",
511                        "url": "https://www.example2.com/a/b/c/d",
512                        "title": "",
513                    },
514                    {
515                        "guid": "bookmark4___",
516                        "url": "https://www.doesnt_match.com/a/b/c/d",
517                        "title": "",
518                    },
519                    {
520                        "guid": "bookmark5___",
521                        "url": "https://www.example2.com/a/b/",
522                        "title": "a b c d",
523                    },
524                    {
525                        "guid": "bookmark6___",
526                        "url": "https://www.example2.com/a/b/c/d",
527                        "title": "foo bar baz",
528                    },
529                    {
530                        "guid": "bookmark7___",
531                        "url": "https://www.1234.com/a/b/c/d",
532                        "title": "my example bookmark",
533                    },
534                ]
535            }),
536        );
537        append_invalid_bookmark(
538            &conns.write,
539            BookmarkRootGuid::Unfiled.guid(),
540            "invalid",
541            "badurl",
542        );
543        let mut bmks = search_bookmarks(&conns.read, "ample", 10)?;
544        bmks.sort_by_key(|b| b.guid.as_str().to_string());
545        assert_eq!(bmks.len(), 6);
546        let expect = [
547            ("bookmark1___", "https://www.example1.com/", "", 0),
548            (
549                "bookmark2___",
550                "https://www.example2.com/a/b/c/d?q=v#example",
551                "",
552                1,
553            ),
554            ("bookmark3___", "https://www.example2.com/a/b/c/d", "", 2),
555            (
556                "bookmark5___",
557                "https://www.example2.com/a/b/",
558                "a b c d",
559                4,
560            ),
561            (
562                "bookmark6___",
563                "https://www.example2.com/a/b/c/d",
564                "foo bar baz",
565                5,
566            ),
567            (
568                "bookmark7___",
569                "https://www.1234.com/a/b/c/d",
570                "my example bookmark",
571                6,
572            ),
573        ];
574        for (got, want) in bmks.iter().zip(expect.iter()) {
575            assert_eq!(got.guid.as_str(), want.0);
576            assert_eq!(got.url, url::Url::parse(want.1).unwrap());
577            assert_eq!(got.title.as_ref().unwrap_or(&String::new()), want.2);
578            assert_eq!(got.position, want.3);
579            assert_eq!(got.parent_guid, BookmarkRootGuid::Unfiled);
580        }
581        Ok(())
582    }
583    #[test]
584    fn test_fetch_bookmark() -> Result<()> {
585        let conns = new_mem_connections();
586
587        insert_json_tree(
588            &conns.write,
589            json!({
590                "guid": BookmarkRootGuid::Mobile.as_guid(),
591                "children": [
592                    {
593                        "guid": "bookmark1___",
594                        "url": "https://www.example1.com/"
595                    },
596                    {
597                        "guid": "bookmark2___",
598                        "url": "https://www.example2.com/"
599                    },
600                ]
601            }),
602        );
603
604        // Put a couple of invalid items in the tree - not only should fetching
605        // them directly "work" (as in, not crash!), fetching their parent's
606        // tree should also do a sane thing (ie, not crash *and* return the
607        // valid items)
608        let guid_bad = append_invalid_bookmark(
609            &conns.write,
610            BookmarkRootGuid::Mobile.guid(),
611            "invalid url",
612            "badurl",
613        )
614        .guid;
615        assert!(fetch_bookmark(&conns.read, &guid_bad, false)?.is_none());
616
617        // Now fetch the entire tree.
618        let root = match fetch_bookmark(&conns.read, BookmarkRootGuid::Root.guid(), false)?.unwrap()
619        {
620            Item::Folder { f } => f,
621            _ => panic!("root not a folder?"),
622        };
623        assert!(root.child_guids.is_some());
624        assert!(root.child_nodes.is_none());
625        assert_eq!(root.child_guids.unwrap().len(), 4);
626
627        let root = match fetch_bookmark(&conns.read, BookmarkRootGuid::Root.guid(), true)?.unwrap()
628        {
629            Item::Folder { f } => f,
630            _ => panic!("not a folder?"),
631        };
632
633        assert!(root.child_nodes.is_some());
634        assert!(root.child_guids.is_some());
635        assert_eq!(
636            root.child_guids.unwrap(),
637            root.child_nodes
638                .as_ref()
639                .unwrap()
640                .iter()
641                .map(|c| c.guid().clone())
642                .collect::<Vec<SyncGuid>>()
643        );
644        let root_children = root.child_nodes.unwrap();
645        assert_eq!(root_children.len(), 4);
646        for child in root_children {
647            match child {
648                Item::Folder { f: child } => {
649                    assert!(child.child_guids.is_some());
650                    assert!(child.child_nodes.is_none());
651                    if child.guid == BookmarkRootGuid::Mobile {
652                        assert_eq!(
653                            child.child_guids.unwrap(),
654                            &[
655                                SyncGuid::from("bookmark1___"),
656                                SyncGuid::from("bookmark2___")
657                            ]
658                        );
659                    }
660                }
661                _ => panic!("all root children should be folders"),
662            }
663        }
664
665        let unfiled =
666            match fetch_bookmark(&conns.read, BookmarkRootGuid::Unfiled.guid(), false)?.unwrap() {
667                Item::Folder { f } => f,
668                _ => panic!("not a folder?"),
669            };
670
671        assert!(unfiled.child_guids.is_some());
672        assert!(unfiled.child_nodes.is_none());
673        assert_eq!(unfiled.child_guids.unwrap().len(), 0);
674
675        let unfiled =
676            match fetch_bookmark(&conns.read, BookmarkRootGuid::Unfiled.guid(), true)?.unwrap() {
677                Item::Folder { f } => f,
678                _ => panic!("not a folder?"),
679            };
680        assert!(unfiled.child_guids.is_some());
681        assert!(unfiled.child_nodes.is_some());
682
683        assert_eq!(unfiled.child_nodes.unwrap().len(), 0);
684        assert_eq!(unfiled.child_guids.unwrap().len(), 0);
685
686        assert!(fetch_bookmark(&conns.read, &"not_exist___".into(), true)?.is_none());
687        Ok(())
688    }
689    #[test]
690    fn test_fetch_tree() -> Result<()> {
691        let conns = new_mem_connections();
692
693        insert_json_tree(
694            &conns.write,
695            json!({
696                "guid": BookmarkRootGuid::Mobile.as_guid(),
697                "children": [
698                    {
699                        "guid": "bookmark1___",
700                        "url": "https://www.example1.com/"
701                    },
702                    {
703                        "guid": "bookmark2___",
704                        "url": "https://www.example2.com/"
705                    },
706                ]
707            }),
708        );
709
710        append_invalid_bookmark(
711            &conns.write,
712            BookmarkRootGuid::Mobile.guid(),
713            "invalid url",
714            "badurl",
715        );
716
717        let root = match fetch_tree(&conns.read, BookmarkRootGuid::Root.guid())?.unwrap() {
718            Item::Folder { f } => f,
719            _ => panic!("root not a folder?"),
720        };
721        assert!(root.parent_guid.is_none());
722        assert_eq!(root.position, 0);
723
724        assert!(root.child_guids.is_some());
725        let children = root.child_nodes.as_ref().unwrap();
726        assert_eq!(
727            root.child_guids.unwrap(),
728            children
729                .iter()
730                .map(|c| c.guid().clone())
731                .collect::<Vec<SyncGuid>>()
732        );
733        let mut mobile_pos = None;
734        for (i, c) in children.iter().enumerate() {
735            assert_eq!(i as u32, *c.position());
736            assert_eq!(c.parent_guid().unwrap(), &root.guid);
737            match c {
738                Item::Folder { f } => {
739                    // all out roots are here, so check it is mobile.
740                    if f.guid == BookmarkRootGuid::Mobile {
741                        assert!(f.child_guids.is_some());
742                        assert!(f.child_nodes.is_some());
743                        let child_nodes = f.child_nodes.as_ref().unwrap();
744                        assert_eq!(
745                            f.child_guids.as_ref().unwrap(),
746                            &child_nodes
747                                .iter()
748                                .map(|c| c.guid().clone())
749                                .collect::<Vec<SyncGuid>>()
750                        );
751                        mobile_pos = Some(i as u32);
752                        let b = match &child_nodes[0] {
753                            Item::Bookmark { b } => b,
754                            _ => panic!("expect a bookmark"),
755                        };
756                        assert_eq!(b.position, 0);
757                        assert_eq!(b.guid, SyncGuid::from("bookmark1___"));
758                        assert_eq!(b.url, Url::parse("https://www.example1.com/").unwrap());
759
760                        let b = match &child_nodes[1] {
761                            Item::Bookmark { b } => b,
762                            _ => panic!("expect a bookmark"),
763                        };
764                        assert_eq!(b.position, 1);
765                        assert_eq!(b.guid, SyncGuid::from("bookmark2___"));
766                        assert_eq!(b.url, Url::parse("https://www.example2.com/").unwrap());
767                    }
768                }
769                _ => panic!("unexpected type"),
770            }
771        }
772        // parent_guid/position for the directly returned node is filled in separately,
773        // so make sure it works for non-root nodes too.
774        let mobile = match fetch_tree(&conns.read, BookmarkRootGuid::Mobile.guid())?.unwrap() {
775            Item::Folder { f } => f,
776            _ => panic!("not a folder?"),
777        };
778        assert_eq!(mobile.parent_guid.unwrap(), BookmarkRootGuid::Root);
779        assert_eq!(mobile.position, mobile_pos.unwrap());
780
781        let bm1 = match fetch_tree(&conns.read, &SyncGuid::from("bookmark1___"))?.unwrap() {
782            Item::Bookmark { b } => b,
783            _ => panic!("not a bookmark?"),
784        };
785        assert_eq!(bm1.parent_guid, BookmarkRootGuid::Mobile);
786        assert_eq!(bm1.position, 0);
787
788        Ok(())
789    }
790    #[test]
791    fn test_recent() -> Result<()> {
792        let conns = new_mem_connections();
793        let kids = [
794            json!({
795                "guid": "bookmark1___",
796                "url": "https://www.example1.com/",
797                "title": "b1",
798            }),
799            json!({
800                "guid": "bookmark2___",
801                "url": "https://www.example2.com/",
802                "title": "b2",
803            }),
804            json!({
805                "guid": "bookmark3___",
806                "url": "https://www.example3.com/",
807                "title": "b3",
808            }),
809            json!({
810                "guid": "bookmark4___",
811                "url": "https://www.example4.com/",
812                "title": "b4",
813            }),
814            // should be ignored.
815            json!({
816                "guid": "folder1_____",
817                "title": "A folder",
818                "children": []
819            }),
820            json!({
821                "guid": "bookmark5___",
822                "url": "https://www.example5.com/",
823                "title": "b5",
824            }),
825        ];
826        for k in &kids {
827            insert_json_tree(
828                &conns.write,
829                json!({
830                    "guid": String::from(BookmarkRootGuid::Unfiled.as_str()),
831                    "children": [k.clone()],
832                }),
833            );
834            std::thread::sleep(std::time::Duration::from_millis(10));
835        }
836
837        append_invalid_bookmark(
838            &conns.write,
839            BookmarkRootGuid::Unfiled.guid(),
840            "invalid url",
841            "badurl",
842        );
843
844        // The limit applies before we filter the invalid bookmark, so ask for 4.
845        let bmks = recent_bookmarks(&conns.read, 4)?;
846        assert_eq!(bmks.len(), 3);
847
848        assert_eq!(
849            bmks[0],
850            BookmarkData {
851                guid: "bookmark5___".into(),
852                title: Some("b5".into()),
853                url: Url::parse("https://www.example5.com/").unwrap(),
854                parent_guid: BookmarkRootGuid::Unfiled.into(),
855                position: 5,
856                // Ignored by our PartialEq
857                date_added: Timestamp(0),
858                last_modified: Timestamp(0),
859            }
860        );
861        assert_eq!(
862            bmks[1],
863            BookmarkData {
864                guid: "bookmark4___".into(),
865                title: Some("b4".into()),
866                url: Url::parse("https://www.example4.com/").unwrap(),
867                parent_guid: BookmarkRootGuid::Unfiled.into(),
868                position: 3,
869                // Ignored by our PartialEq
870                date_added: Timestamp(0),
871                last_modified: Timestamp(0),
872            }
873        );
874        assert_eq!(
875            bmks[2],
876            BookmarkData {
877                guid: "bookmark3___".into(),
878                title: Some("b3".into()),
879                url: Url::parse("https://www.example3.com/").unwrap(),
880                parent_guid: BookmarkRootGuid::Unfiled.into(),
881                position: 2,
882                // Ignored by our PartialEq
883                date_added: Timestamp(0),
884                last_modified: Timestamp(0),
885            }
886        );
887        Ok(())
888    }
889}