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