prepare for multiple apps
This commit is contained in:
+766
@@ -0,0 +1,766 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// ignore_for_file: avoid_redundant_argument_values
|
||||
|
||||
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 = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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 = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Example</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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 = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com"><unescaped="test"></A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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('<unescaped="test">'));
|
||||
});
|
||||
|
||||
test('should import bookmarks with timestamps', () async {
|
||||
const htmlWithDates = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com" ADD_DATE="1177375336" LAST_MODIFIED="1177375423">Test</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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 = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3>Parent Folder</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/1">Child 1</A>
|
||||
<DT><H3>Nested Folder</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/2">Grandchild</A>
|
||||
</DL><p>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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 = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Toolbar Bookmark</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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 = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><H3 UNFILED_BOOKMARKS_FOLDER="true">Unsorted Bookmarks</H3>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com">Unfiled Bookmark</A>
|
||||
</DL><p>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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 = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A HREF="https://example.com/1">Bookmark 1</A>
|
||||
<HR>
|
||||
<DT><A HREF="https://example.com/2">Bookmark 2</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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 = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A>No URL</A>
|
||||
<DT><A HREF="https://example.com">Valid</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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 = '''
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
<H1>Bookmarks</H1>
|
||||
<DL><p>
|
||||
<DT><A HREF="not a url">Invalid</A>
|
||||
<DT><A HREF="https://example.com">Valid</A>
|
||||
</DL>
|
||||
''';
|
||||
|
||||
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('<!DOCTYPE NETSCAPE-Bookmark-file-1>'));
|
||||
expect(html, contains('<H1>Bookmarks Menu</H1>'));
|
||||
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: '<unescaped="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 escape special characters
|
||||
expect(html, contains('<unescaped="test">'));
|
||||
expect(html, isNot(contains('<unescaped="test">')));
|
||||
});
|
||||
|
||||
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('<H1>Bookmarks Toolbar</H1>'));
|
||||
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('<H1>Unsorted Bookmarks</H1>'));
|
||||
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('<HR>'));
|
||||
});
|
||||
|
||||
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('<H3'));
|
||||
expect(html, contains('</H3>'));
|
||||
});
|
||||
|
||||
test('should throw when tree cannot be fetched', () {
|
||||
when(
|
||||
mockService.getTree(any, recursive: true),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
expect(
|
||||
() => utils.exportToHTML(root: BookmarkRoot.menu),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
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('<!DOCTYPE NETSCAPE-Bookmark-file-1>'));
|
||||
expect(html, contains('<META HTTP-EQUIV="Content-Type"'));
|
||||
expect(html, contains('<TITLE>Bookmarks</TITLE>'));
|
||||
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(' <DT>'));
|
||||
expect(html, contains('<DL><p>'));
|
||||
expect(html, contains('</DL>'));
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+158
@@ -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<List<_i4.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmarksWithUrl, [url]),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getRecentBookmarks(
|
||||
int? limit, {
|
||||
Duration? maxAge = Duration.zero,
|
||||
DateTime? currentTime,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getRecentBookmarks,
|
||||
[limit],
|
||||
{#maxAge: maxAge, #currentTime: currentTime},
|
||||
),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> searchBookmarks(
|
||||
String? query, {
|
||||
int? limit = 10,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#searchBookmarks, [query], {#limit: limit}),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addItem(
|
||||
String? parentGuid,
|
||||
Uri? url,
|
||||
String? title,
|
||||
int? position,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addFolder(
|
||||
String? parentGuid,
|
||||
String? title,
|
||||
int? position,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> updateNode(String? guid, _i4.BookmarkInfo? info) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#updateNode, [guid, info]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
|
||||
@override
|
||||
_i3.Future<bool> deleteNode(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#deleteNode, [guid]),
|
||||
returnValue: _i3.Future<bool>.value(false),
|
||||
)
|
||||
as _i3.Future<bool>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> eraseEverything(_i2.BookmarkRoot? root) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#eraseEverything, [root]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
}
|
||||
+796
@@ -0,0 +1,796 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// ignore_for_file: avoid_redundant_argument_values, avoid_dynamic_calls
|
||||
|
||||
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<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<List>());
|
||||
expect((result['children'] as List).length, equals(1));
|
||||
|
||||
final child = (result['children'] as List)[0] as Map<String, dynamic>;
|
||||
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', () {
|
||||
when(
|
||||
mockService.getTree(any, recursive: true),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
expect(
|
||||
() => utils.exportToJson(root: BookmarkRoot.menu),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
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<String, dynamic>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+158
@@ -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<List<_i4.BookmarkNode>> getBookmarksWithUrl(Uri? url) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#getBookmarksWithUrl, [url]),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> getRecentBookmarks(
|
||||
int? limit, {
|
||||
Duration? maxAge = Duration.zero,
|
||||
DateTime? currentTime,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getRecentBookmarks,
|
||||
[limit],
|
||||
{#maxAge: maxAge, #currentTime: currentTime},
|
||||
),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<List<_i4.BookmarkNode>> searchBookmarks(
|
||||
String? query, {
|
||||
int? limit = 10,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#searchBookmarks, [query], {#limit: limit}),
|
||||
returnValue: _i3.Future<List<_i4.BookmarkNode>>.value(
|
||||
<_i4.BookmarkNode>[],
|
||||
),
|
||||
)
|
||||
as _i3.Future<List<_i4.BookmarkNode>>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addItem(
|
||||
String? parentGuid,
|
||||
Uri? url,
|
||||
String? title,
|
||||
int? position,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addItem, [parentGuid, url, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<String> addFolder(
|
||||
String? parentGuid,
|
||||
String? title,
|
||||
int? position,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
returnValue: _i3.Future<String>.value(
|
||||
_i5.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(#addFolder, [parentGuid, title, position]),
|
||||
),
|
||||
),
|
||||
)
|
||||
as _i3.Future<String>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> updateNode(String? guid, _i4.BookmarkInfo? info) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#updateNode, [guid, info]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
|
||||
@override
|
||||
_i3.Future<bool> deleteNode(String? guid) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#deleteNode, [guid]),
|
||||
returnValue: _i3.Future<bool>.value(false),
|
||||
)
|
||||
as _i3.Future<bool>);
|
||||
|
||||
@override
|
||||
_i3.Future<void> eraseEverything(_i2.BookmarkRoot? root) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(#eraseEverything, [root]),
|
||||
returnValue: _i3.Future<void>.value(),
|
||||
returnValueForMissingStub: _i3.Future<void>.value(),
|
||||
)
|
||||
as _i3.Future<void>);
|
||||
}
|
||||
+1171
File diff suppressed because it is too large
Load Diff
+355
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_unshortener_service.dart';
|
||||
|
||||
void main() {
|
||||
late ProviderContainer container;
|
||||
late UrlUnshortenerService service;
|
||||
|
||||
setUp(() {
|
||||
container = ProviderContainer();
|
||||
service = container.read(urlUnshortenerServiceProvider.notifier);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
container.dispose();
|
||||
});
|
||||
|
||||
group('warning list asset compatibility', () {
|
||||
test('parses current MISP url-shortener list format', () {
|
||||
final rawJson = File(
|
||||
'assets/preferences/url-shortener-list.json',
|
||||
).readAsStringSync();
|
||||
|
||||
final decoded = jsonDecode(rawJson) as Map<String, dynamic>;
|
||||
expect(decoded['type'], 'hostname');
|
||||
expect(decoded['matching_attributes'], isA<List<dynamic>>());
|
||||
expect(decoded['list'], isA<List<dynamic>>());
|
||||
|
||||
final hosts = service.parseSupportedShortenerHosts(rawJson);
|
||||
expect(hosts.length, greaterThan(200));
|
||||
expect(hosts, contains('bit.ly'));
|
||||
expect(service.isSupportedShortenerHost('www.bit.ly', hosts), isTrue);
|
||||
expect(
|
||||
service.isSupportedShortenerUrl('https://example.com', hosts),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('supported shortener host checks', () {
|
||||
test('parses list hosts from warning list json', () {
|
||||
final hosts = service.parseSupportedShortenerHosts(
|
||||
jsonEncode({
|
||||
'list': ['bit.ly', 't.co', 'TinyURL.com', '*.short.cm'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hosts, containsAll({'bit.ly', 't.co', 'tinyurl.com', 'short.cm'}));
|
||||
});
|
||||
|
||||
test('normalizes URL-like entries to hostnames', () {
|
||||
final hosts = service.parseSupportedShortenerHosts(
|
||||
jsonEncode({
|
||||
'list': ['https://bit.ly/abc', 'tinyurl.com/path?a=1', 't.co/#frag'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hosts, containsAll({'bit.ly', 'tinyurl.com', 't.co'}));
|
||||
});
|
||||
|
||||
test('matches exact and subdomain hosts', () {
|
||||
const supportedHosts = {'bit.ly', 't.co'};
|
||||
|
||||
expect(
|
||||
service.isSupportedShortenerUrl('https://bit.ly/abc', supportedHosts),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
service.isSupportedShortenerUrl('https://www.t.co/abc', supportedHosts),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
service.isSupportedShortenerUrl(
|
||||
'https://example.com/abc',
|
||||
supportedHosts,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches urls without a scheme', () {
|
||||
const supportedHosts = {'tinyurl.com'};
|
||||
|
||||
expect(
|
||||
service.isSupportedShortenerUrl('tinyurl.com/abc', supportedHosts),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
service.isSupportedShortenerUrl('notinyurl.com/abc', supportedHosts),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('unshortenUrl', () {
|
||||
group('unauthenticated requests', () {
|
||||
test('resolves shortened URL successfully', () async {
|
||||
final client = MockClient((request) async {
|
||||
expect(request.url.host, 'unshorten.me');
|
||||
expect(request.url.pathSegments, contains('json'));
|
||||
expect(request.headers, isNot(contains('Authorization')));
|
||||
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'success': true,
|
||||
'resolved_url': 'https://example.com/full-article',
|
||||
'remaining_calls': 8,
|
||||
'usage_count': 10,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await service.unshortenUrl(
|
||||
'https://bit.ly/abc123',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.finalUrl, 'https://example.com/full-article');
|
||||
expect(result.remainingCalls, 8);
|
||||
expect(result.usageCount, 10);
|
||||
expect(result.error, isNull);
|
||||
});
|
||||
|
||||
test('encodes URL in request path', () async {
|
||||
late Uri capturedUri;
|
||||
final client = MockClient((request) async {
|
||||
capturedUri = request.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'success': true,
|
||||
'resolved_url': 'https://example.com',
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
await service.unshortenUrl(
|
||||
'https://bit.ly/test?a=1&b=2',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(
|
||||
capturedUri.toString(),
|
||||
contains(Uri.encodeComponent('https://bit.ly/test?a=1&b=2')),
|
||||
);
|
||||
});
|
||||
|
||||
test('returns error on API failure', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({'success': false, 'error': 'Could not resolve URL'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await service.unshortenUrl(
|
||||
'https://invalid-short.url/x',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.error, 'Could not resolve URL');
|
||||
expect(result.finalUrl, isNull);
|
||||
});
|
||||
|
||||
test('returns error on HTTP error status', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response('Server Error', 500);
|
||||
});
|
||||
|
||||
final result = await service.unshortenUrl(
|
||||
'https://bit.ly/abc',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.error, 'HTTP 500');
|
||||
});
|
||||
|
||||
test('returns error on rate limit', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response('Too Many Requests', 429);
|
||||
});
|
||||
|
||||
final result = await service.unshortenUrl(
|
||||
'https://bit.ly/abc',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.error, 'HTTP 429');
|
||||
});
|
||||
|
||||
test('handles missing success field', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({'resolved_url': 'https://example.com'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await service.unshortenUrl(
|
||||
'https://bit.ly/abc',
|
||||
client: client,
|
||||
);
|
||||
|
||||
// success defaults to false when missing
|
||||
expect(result.success, isFalse);
|
||||
});
|
||||
|
||||
test('handles missing error field on failure', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response(jsonEncode({'success': false}), 200);
|
||||
});
|
||||
|
||||
final result = await service.unshortenUrl(
|
||||
'https://bit.ly/abc',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.error, 'Unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
group('authenticated requests', () {
|
||||
test('sends token in Authorization header', () async {
|
||||
late Map<String, String> capturedHeaders;
|
||||
final client = MockClient((request) async {
|
||||
capturedHeaders = request.headers;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'unshortened_url': 'https://example.com/page',
|
||||
'remaining_calls': 95,
|
||||
'usage_count': 100,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
await service.unshortenUrl(
|
||||
'https://bit.ly/abc',
|
||||
token: 'my-api-token',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(capturedHeaders['Authorization'], 'Token my-api-token');
|
||||
});
|
||||
|
||||
test('uses v2 API endpoint with token', () async {
|
||||
late Uri capturedUri;
|
||||
final client = MockClient((request) async {
|
||||
capturedUri = request.url;
|
||||
return http.Response(
|
||||
jsonEncode({'unshortened_url': 'https://example.com'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
await service.unshortenUrl(
|
||||
'https://bit.ly/abc',
|
||||
token: 'token123',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(capturedUri.pathSegments, contains('v2'));
|
||||
expect(capturedUri.pathSegments, contains('unshorten'));
|
||||
expect(capturedUri.queryParameters['url'], isNotNull);
|
||||
});
|
||||
|
||||
test('resolves URL with token successfully', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'unshortened_url': 'https://example.com/target',
|
||||
'remaining_calls': 50,
|
||||
'usage_count': 100,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await service.unshortenUrl(
|
||||
'https://t.co/abc',
|
||||
token: 'valid-token',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.finalUrl, 'https://example.com/target');
|
||||
expect(result.remainingCalls, 50);
|
||||
expect(result.usageCount, 100);
|
||||
});
|
||||
|
||||
test('returns error from authenticated API', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response(jsonEncode({'error': 'Invalid token'}), 200);
|
||||
});
|
||||
|
||||
final result = await service.unshortenUrl(
|
||||
'https://bit.ly/abc',
|
||||
token: 'bad-token',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(result.success, isFalse);
|
||||
expect(result.error, 'Invalid token');
|
||||
});
|
||||
|
||||
test('handles empty error field as success', () async {
|
||||
final client = MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({'error': '', 'unshortened_url': 'https://example.com'}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await service.unshortenUrl(
|
||||
'https://bit.ly/abc',
|
||||
token: 'token',
|
||||
client: client,
|
||||
);
|
||||
|
||||
expect(result.success, isTrue);
|
||||
expect(result.finalUrl, 'https://example.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
|
||||
void main() {
|
||||
group('TabMode value semantics', () {
|
||||
test('isolated modes with same context are equal and hash equally', () {
|
||||
final first = TabMode.isolated('iso1_same');
|
||||
final second = TabMode.isolated('iso1_same');
|
||||
|
||||
expect(first, equals(second));
|
||||
expect(first.hashCode, equals(second.hashCode));
|
||||
});
|
||||
|
||||
test('isolated modes with different contexts are not equal', () {
|
||||
final first = TabMode.isolated('iso1_a');
|
||||
final second = TabMode.isolated('iso1_b');
|
||||
|
||||
expect(first, isNot(equals(second)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2024-2026 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
|
||||
|
||||
void main() {
|
||||
group('SharedContent.parse', () {
|
||||
test('returns SharedUrl for explicit https uri', () {
|
||||
final parsed = SharedContent.parse('https://weblibre.eu/path');
|
||||
|
||||
expect(parsed, isA<SharedUrl>());
|
||||
expect((parsed as SharedUrl).url.host, 'weblibre.eu');
|
||||
});
|
||||
|
||||
test('returns SharedText for explicit non-http scheme', () {
|
||||
final parsed = SharedContent.parse('moz-extension://abc/index.html');
|
||||
|
||||
expect(parsed, isA<SharedText>());
|
||||
});
|
||||
|
||||
test('returns SharedText for plain sentence', () {
|
||||
final parsed = SharedContent.parse('WebLibre README.md');
|
||||
|
||||
expect(parsed, isA<SharedText>());
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user