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); +}