diff --git a/app/pubspec.yaml b/app/pubspec.yaml
index 38843a1b..e44b3343 100644
--- a/app/pubspec.yaml
+++ b/app/pubspec.yaml
@@ -105,6 +105,7 @@ dev_dependencies:
go_router_builder: ^4.1.3
json_serializable: ^6.11.2
lint: ^2.8.0
+ mockito: ^5.4.4
riverpod_generator: ^4.0.0+1
riverpod_lint: ^3.1.0
diff --git a/app/test/features/geckoview/features/bookmarks/utils/bookmark_html_utils_test.dart b/app/test/features/geckoview/features/bookmarks/utils/bookmark_html_utils_test.dart
new file mode 100644
index 00000000..25413471
--- /dev/null
+++ b/app/test/features/geckoview/features/bookmarks/utils/bookmark_html_utils_test.dart
@@ -0,0 +1,731 @@
+/*
+ * Copyright (c) 2024-2025 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'dart:io';
+
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
+
+@GenerateMocks([GeckoBookmarksService])
+import 'bookmark_html_utils_test.mocks.dart';
+
+void main() {
+ late MockGeckoBookmarksService mockService;
+ late BookmarkHTMLUtils utils;
+
+ setUp(() {
+ mockService = MockGeckoBookmarksService();
+ utils = BookmarkHTMLUtils(mockService);
+ });
+
+ group('BookmarkHTMLUtils - Import', () {
+ test('should handle corrupt HTML file with malformed URIs', () async {
+ // Load the corrupt fixture
+ final fixtureFile =
+ File('test/utils/bookmarks/fixtures/bookmarks.corrupt.html');
+ final htmlString = await fixtureFile.readAsString();
+
+ // Mock the service calls
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+ when(mockService.addFolder(any, any, any))
+ .thenAnswer((_) async => 'generated_guid');
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'generated_guid');
+
+ final count = await utils.importFromHTML(htmlString, replace: true);
+
+ // Should import valid bookmarks and skip the corrupt one
+ expect(count, greaterThan(0));
+ verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
+ });
+
+ test('should import from valid HTML file', () async {
+ final fixtureFile =
+ File('test/utils/bookmarks/fixtures/bookmarks.preplaces.html');
+ final htmlString = await fixtureFile.readAsString();
+
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+ when(mockService.addFolder(any, any, any))
+ .thenAnswer((_) async => 'folder_guid');
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'bookmark_guid');
+
+ final count = await utils.importFromHTML(htmlString, replace: true);
+
+ expect(count, greaterThan(0));
+ verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
+ // Verify some bookmarks were added
+ verify(mockService.addItem(any, any, any, any)).called(greaterThan(0));
+ });
+
+ test('should handle empty HTML', () async {
+ const emptyHtml = '''
+
+
Bookmarks
+ Bookmarks
+
+
+ ''';
+
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+
+ final count = await utils.importFromHTML(emptyHtml, replace: true);
+
+ expect(count, equals(0));
+ verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
+ });
+
+ test('should not erase when replace is false', () async {
+ const simpleHtml = '''
+
+ Bookmarks
+ Bookmarks
+
+
- Example
+
+ ''';
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'guid');
+
+ await utils.importFromHTML(simpleHtml);
+
+ verifyNever(mockService.eraseEverything(any));
+ });
+
+ test('should handle bookmarks with special characters in title', () async {
+ const htmlWithSpecialChars = '''
+
+ Bookmarks
+ Bookmarks
+
+
- <unescaped="test">
+
+ ''';
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'guid');
+
+ final count = await utils.importFromHTML(htmlWithSpecialChars);
+
+ expect(count, equals(1));
+ final captured = verify(
+ mockService.addItem(any, any, captureAny, any),
+ ).captured;
+ // Should properly decode HTML entities
+ expect(captured[0], equals(''));
+ });
+
+ test('should import bookmarks with timestamps', () async {
+ const htmlWithDates = '''
+
+ Bookmarks
+ Bookmarks
+
+
- Test
+
+ ''';
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'guid');
+
+ final count = await utils.importFromHTML(htmlWithDates);
+
+ expect(count, equals(1));
+ });
+
+ test('should handle folder hierarchy', () async {
+ const htmlWithFolders = '''
+
+ Bookmarks
+ Bookmarks
+
+
Parent Folder
+
+
- Child 1
+
Nested Folder
+
+
- Grandchild
+
+
+
+ ''';
+
+ when(mockService.addFolder(any, any, any))
+ .thenAnswer((_) async => 'folder_guid');
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'bookmark_guid');
+
+ final count = await utils.importFromHTML(htmlWithFolders);
+
+ expect(count, equals(2)); // 2 bookmarks
+ verify(mockService.addFolder(any, any, any)).called(2); // 2 folders
+ });
+
+ test('should recognize toolbar folder', () async {
+ const htmlWithToolbar = '''
+
+ Bookmarks
+ Bookmarks
+
+
Bookmarks Toolbar
+
+
- Toolbar Bookmark
+
+
+ ''';
+
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'guid');
+
+ await utils.importFromHTML(htmlWithToolbar, replace: true);
+
+ // When replace is true, should add to toolbar
+ final captured = verify(
+ mockService.addItem(captureAny, any, any, any),
+ ).captured;
+ expect(captured[0], equals(BookmarkRoot.toolbar.id));
+ });
+
+ test('should recognize unfiled folder', () async {
+ const htmlWithUnfiled = '''
+
+ Bookmarks
+ Bookmarks
+
+
Unsorted Bookmarks
+
+
- Unfiled Bookmark
+
+
+ ''';
+
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'guid');
+
+ await utils.importFromHTML(htmlWithUnfiled, replace: true);
+
+ final captured = verify(
+ mockService.addItem(captureAny, any, any, any),
+ ).captured;
+ expect(captured[0], equals(BookmarkRoot.unfiled.id));
+ });
+
+ test('should handle separators', () async {
+ const htmlWithSeparator = '''
+
+ Bookmarks
+ Bookmarks
+
+
- Bookmark 1
+
+ - Bookmark 2
+
+ ''';
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'guid');
+
+ final count = await utils.importFromHTML(htmlWithSeparator);
+
+ // Should import 2 bookmarks (separator is not supported by Android API)
+ expect(count, equals(2));
+ });
+
+ test('should skip bookmarks without URLs', () async {
+ const htmlWithoutUrl = '''
+
+ Bookmarks
+ Bookmarks
+
+
- No URL
+
- Valid
+
+ ''';
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'guid');
+
+ final count = await utils.importFromHTML(htmlWithoutUrl);
+
+ expect(count, equals(1)); // Only the valid one
+ });
+
+ test('should skip bookmarks with invalid URLs', () async {
+ const htmlWithInvalidUrl = '''
+
+ Bookmarks
+ Bookmarks
+
+
- Invalid
+
- Valid
+
+ ''';
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'guid');
+
+ final count = await utils.importFromHTML(htmlWithInvalidUrl);
+
+ expect(count, equals(1));
+ });
+
+ test('should handle single frame HTML', () async {
+ final fixtureFile =
+ File('test/utils/bookmarks/fixtures/bookmarks_html_singleframe.html');
+ final htmlString = await fixtureFile.readAsString();
+
+ when(mockService.addFolder(any, any, any))
+ .thenAnswer((_) async => 'folder_guid');
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'bookmark_guid');
+
+ final count = await utils.importFromHTML(htmlString);
+
+ expect(count, greaterThan(0));
+ });
+ });
+
+ group('BookmarkHTMLUtils - Export', () {
+ test('should export bookmark tree to HTML', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.menu.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks Menu',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 0,
+ title: 'Test Bookmark',
+ url: 'https://example.com',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.menu);
+
+ expect(html, contains(''));
+ expect(html, contains('Bookmarks Menu
'));
+ expect(html, contains('https://example.com'));
+ expect(html, contains('Test Bookmark'));
+ });
+
+ test('should escape HTML entities in export', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.menu.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks',
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 0,
+ title: '',
+ url: 'https://example.com',
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.menu);
+
+ // Should escape special characters
+ expect(html, contains('<unescaped="test">'));
+ expect(html, isNot(contains('')));
+ });
+
+ test('should include date attributes in export', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.menu.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 0,
+ title: 'Test',
+ url: 'https://example.com',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.menu);
+
+ expect(html, contains('ADD_DATE='));
+ expect(html, contains('LAST_MODIFIED='));
+ });
+
+ test('should export toolbar with title as H1 when root', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.toolbar.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks Toolbar',
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.folder,
+ children: [],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.toolbar);
+
+ // When toolbar is the root, it becomes H1 without special attributes
+ expect(html, contains('Bookmarks Toolbar
'));
+ expect(html, isNot(contains('PERSONAL_TOOLBAR_FOLDER')));
+ });
+
+ test('should export unfiled with title as H1 when root', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.unfiled.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Unsorted Bookmarks',
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.folder,
+ children: [],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.unfiled);
+
+ // When unfiled is the root, it becomes H1 without special attributes
+ expect(html, contains('Unsorted Bookmarks
'));
+ expect(html, isNot(contains('UNFILED_BOOKMARKS_FOLDER')));
+ });
+
+ test('should export separators', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.menu.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks',
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 0,
+ title: 'First',
+ url: 'https://example.com/1',
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.item,
+ ),
+ BookmarkNode(
+ guid: 'separator___',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 1,
+ title: null,
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.separator,
+ ),
+ BookmarkNode(
+ guid: 'bookmark2___',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 2,
+ title: 'Second',
+ url: 'https://example.com/2',
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.menu);
+
+ expect(html, contains('
'));
+ });
+
+ test('should skip bookmarks with invalid URLs during export', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.menu.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks',
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'invalid1____',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 0,
+ title: 'Invalid',
+ url: '', // Empty URL
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.item,
+ ),
+ BookmarkNode(
+ guid: 'valid1______',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 1,
+ title: 'Valid',
+ url: 'https://example.com',
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.menu);
+
+ // Should only contain the valid bookmark
+ expect(html, contains('https://example.com'));
+ expect(html, contains('Valid'));
+ expect(html, isNot(contains('Invalid')));
+ });
+
+ test('should export nested folders', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.menu.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks',
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'folder1_____',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 0,
+ title: 'Parent Folder',
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: 'folder1_____',
+ position: 0,
+ title: 'Nested Bookmark',
+ url: 'https://example.com',
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.menu);
+
+ expect(html, contains('Parent Folder'));
+ expect(html, contains('Nested Bookmark'));
+ expect(html, contains(''));
+ });
+
+ test('should throw when tree cannot be fetched', () async {
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => null);
+
+ expect(
+ () => utils.exportToHTML(root: BookmarkRoot.menu),
+ throwsA(isA()),
+ );
+ });
+
+ test('should include proper HTML header', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.menu.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks',
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.folder,
+ children: [],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.menu);
+
+ expect(html, contains(''));
+ expect(html, contains('Bookmarks'));
+ expect(html, contains('Content-Security-Policy'));
+ });
+
+ test('should properly indent HTML structure', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.menu.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks',
+ url: null,
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 0,
+ title: 'Test',
+ url: 'https://example.com',
+ dateAdded: 0,
+ lastModified: 0,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final html = await utils.exportToHTML(root: BookmarkRoot.menu);
+
+ // Should contain indentation
+ expect(html, contains(' '));
+ expect(html, contains(''));
+ expect(html, contains('
'));
+ });
+ });
+
+ group('BookmarkHTMLUtils - Import/Export Round-Trip', () {
+ test('should preserve data through export and re-import', () async {
+ final mockNode = BookmarkNode(
+ guid: BookmarkRoot.menu.id,
+ parentGuid: BookmarkRoot.root.id,
+ position: 0,
+ title: 'Bookmarks Menu',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'folder1_____',
+ parentGuid: BookmarkRoot.menu.id,
+ position: 0,
+ title: 'Test Folder',
+ url: null,
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: 'folder1_____',
+ position: 0,
+ title: 'Test Bookmark',
+ url: 'https://example.com',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ // Export
+ final html = await utils.exportToHTML(root: BookmarkRoot.menu);
+ expect(html, isNotEmpty);
+
+ // Re-import
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+ when(mockService.addFolder(any, any, any))
+ .thenAnswer((_) async => 'folder1_____');
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'bookmark1___');
+
+ final count = await utils.importFromHTML(html, replace: true);
+
+ expect(count, equals(1)); // One bookmark imported
+ verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
+ });
+ });
+}
diff --git a/app/test/features/geckoview/features/bookmarks/utils/bookmark_html_utils_test.mocks.dart b/app/test/features/geckoview/features/bookmarks/utils/bookmark_html_utils_test.mocks.dart
new file mode 100644
index 00000000..6112b7dc
--- /dev/null
+++ b/app/test/features/geckoview/features/bookmarks/utils/bookmark_html_utils_test.mocks.dart
@@ -0,0 +1,158 @@
+// Mocks generated by Mockito 5.4.6 from annotations
+// in weblibre/test/features/geckoview/features/bookmarks/utils/bookmark_html_utils_test.dart.
+// Do not manually edit this file.
+
+// ignore_for_file: no_leading_underscores_for_library_prefixes
+import 'dart:async' as _i3;
+
+import 'package:flutter_mozilla_components/src/domain/services/gecko_bookmarks.dart'
+ as _i2;
+import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i4;
+import 'package:mockito/mockito.dart' as _i1;
+import 'package:mockito/src/dummies.dart' as _i5;
+
+// ignore_for_file: type=lint
+// ignore_for_file: avoid_redundant_argument_values
+// ignore_for_file: avoid_setters_without_getters
+// ignore_for_file: comment_references
+// ignore_for_file: deprecated_member_use
+// ignore_for_file: deprecated_member_use_from_same_package
+// ignore_for_file: implementation_imports
+// ignore_for_file: invalid_use_of_visible_for_testing_member
+// ignore_for_file: must_be_immutable
+// ignore_for_file: prefer_const_constructors
+// ignore_for_file: unnecessary_parenthesis
+// ignore_for_file: camel_case_types
+// ignore_for_file: subtype_of_sealed_class
+// ignore_for_file: invalid_use_of_internal_member
+
+/// A class which mocks [GeckoBookmarksService].
+///
+/// See the documentation for Mockito's code generation for more information.
+class MockGeckoBookmarksService extends _i1.Mock
+ implements _i2.GeckoBookmarksService {
+ MockGeckoBookmarksService() {
+ _i1.throwOnMissingStub(this);
+ }
+
+ @override
+ _i3.Future<_i4.BookmarkNode?> getTree(
+ String? guid, {
+ bool? recursive = false,
+ }) =>
+ (super.noSuchMethod(
+ Invocation.method(#getTree, [guid], {#recursive: recursive}),
+ returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
+ )
+ as _i3.Future<_i4.BookmarkNode?>);
+
+ @override
+ _i3.Future<_i4.BookmarkNode?> getBookmark(String? guid) =>
+ (super.noSuchMethod(
+ Invocation.method(#getBookmark, [guid]),
+ returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
+ )
+ as _i3.Future<_i4.BookmarkNode?>);
+
+ @override
+ _i3.Future> getBookmarksWithUrl(Uri? url) =>
+ (super.noSuchMethod(
+ Invocation.method(#getBookmarksWithUrl, [url]),
+ returnValue: _i3.Future>.value(
+ <_i4.BookmarkNode>[],
+ ),
+ )
+ as _i3.Future>);
+
+ @override
+ _i3.Future> getRecentBookmarks(
+ int? limit, {
+ Duration? maxAge = Duration.zero,
+ DateTime? currentTime,
+ }) =>
+ (super.noSuchMethod(
+ Invocation.method(
+ #getRecentBookmarks,
+ [limit],
+ {#maxAge: maxAge, #currentTime: currentTime},
+ ),
+ returnValue: _i3.Future>.value(
+ <_i4.BookmarkNode>[],
+ ),
+ )
+ as _i3.Future>);
+
+ @override
+ _i3.Future> searchBookmarks(
+ String? query, {
+ int? limit = 10,
+ }) =>
+ (super.noSuchMethod(
+ Invocation.method(#searchBookmarks, [query], {#limit: limit}),
+ returnValue: _i3.Future>.value(
+ <_i4.BookmarkNode>[],
+ ),
+ )
+ as _i3.Future>);
+
+ @override
+ _i3.Future addItem(
+ String? parentGuid,
+ Uri? url,
+ String? title,
+ int? position,
+ ) =>
+ (super.noSuchMethod(
+ Invocation.method(#addItem, [parentGuid, url, title, position]),
+ returnValue: _i3.Future.value(
+ _i5.dummyValue(
+ this,
+ Invocation.method(#addItem, [parentGuid, url, title, position]),
+ ),
+ ),
+ )
+ as _i3.Future);
+
+ @override
+ _i3.Future addFolder(
+ String? parentGuid,
+ String? title,
+ int? position,
+ ) =>
+ (super.noSuchMethod(
+ Invocation.method(#addFolder, [parentGuid, title, position]),
+ returnValue: _i3.Future.value(
+ _i5.dummyValue(
+ this,
+ Invocation.method(#addFolder, [parentGuid, title, position]),
+ ),
+ ),
+ )
+ as _i3.Future);
+
+ @override
+ _i3.Future updateNode(String? guid, _i4.BookmarkInfo? info) =>
+ (super.noSuchMethod(
+ Invocation.method(#updateNode, [guid, info]),
+ returnValue: _i3.Future.value(),
+ returnValueForMissingStub: _i3.Future.value(),
+ )
+ as _i3.Future);
+
+ @override
+ _i3.Future deleteNode(String? guid) =>
+ (super.noSuchMethod(
+ Invocation.method(#deleteNode, [guid]),
+ returnValue: _i3.Future.value(false),
+ )
+ as _i3.Future);
+
+ @override
+ _i3.Future eraseEverything(_i2.BookmarkRoot? root) =>
+ (super.noSuchMethod(
+ Invocation.method(#eraseEverything, [root]),
+ returnValue: _i3.Future.value(),
+ returnValueForMissingStub: _i3.Future.value(),
+ )
+ as _i3.Future);
+}
diff --git a/app/test/features/geckoview/features/bookmarks/utils/bookmark_json_utils_test.dart b/app/test/features/geckoview/features/bookmarks/utils/bookmark_json_utils_test.dart
new file mode 100644
index 00000000..cc7e950d
--- /dev/null
+++ b/app/test/features/geckoview/features/bookmarks/utils/bookmark_json_utils_test.dart
@@ -0,0 +1,776 @@
+/*
+ * Copyright (c) 2024-2025 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/annotations.dart';
+import 'package:mockito/mockito.dart';
+import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
+
+@GenerateMocks([GeckoBookmarksService])
+import 'bookmark_json_utils_test.mocks.dart';
+
+void main() {
+ late MockGeckoBookmarksService mockService;
+ late BookmarkJSONUtils utils;
+
+ setUp(() {
+ mockService = MockGeckoBookmarksService();
+ utils = BookmarkJSONUtils(mockService);
+ });
+
+ group('BookmarkJSONUtils - Import', () {
+ test('should reject invalid JSON format', () {
+ const invalidJson = '[]';
+
+ expect(
+ () => utils.importFromJSON(invalidJson),
+ throwsA(isA()),
+ );
+ });
+
+ test('should return 0 for empty children', () async {
+ const emptyJson = '{"children": []}';
+
+ final count = await utils.importFromJSON(emptyJson);
+
+ expect(count, equals(0));
+ });
+
+ test('should return 0 when children is null', () async {
+ const noChildrenJson = '{"guid": "root________"}';
+
+ final count = await utils.importFromJSON(noChildrenJson);
+
+ expect(count, equals(0));
+ });
+
+ test('should filter out tags folder during import', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'tags________',
+ 'root': 'tagsFolder',
+ 'type': 'text/x-moz-place-container',
+ 'children': [],
+ },
+ {
+ 'guid': 'menu________',
+ 'root': 'bookmarksMenuFolder',
+ 'type': 'text/x-moz-place-container',
+ 'children': [],
+ },
+ ],
+ };
+
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+
+ final count = await utils.importFromJSON(
+ jsonEncode(jsonData),
+ replace: true,
+ );
+
+ // Only the menu folder should be processed, tags should be filtered
+ expect(count, equals(0)); // No bookmarks, just folders
+ verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
+ });
+
+ test('should erase everything when replace is true', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'menu________',
+ 'type': 'text/x-moz-place-container',
+ 'children': [],
+ },
+ ],
+ };
+
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+
+ await utils.importFromJSON(
+ jsonEncode(jsonData),
+ replace: true,
+ );
+
+ verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
+ });
+
+ test('should not erase when replace is false', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'menu________',
+ 'type': 'text/x-moz-place-container',
+ 'children': [],
+ },
+ ],
+ };
+
+ await utils.importFromJSON(jsonEncode(jsonData));
+
+ verifyNever(mockService.eraseEverything(any));
+ });
+
+ test('should import bookmarks with URI field', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'menu________',
+ 'type': 'text/x-moz-place-container',
+ 'children': [
+ {
+ 'guid': 'bookmark1___',
+ 'title': 'Test Bookmark',
+ 'type': 'text/x-moz-place',
+ 'uri': 'https://example.com',
+ },
+ ],
+ },
+ ],
+ };
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'bookmark1___');
+
+ final count = await utils.importFromJSON(jsonEncode(jsonData));
+
+ expect(count, equals(1));
+ verify(
+ mockService.addItem(
+ 'menu________',
+ Uri.parse('https://example.com'),
+ 'Test Bookmark',
+ 0,
+ ),
+ ).called(1);
+ });
+
+ test('should import bookmarks with URL field', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'menu________',
+ 'type': 'text/x-moz-place-container',
+ 'children': [
+ {
+ 'guid': 'bookmark1___',
+ 'title': 'Test Bookmark',
+ 'type': 'text/x-moz-place',
+ 'url': 'https://example.com',
+ },
+ ],
+ },
+ ],
+ };
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'bookmark1___');
+
+ final count = await utils.importFromJSON(jsonEncode(jsonData));
+
+ expect(count, equals(1));
+ verify(
+ mockService.addItem(
+ 'menu________',
+ Uri.parse('https://example.com'),
+ 'Test Bookmark',
+ 0,
+ ),
+ ).called(1);
+ });
+
+ test('should skip bookmarks with invalid URLs', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'menu________',
+ 'type': 'text/x-moz-place-container',
+ 'children': [
+ {
+ 'guid': 'invalid1____',
+ 'title': 'Invalid URL',
+ 'type': 'text/x-moz-place',
+ 'uri': 'not a valid url',
+ },
+ {
+ 'guid': 'valid1______',
+ 'title': 'Valid URL',
+ 'type': 'text/x-moz-place',
+ 'uri': 'https://example.com',
+ },
+ ],
+ },
+ ],
+ };
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'valid1______');
+
+ final count = await utils.importFromJSON(jsonEncode(jsonData));
+
+ // Only one valid bookmark should be imported
+ expect(count, equals(1));
+ // Note: position is 1 because the invalid bookmark was skipped first
+ verify(
+ mockService.addItem(
+ 'menu________',
+ Uri.parse('https://example.com'),
+ 'Valid URL',
+ 1,
+ ),
+ ).called(1);
+ });
+
+ test('should import nested folders recursively', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'menu________',
+ 'type': 'text/x-moz-place-container',
+ 'children': [
+ {
+ 'guid': 'folder1_____',
+ 'title': 'Folder 1',
+ 'type': 'text/x-moz-place-container',
+ 'children': [
+ {
+ 'guid': 'bookmark1___',
+ 'title': 'Nested Bookmark',
+ 'type': 'text/x-moz-place',
+ 'uri': 'https://example.com',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ when(mockService.addFolder(any, any, any))
+ .thenAnswer((_) async => 'folder1_____');
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'bookmark1___');
+
+ final count = await utils.importFromJSON(jsonEncode(jsonData));
+
+ expect(count, equals(1));
+ verify(mockService.addFolder('menu________', 'Folder 1', 0)).called(1);
+ verify(
+ mockService.addItem(
+ 'folder1_____',
+ Uri.parse('https://example.com'),
+ 'Nested Bookmark',
+ 0,
+ ),
+ ).called(1);
+ });
+
+ test('should handle separators gracefully (skip them)', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'menu________',
+ 'type': 'text/x-moz-place-container',
+ 'children': [
+ {
+ 'guid': 'bookmark1___',
+ 'title': 'First Bookmark',
+ 'type': 'text/x-moz-place',
+ 'uri': 'https://example.com/1',
+ },
+ {
+ 'guid': 'separator___',
+ 'type': 'text/x-moz-place-separator',
+ },
+ {
+ 'guid': 'bookmark2___',
+ 'title': 'Second Bookmark',
+ 'type': 'text/x-moz-place',
+ 'uri': 'https://example.com/2',
+ },
+ ],
+ },
+ ],
+ };
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((invocation) async => 'generated_guid');
+
+ final count = await utils.importFromJSON(jsonEncode(jsonData));
+
+ // Two bookmarks, separator should be skipped
+ expect(count, equals(2));
+ verify(mockService.addItem(any, any, any, any)).called(2);
+ });
+
+ test('should fixup place: queries with folder shortcuts', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'unfiled_____',
+ 'type': 'text/x-moz-place-container',
+ 'id': '5',
+ 'children': [
+ {
+ 'guid': 'folder1_____',
+ 'title': 'Test Folder',
+ 'type': 'text/x-moz-place-container',
+ 'id': '6',
+ 'children': [],
+ },
+ {
+ 'guid': 'shortcut1___',
+ 'title': 'Folder Shortcut',
+ 'type': 'text/x-moz-place',
+ 'uri': 'place:folder=6',
+ },
+ ],
+ },
+ ],
+ };
+
+ when(mockService.addFolder(any, any, any))
+ .thenAnswer((_) async => 'folder1_____');
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'shortcut1___');
+
+ await utils.importFromJSON(jsonEncode(jsonData));
+
+ // Capture the URI argument to verify it was fixed up
+ // Note: position is 1 because the folder was added first at position 0
+ final captured = verify(
+ mockService.addItem('unfiled_____', captureAny, 'Folder Shortcut', 1),
+ ).captured;
+
+ expect((captured[0] as Uri).toString(), contains('parent=folder1_____'));
+ });
+
+ test('should handle invalid folder references in place: queries', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'unfiled_____',
+ 'type': 'text/x-moz-place-container',
+ 'children': [
+ {
+ 'guid': 'shortcut1___',
+ 'title': 'Invalid Folder Shortcut',
+ 'type': 'text/x-moz-place',
+ 'uri': 'place:folder=999999',
+ },
+ ],
+ },
+ ],
+ };
+
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'shortcut1___');
+
+ await utils.importFromJSON(jsonEncode(jsonData));
+
+ final captured = verify(
+ mockService.addItem(
+ 'unfiled_____',
+ captureAny,
+ 'Invalid Folder Shortcut',
+ 0,
+ ),
+ ).captured;
+
+ final url = (captured[0] as Uri).toString();
+ expect(url, contains('invalidOldParentId=999999'));
+ expect(url, contains('excludeItems=1'));
+ });
+
+ test('should count imported bookmarks correctly from fixture', () async {
+ // Load the fixture
+ final fixtureFile = File('test/utils/bookmarks/fixtures/bookmarks.json');
+ final jsonString = await fixtureFile.readAsString();
+
+ // Mock the service calls
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+ when(mockService.addFolder(any, any, any))
+ .thenAnswer((invocation) async => 'generated_guid');
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((invocation) async => 'generated_guid');
+
+ final count = await utils.importFromJSON(jsonString, replace: true);
+
+ // The fixture has several bookmarks - we should count only valid ones
+ expect(count, greaterThan(0));
+ verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
+ });
+
+ test('should handle import errors gracefully', () async {
+ final jsonData = {
+ 'children': [
+ {
+ 'guid': 'menu________',
+ 'type': 'text/x-moz-place-container',
+ 'children': [
+ {
+ 'guid': 'bookmark1___',
+ 'title': 'Test Bookmark',
+ 'type': 'text/x-moz-place',
+ 'uri': 'https://example.com',
+ },
+ ],
+ },
+ ],
+ };
+
+ when(mockService.addItem(any, any, any, any))
+ .thenThrow(Exception('Database error'));
+
+ // Should not throw, but should log and continue
+ final count = await utils.importFromJSON(jsonEncode(jsonData));
+
+ expect(count, equals(0)); // Failed to add
+ });
+ });
+
+ group('BookmarkJSONUtils - Export', () {
+ test('should export bookmark tree to JSON', () async {
+ final mockNode = BookmarkNode(
+ guid: 'menu________',
+ parentGuid: 'root________',
+ position: 0,
+ title: 'Bookmarks Menu',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: 'menu________',
+ position: 0,
+ title: 'Test Bookmark',
+ url: 'https://example.com',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final result = await utils.exportToJson(root: BookmarkRoot.menu);
+
+ expect(result, isNotNull);
+ expect(result!['guid'], equals('menu________'));
+ expect(result['title'], equals('Bookmarks Menu'));
+ expect(result['type'], equals('text/x-moz-place-container'));
+ expect(result['root'], equals('bookmarksMenuFolder'));
+ expect(result['children'], isA());
+ expect((result['children'] as List).length, equals(1));
+
+ final child = (result['children'] as List)[0] as Map;
+ expect(child['guid'], equals('bookmark1___'));
+ expect(child['title'], equals('Test Bookmark'));
+ expect(child['url'], equals('https://example.com'));
+ expect(child['type'], equals('text/x-moz-place'));
+ });
+
+ test('should skip bookmarks with invalid URLs during export', () async {
+ final mockNode = BookmarkNode(
+ guid: 'menu________',
+ parentGuid: 'root________',
+ position: 0,
+ title: 'Bookmarks Menu',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'invalid1____',
+ parentGuid: 'menu________',
+ position: 0,
+ title: 'Invalid Bookmark',
+ url: '', // Empty URL
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ BookmarkNode(
+ guid: 'valid1______',
+ parentGuid: 'menu________',
+ position: 1,
+ title: 'Valid Bookmark',
+ url: 'https://example.com',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final result = await utils.exportToJson(root: BookmarkRoot.menu);
+
+ expect(result, isNotNull);
+ final children = result!['children'] as List;
+ // Only the valid bookmark should be exported
+ expect(children.length, equals(1));
+ expect(children[0]['guid'], equals('valid1______'));
+ });
+
+ test('should handle separators in export', () async {
+ final mockNode = BookmarkNode(
+ guid: 'menu________',
+ parentGuid: 'root________',
+ position: 0,
+ title: 'Bookmarks Menu',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'separator___',
+ parentGuid: 'menu________',
+ position: 0,
+ title: 'should be ignored',
+ url: null,
+ dateAdded: 1361551979380988,
+ lastModified: 1361551979380988,
+ type: BookmarkNodeType.separator,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final result = await utils.exportToJson(root: BookmarkRoot.menu);
+
+ expect(result, isNotNull);
+ final children = result!['children'] as List;
+ expect(children.length, equals(1));
+
+ final separator = children[0];
+ expect(separator['type'], equals('text/x-moz-place-separator'));
+ expect(separator['title'], equals('')); // Title should be empty
+ });
+
+ test('should assign correct root names', () async {
+ final testCases = [
+ (BookmarkRoot.menu, 'bookmarksMenuFolder'),
+ (BookmarkRoot.toolbar, 'toolbarFolder'),
+ (BookmarkRoot.unfiled, 'unfiledBookmarksFolder'),
+ (BookmarkRoot.mobile, 'mobileFolder'),
+ ];
+
+ for (final testCase in testCases) {
+ final mockNode = BookmarkNode(
+ guid: testCase.$1.id,
+ parentGuid: 'root________',
+ position: 0,
+ title: 'Test Root',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [],
+ );
+
+ when(mockService.getTree(testCase.$1.id, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final result = await utils.exportToJson(root: testCase.$1);
+
+ expect(result, isNotNull);
+ expect(result!['root'], equals(testCase.$2));
+ }
+ });
+
+ test('should preserve correct index values for children', () async {
+ final mockNode = BookmarkNode(
+ guid: 'menu________',
+ parentGuid: 'root________',
+ position: 0,
+ title: 'Bookmarks Menu',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: 'menu________',
+ position: 0,
+ title: 'First',
+ url: 'https://example.com/1',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ BookmarkNode(
+ guid: 'bookmark2___',
+ parentGuid: 'menu________',
+ position: 1,
+ title: 'Second',
+ url: 'https://example.com/2',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ BookmarkNode(
+ guid: 'bookmark3___',
+ parentGuid: 'menu________',
+ position: 2,
+ title: 'Third',
+ url: 'https://example.com/3',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final result = await utils.exportToJson(root: BookmarkRoot.menu);
+
+ expect(result, isNotNull);
+ final children = result!['children'] as List;
+ expect(children.length, equals(3));
+ expect(children[0]['index'], equals(0));
+ expect(children[1]['index'], equals(1));
+ expect(children[2]['index'], equals(2));
+ });
+
+ test('should throw when tree cannot be fetched', () async {
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => null);
+
+ expect(
+ () => utils.exportToJson(root: BookmarkRoot.menu),
+ throwsA(isA()),
+ );
+ });
+
+ test('should include typeCode in export', () async {
+ final mockNode = BookmarkNode(
+ guid: 'menu________',
+ parentGuid: 'root________',
+ position: 0,
+ title: 'Bookmarks Menu',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: 'menu________',
+ position: 0,
+ title: 'Test Bookmark',
+ url: 'https://example.com',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ final result = await utils.exportToJson(root: BookmarkRoot.menu);
+
+ expect(result, isNotNull);
+ expect(result!['typeCode'], equals(BookmarkNodeType.folder.index + 1));
+
+ final child = (result['children'] as List)[0] as Map;
+ expect(child['typeCode'], equals(BookmarkNodeType.item.index + 1));
+ });
+ });
+
+ group('BookmarkJSONUtils - Import/Export Round-Trip', () {
+ test('should preserve data through export and re-import', () async {
+ // Setup initial data
+ final mockNode = BookmarkNode(
+ guid: 'menu________',
+ parentGuid: 'root________',
+ position: 0,
+ title: 'Bookmarks Menu',
+ url: null,
+ dateAdded: 1361551978957783,
+ lastModified: 1361551979382837,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'folder1_____',
+ parentGuid: 'menu________',
+ position: 0,
+ title: 'Test Folder',
+ url: null,
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.folder,
+ children: [
+ BookmarkNode(
+ guid: 'bookmark1___',
+ parentGuid: 'folder1_____',
+ position: 0,
+ title: 'Test Bookmark',
+ url: 'https://example.com',
+ dateAdded: 1361551979350273,
+ lastModified: 1361551979376699,
+ type: BookmarkNodeType.item,
+ ),
+ ],
+ ),
+ ],
+ );
+
+ when(mockService.getTree(any, recursive: true))
+ .thenAnswer((_) async => mockNode);
+
+ // Export
+ final exported = await utils.exportToJson(root: BookmarkRoot.menu);
+ expect(exported, isNotNull);
+
+ // Re-import
+ when(mockService.eraseEverything(any)).thenAnswer((_) async {});
+ when(mockService.addFolder(any, any, any))
+ .thenAnswer((_) async => 'folder1_____');
+ when(mockService.addItem(any, any, any, any))
+ .thenAnswer((_) async => 'bookmark1___');
+
+ final jsonString = jsonEncode({'children': [exported]});
+ final count = await utils.importFromJSON(jsonString, replace: true);
+
+ expect(count, equals(1)); // One bookmark imported
+ verify(mockService.eraseEverything(BookmarkRoot.root)).called(1);
+ });
+ });
+}
diff --git a/app/test/features/geckoview/features/bookmarks/utils/bookmark_json_utils_test.mocks.dart b/app/test/features/geckoview/features/bookmarks/utils/bookmark_json_utils_test.mocks.dart
new file mode 100644
index 00000000..b5a7657a
--- /dev/null
+++ b/app/test/features/geckoview/features/bookmarks/utils/bookmark_json_utils_test.mocks.dart
@@ -0,0 +1,158 @@
+// Mocks generated by Mockito 5.4.6 from annotations
+// in weblibre/test/features/geckoview/features/bookmarks/utils/bookmark_json_utils_test.dart.
+// Do not manually edit this file.
+
+// ignore_for_file: no_leading_underscores_for_library_prefixes
+import 'dart:async' as _i3;
+
+import 'package:flutter_mozilla_components/src/domain/services/gecko_bookmarks.dart'
+ as _i2;
+import 'package:flutter_mozilla_components/src/pigeons/gecko.g.dart' as _i4;
+import 'package:mockito/mockito.dart' as _i1;
+import 'package:mockito/src/dummies.dart' as _i5;
+
+// ignore_for_file: type=lint
+// ignore_for_file: avoid_redundant_argument_values
+// ignore_for_file: avoid_setters_without_getters
+// ignore_for_file: comment_references
+// ignore_for_file: deprecated_member_use
+// ignore_for_file: deprecated_member_use_from_same_package
+// ignore_for_file: implementation_imports
+// ignore_for_file: invalid_use_of_visible_for_testing_member
+// ignore_for_file: must_be_immutable
+// ignore_for_file: prefer_const_constructors
+// ignore_for_file: unnecessary_parenthesis
+// ignore_for_file: camel_case_types
+// ignore_for_file: subtype_of_sealed_class
+// ignore_for_file: invalid_use_of_internal_member
+
+/// A class which mocks [GeckoBookmarksService].
+///
+/// See the documentation for Mockito's code generation for more information.
+class MockGeckoBookmarksService extends _i1.Mock
+ implements _i2.GeckoBookmarksService {
+ MockGeckoBookmarksService() {
+ _i1.throwOnMissingStub(this);
+ }
+
+ @override
+ _i3.Future<_i4.BookmarkNode?> getTree(
+ String? guid, {
+ bool? recursive = false,
+ }) =>
+ (super.noSuchMethod(
+ Invocation.method(#getTree, [guid], {#recursive: recursive}),
+ returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
+ )
+ as _i3.Future<_i4.BookmarkNode?>);
+
+ @override
+ _i3.Future<_i4.BookmarkNode?> getBookmark(String? guid) =>
+ (super.noSuchMethod(
+ Invocation.method(#getBookmark, [guid]),
+ returnValue: _i3.Future<_i4.BookmarkNode?>.value(),
+ )
+ as _i3.Future<_i4.BookmarkNode?>);
+
+ @override
+ _i3.Future> getBookmarksWithUrl(Uri? url) =>
+ (super.noSuchMethod(
+ Invocation.method(#getBookmarksWithUrl, [url]),
+ returnValue: _i3.Future>.value(
+ <_i4.BookmarkNode>[],
+ ),
+ )
+ as _i3.Future>);
+
+ @override
+ _i3.Future> getRecentBookmarks(
+ int? limit, {
+ Duration? maxAge = Duration.zero,
+ DateTime? currentTime,
+ }) =>
+ (super.noSuchMethod(
+ Invocation.method(
+ #getRecentBookmarks,
+ [limit],
+ {#maxAge: maxAge, #currentTime: currentTime},
+ ),
+ returnValue: _i3.Future>.value(
+ <_i4.BookmarkNode>[],
+ ),
+ )
+ as _i3.Future>);
+
+ @override
+ _i3.Future> searchBookmarks(
+ String? query, {
+ int? limit = 10,
+ }) =>
+ (super.noSuchMethod(
+ Invocation.method(#searchBookmarks, [query], {#limit: limit}),
+ returnValue: _i3.Future>.value(
+ <_i4.BookmarkNode>[],
+ ),
+ )
+ as _i3.Future>);
+
+ @override
+ _i3.Future addItem(
+ String? parentGuid,
+ Uri? url,
+ String? title,
+ int? position,
+ ) =>
+ (super.noSuchMethod(
+ Invocation.method(#addItem, [parentGuid, url, title, position]),
+ returnValue: _i3.Future.value(
+ _i5.dummyValue(
+ this,
+ Invocation.method(#addItem, [parentGuid, url, title, position]),
+ ),
+ ),
+ )
+ as _i3.Future);
+
+ @override
+ _i3.Future addFolder(
+ String? parentGuid,
+ String? title,
+ int? position,
+ ) =>
+ (super.noSuchMethod(
+ Invocation.method(#addFolder, [parentGuid, title, position]),
+ returnValue: _i3.Future.value(
+ _i5.dummyValue(
+ this,
+ Invocation.method(#addFolder, [parentGuid, title, position]),
+ ),
+ ),
+ )
+ as _i3.Future);
+
+ @override
+ _i3.Future updateNode(String? guid, _i4.BookmarkInfo? info) =>
+ (super.noSuchMethod(
+ Invocation.method(#updateNode, [guid, info]),
+ returnValue: _i3.Future.value(),
+ returnValueForMissingStub: _i3.Future.value(),
+ )
+ as _i3.Future);
+
+ @override
+ _i3.Future deleteNode(String? guid) =>
+ (super.noSuchMethod(
+ Invocation.method(#deleteNode, [guid]),
+ returnValue: _i3.Future.value(false),
+ )
+ as _i3.Future);
+
+ @override
+ _i3.Future eraseEverything(_i2.BookmarkRoot? root) =>
+ (super.noSuchMethod(
+ Invocation.method(#eraseEverything, [root]),
+ returnValue: _i3.Future.value(),
+ returnValueForMissingStub: _i3.Future.value(),
+ )
+ as _i3.Future);
+}