291 lines
12 KiB
Dart
291 lines
12 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
import 'package:mews/database/database.dart';
|
|
import 'package:mews/mews/document.dart';
|
|
import 'package:mews/protocols/tofu.dart';
|
|
|
|
MewsEntry _entry(String url, {String title = 'A post', int day = 8}) =>
|
|
MewsEntry(url: url, date: DateTime(2026, 10, day), title: title);
|
|
|
|
void main() {
|
|
late MewsDatabase db;
|
|
|
|
setUp(() {
|
|
db = MewsDatabase.test(NativeDatabase.memory());
|
|
});
|
|
|
|
tearDown(() async {
|
|
await db.close();
|
|
});
|
|
|
|
test('a page can only be bookmarked once', () async {
|
|
await db.addBookmark('https://example.com/', 'Example');
|
|
await db.addBookmark('https://example.com/', 'Example again');
|
|
expect(await db.allBookmarks(), hasLength(1));
|
|
expect(await db.countBookmarks(), 1);
|
|
});
|
|
|
|
test('a site can only be subscribed to once', () async {
|
|
await db.addSubscription('https://example.com/', 'Example', null);
|
|
await db.addSubscription('https://example.com/', 'Example', null);
|
|
expect(await db.allSubscriptions(), hasLength(1));
|
|
expect(await db.countSubscriptions(), 1);
|
|
});
|
|
|
|
test('finding a post again keeps its read mark', () async {
|
|
const site = 'https://example.com/';
|
|
await db.addSubscription(site, 'Example', null);
|
|
await db.addEntries(site, [_entry('https://example.com/a.html')]);
|
|
expect(await db.unreadCount(), 1);
|
|
|
|
await db.markEntryRead('https://example.com/a.html');
|
|
expect(await db.unreadCount(), 0);
|
|
|
|
// A refresh sees the same post again; it must not come back unread.
|
|
final fresh = await db.addEntries(site, [_entry('https://example.com/a.html')]);
|
|
expect(fresh, isEmpty);
|
|
expect(await db.unreadCount(), 0);
|
|
});
|
|
|
|
test('unread counts split by site', () async {
|
|
await db.addEntries('https://a.example/', [
|
|
_entry('https://a.example/one.html'),
|
|
_entry('https://a.example/two.html', day: 9),
|
|
]);
|
|
await db.addEntries('https://b.example/', [_entry('https://b.example/one.html')]);
|
|
|
|
expect(await db.unreadCount(), 3);
|
|
expect(await db.unreadCount(siteUrl: 'https://a.example/'), 2);
|
|
expect(await db.unreadCounts(),
|
|
{'https://a.example/': 2, 'https://b.example/': 1});
|
|
});
|
|
|
|
test('describing a site leaves the fields it is not given', () async {
|
|
const site = 'https://example.com/';
|
|
await db.addSubscription(site, 'Signal Bars', null);
|
|
|
|
// The old single update method blanked the title whenever a refresh only
|
|
// had a feed address to record.
|
|
await db.describeSubscription(site, atomHref: 'https://example.com/feed.xml');
|
|
final row = (await db.allSubscriptions()).single;
|
|
expect(row.title, 'Signal Bars');
|
|
expect(row.atomHref, 'https://example.com/feed.xml');
|
|
});
|
|
|
|
test('checking a site records the time without touching its posts', () async {
|
|
const site = 'https://example.com/';
|
|
await db.addSubscription(site, 'Example', null);
|
|
await db.addEntries(site, [_entry('https://example.com/a.html')]);
|
|
|
|
final checkedAt = DateTime(2026, 10, 11, 9, 30);
|
|
await db.touchSubscription(site, checkedAt);
|
|
expect((await db.allSubscriptions()).single.lastCheckedAt, checkedAt);
|
|
expect(await db.unreadCount(), 1);
|
|
});
|
|
|
|
test('history counts addresses rather than visits', () async {
|
|
await db.recordVisit('https://example.com/a.html', 'A');
|
|
await db.recordVisit('https://example.com/a.html', 'A');
|
|
await db.recordVisit('https://example.com/b.html', 'B');
|
|
expect(await db.countHistory(), 2);
|
|
});
|
|
|
|
test('a search matches titles and addresses literally', () async {
|
|
await db.addBookmark('https://a.example/retro.html', 'Collecting retro phones');
|
|
await db.addBookmark('https://b.example/signal.html', 'Signal bars');
|
|
await db.addBookmark('https://c.example/100.html', '100% signal');
|
|
|
|
expect((await db.bookmarksByDate(query: 'retro')).map((b) => b.title),
|
|
['Collecting retro phones']);
|
|
// The address is searched too.
|
|
expect((await db.bookmarksByDate(query: 'b.example')).map((b) => b.title),
|
|
['Signal bars']);
|
|
// A blank search is not a filter.
|
|
expect(await db.bookmarksByDate(query: ' '), hasLength(3));
|
|
|
|
// The reader types text, not a pattern: LIKE's wildcards match literally.
|
|
expect(await db.bookmarksByDate(query: '%'), hasLength(1));
|
|
expect((await db.bookmarksByDate(query: '100%')).map((b) => b.title),
|
|
['100% signal']);
|
|
expect(await db.bookmarksByDate(query: '_'), isEmpty);
|
|
});
|
|
|
|
test('history lists one row per address, newest visit first', () async {
|
|
await db.recordVisit('https://a.example/one.html', 'First title');
|
|
await db.recordVisit('https://b.example/two.html', 'Page two');
|
|
await db.recordVisit('https://a.example/one.html', 'Newer title');
|
|
|
|
final pages = await db.recentPages();
|
|
expect(pages.map((p) => p.url),
|
|
['https://a.example/one.html', 'https://b.example/two.html']);
|
|
// The row carries the title of the latest visit.
|
|
expect(pages.first.title, 'Newer title');
|
|
|
|
await db.forgetPage('https://a.example/one.html');
|
|
expect((await db.recentPages()).map((p) => p.url),
|
|
['https://b.example/two.html']);
|
|
|
|
await db.clearHistory();
|
|
expect(await db.recentPages(), isEmpty);
|
|
});
|
|
|
|
test('an older database is recreated rather than migrated', () async {
|
|
final dir = Directory.systemTemp.createTempSync('mews-schema');
|
|
addTearDown(() => dir.deleteSync(recursive: true));
|
|
final file = File(p.join(dir.path, 'mews.sqlite'));
|
|
|
|
final before = MewsDatabase.test(NativeDatabase(file));
|
|
await before.addBookmark('https://example.com/', 'Example');
|
|
await before.addEntries(
|
|
'https://example.com/', [_entry('https://example.com/a.html')]);
|
|
// Make the file look like the shipped version 1: the posts and tags
|
|
// tables did not exist, and the version counter said 1.
|
|
await before.customStatement('DROP TABLE feed_entries');
|
|
await before.customStatement('DROP TABLE item_tags');
|
|
await before.customStatement('PRAGMA user_version = 1');
|
|
await before.close();
|
|
|
|
// Opening it now runs the destructive upgrade: the reader loses their
|
|
// local data, which is the agreed trade before release, but the app must
|
|
// come up rather than fail on a table that was never there.
|
|
final after = MewsDatabase.test(NativeDatabase(file));
|
|
addTearDown(after.close);
|
|
expect(await after.allBookmarks(), isEmpty);
|
|
expect(await after.unreadCount(), 0);
|
|
|
|
// The new schema works afterwards.
|
|
await after.addBookmark('https://example.com/', 'Example');
|
|
await after.addEntries(
|
|
'https://example.com/', [_entry('https://example.com/a.html')]);
|
|
expect(await after.countBookmarks(), 1);
|
|
expect(await after.unreadCount(), 1);
|
|
});
|
|
|
|
test('tags follow the item they are on', () async {
|
|
const url = 'https://example.com/';
|
|
await db.addBookmark(url, 'Example');
|
|
await db.addTag(TagKind.bookmark, url, 'phones');
|
|
await db.addTag(TagKind.bookmark, url, 'phones');
|
|
expect(await db.tagsOf(TagKind.bookmark, url), ['phones']);
|
|
|
|
// Removing the bookmark takes its tags with it.
|
|
await db.removeBookmark(url);
|
|
expect(await db.tagsOf(TagKind.bookmark, url), isEmpty);
|
|
});
|
|
|
|
test('pins are saved by host and port and replaced on renewal', () async {
|
|
final pin = TofuPin(fingerprint: 'aa', expires: DateTime(2030));
|
|
await db.savePin('capsule.example', 1965, pin);
|
|
await db.savePin('capsule.example', 1966, pin);
|
|
|
|
expect(await db.allPins(), {
|
|
'capsule.example:1965': pin,
|
|
'capsule.example:1966': pin,
|
|
});
|
|
|
|
// A renewal or a reader's new trust replaces the pin for that host.
|
|
final renewed = TofuPin(fingerprint: 'bb', expires: DateTime(2036));
|
|
await db.savePin('capsule.example:1965'.split(':').first, 1965, renewed);
|
|
expect((await db.allPins())['capsule.example:1965'], renewed);
|
|
});
|
|
|
|
test('the directory bookmark is given once and removal keeps it removed',
|
|
() async {
|
|
// A fresh install has nothing.
|
|
expect(await db.directorySeeded(), isFalse);
|
|
|
|
// The first start gives the directory of sites.
|
|
await db.seedDirectoryBookmark('https://mews.page/directory', 'Mews directory');
|
|
expect(await db.countBookmarks(), 1);
|
|
expect((await db.allBookmarks()).single.title, 'Mews directory');
|
|
expect(await db.directorySeeded(), isTrue);
|
|
|
|
// Starting again adds nothing, and the reader's removal of it is not
|
|
// undone by any later start.
|
|
await db.removeBookmark('https://mews.page/directory');
|
|
await db.seedDirectoryBookmark('https://mews.page/directory', 'Mews directory');
|
|
expect(await db.countBookmarks(), 0);
|
|
});
|
|
|
|
test('bindings tie identities to hosts and follow the identity out', () async {
|
|
final id = await db.addClientCertificate('Reader', 'CERT PEM');
|
|
await db.bindCertificate('capsule.example', 1965, id);
|
|
// Binding again, not twice.
|
|
await db.bindCertificate('capsule.example', 1965, id);
|
|
|
|
expect(await db.bindingFor('capsule.example', 1965), id);
|
|
expect(await db.bindingsOf(id), ['capsule.example:1965']);
|
|
|
|
// Renaming an identity is its name only; where it is worn stays.
|
|
await db.renameClientCertificate(id, 'Reader Two');
|
|
expect((await db.allClientCertificates()).single.name, 'Reader Two');
|
|
expect(await db.bindingFor('capsule.example', 1965), id);
|
|
|
|
// Removing an identity takes its bindings with it, so no site is left
|
|
// pointing at a certificate that is no longer there.
|
|
await db.deleteClientCertificate(id);
|
|
expect(await db.allClientCertificates(), isEmpty);
|
|
expect(await db.bindingFor('capsule.example', 1965), isNull);
|
|
});
|
|
test('quotations are saved with their page and narrow by a search', () async {
|
|
await db.addQuotation(
|
|
quote: 'A fine sentence about cats.',
|
|
pageUrl: 'https://example.com/cats.html',
|
|
pageTitle: 'On cats',
|
|
author: 'A. Author',
|
|
);
|
|
await db.addQuotation(
|
|
quote: 'A dog is a dog is a dog.',
|
|
pageUrl: 'https://example.com/dogs.html',
|
|
);
|
|
expect(await db.countQuotations(), 2);
|
|
|
|
// Saved within the same clock tick, the id tiebreak keeps the order they
|
|
// were saved in; the page's title and author ride along.
|
|
final kept = await db.quotationsList();
|
|
expect(kept.first.pageUrl, 'https://example.com/cats.html');
|
|
expect(kept.first.pageTitle, 'On cats');
|
|
expect(kept.first.author, 'A. Author');
|
|
|
|
// A search matches the quoted text, the title, the author or the
|
|
// address; it does not have to know which one it hit.
|
|
expect((await db.quotationsList(query: 'cats')).length, 1);
|
|
expect((await db.quotationsList(query: 'Author')).single.pageUrl,
|
|
'https://example.com/cats.html');
|
|
expect((await db.quotationsList(query: 'dogs.html')).single.quote,
|
|
'A dog is a dog is a dog.');
|
|
|
|
// Ordered by page, a page without a title sorts under its address.
|
|
final byPage = await db.quotationsList(sort: QuotationSort.page);
|
|
expect(byPage.first.pageUrl, 'https://example.com/cats.html');
|
|
|
|
// A quotation of a page the reader's site lists as a post carries the
|
|
// site's own title, so its citation can name it.
|
|
await db.addSubscription('https://example.com/index.html', 'Signal Bars', null);
|
|
await db.addEntries('https://example.com/index.html',
|
|
[_entry('https://example.com/2026-10-08-retro.html')]);
|
|
await db.addQuotation(
|
|
quote: 'The finest retro handsets.',
|
|
pageUrl: 'https://example.com/2026-10-08-retro.html',
|
|
pageTitle: 'Collecting retro phones',
|
|
);
|
|
final post = (await db.quotationsList())
|
|
.firstWhere((q) => q.pageUrl.contains('retro'));
|
|
expect(post.siteTitle, 'Signal Bars');
|
|
expect(await db.siteTitleOf('https://example.com/index.html'),
|
|
'Signal Bars');
|
|
expect(await db.siteTitleOf('https://unknown.example/'), isNull);
|
|
|
|
// One quotation by its id, for the actions that share or remove it.
|
|
final id = kept.last.id;
|
|
expect((await db.quotationById(id))?.quote, 'A dog is a dog is a dog.');
|
|
await db.removeQuotation(id);
|
|
expect(await db.quotationById(id), isNull);
|
|
// The retro quotation from the site-title check remains.
|
|
expect(await db.countQuotations(), 2);
|
|
});
|
|
}
|