feat: read subscriptions from an Atom feed when a page lists no dated links
This commit is contained in:
parent
b0b86ef5b1
commit
fa51d5793d
9 changed files with 411 additions and 4 deletions
113
app/test/atom_test.dart
Normal file
113
app/test/atom_test.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:mews/mews/atom.dart';
|
||||
import 'package:mews/mews/parser.dart';
|
||||
|
||||
const defaultNamespaced = '''
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Signal Bars</title>
|
||||
<entry>
|
||||
<title>Collecting retro phones</title>
|
||||
<link rel="alternate" href="2026-10-08-retro.html" />
|
||||
<id>tag:example.com,2026:retro</id>
|
||||
<updated>2026-10-08T09:30:00Z</updated>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>First post</title>
|
||||
<link href="2026-09-21-first.html" />
|
||||
<published>2026-09-21T12:00:00Z</published>
|
||||
</entry>
|
||||
</feed>
|
||||
''';
|
||||
|
||||
const prefixed = '''
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<atom:feed xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<atom:entry>
|
||||
<atom:title>Reading the bars</atom:title>
|
||||
<atom:link rel="alternate" href="https://example.com/bars.html" />
|
||||
<atom:updated>2026-10-09T08:00:00Z</atom:updated>
|
||||
</atom:entry>
|
||||
</atom:feed>
|
||||
''';
|
||||
|
||||
void main() {
|
||||
test('reads entries from a feed in the default namespace', () {
|
||||
final entries = parseAtomEntries(
|
||||
source: defaultNamespaced, feedUrl: 'https://example.com/feed.xml');
|
||||
|
||||
expect(entries, hasLength(2));
|
||||
// Newest first, like the dated links a page lists.
|
||||
expect(entries.first.title, 'Collecting retro phones');
|
||||
expect(entries.first.date, DateTime.utc(2026, 10, 8, 9, 30));
|
||||
// Relative links resolve against the feed's own address.
|
||||
expect(entries.first.url, 'https://example.com/2026-10-08-retro.html');
|
||||
// A link without a rel is the alternate one.
|
||||
expect(entries.last.url, 'https://example.com/2026-09-21-first.html');
|
||||
// published stands in when updated is absent.
|
||||
expect(entries.last.date, DateTime.utc(2026, 9, 21, 12));
|
||||
});
|
||||
|
||||
test('reads a feed using an atom prefix', () {
|
||||
final entries =
|
||||
parseAtomEntries(source: prefixed, feedUrl: 'https://example.com/feed.xml');
|
||||
expect(entries.single.title, 'Reading the bars');
|
||||
expect(entries.single.url, 'https://example.com/bars.html');
|
||||
});
|
||||
|
||||
test('falls back to the id when it is a web address', () {
|
||||
const source = '''
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<title>No link here</title>
|
||||
<id>https://example.com/no-link.html</id>
|
||||
<updated>2026-10-08T09:30:00Z</updated>
|
||||
</entry>
|
||||
</feed>
|
||||
''';
|
||||
final entries =
|
||||
parseAtomEntries(source: source, feedUrl: 'https://example.com/feed.xml');
|
||||
expect(entries.single.url, 'https://example.com/no-link.html');
|
||||
});
|
||||
|
||||
test('skips entries with nothing to identify or date them', () {
|
||||
const source = '''
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<title>No date</title>
|
||||
<link rel="alternate" href="a.html" />
|
||||
</entry>
|
||||
<entry>
|
||||
<title>No address</title>
|
||||
<id>tag:example.com,2026:nope</id>
|
||||
<updated>2026-10-08T09:30:00Z</updated>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Unreadable date</title>
|
||||
<link rel="alternate" href="b.html" />
|
||||
<updated>last Tuesday</updated>
|
||||
</entry>
|
||||
</feed>
|
||||
''';
|
||||
expect(
|
||||
parseAtomEntries(source: source, feedUrl: 'https://example.com/feed.xml'),
|
||||
isEmpty);
|
||||
});
|
||||
|
||||
test('a malformed feed is an error, not a guess', () {
|
||||
expect(
|
||||
() => parseAtomEntries(
|
||||
source: '<feed><entry><title>Half', feedUrl: 'https://example.com/feed.xml'),
|
||||
throwsA(isA<MewsParseException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty feed reads as no entries', () {
|
||||
expect(
|
||||
parseAtomEntries(
|
||||
source: '<feed xmlns="http://www.w3.org/2005/Atom"></feed>',
|
||||
feedUrl: 'https://example.com/feed.xml'),
|
||||
isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import 'package:mews/application/controllers/reader_settings_controller.dart';
|
|||
import 'package:mews/application/controllers/subscriptions_controller.dart';
|
||||
import 'package:mews/database/database.dart';
|
||||
import 'package:mews/mews/document.dart';
|
||||
import 'package:mews/mews/parser.dart';
|
||||
|
||||
import 'fake_fetch.dart';
|
||||
|
||||
|
|
@ -207,6 +208,90 @@ void main() {
|
|||
expect(redraws, 2);
|
||||
});
|
||||
|
||||
test('a site publishing only through a feed still reports posts', () async {
|
||||
const site = 'https://feedonly.example/';
|
||||
const feed = 'https://feedonly.example/feed.xml';
|
||||
// A page with no dated links, but it declares a feed (spec 6.2).
|
||||
const page = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>Feed Only</title>
|
||||
<meta name="mews-profile" content="0.1" />
|
||||
<link rel="alternate" type="application/atom+xml" href="feed.xml" />
|
||||
</head>
|
||||
<body><h1>Feed Only</h1><p>Nothing listed here.</p></body>
|
||||
</html>
|
||||
''';
|
||||
String feedWith(List<(String, String)> entries) => '''
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
${entries.map((e) => '<entry><title>${e.$2}</title><link rel="alternate" href="${e.$1}" /><updated>2026-10-08T09:30:00Z</updated></entry>').join()}
|
||||
</feed>
|
||||
''';
|
||||
|
||||
final feeds = {feed: feedWith(const [('old.html', 'An older post')])};
|
||||
final subscriptions = SubscriptionsController(
|
||||
db: db,
|
||||
fetch: FakeFetch({site: page}, feeds: feeds),
|
||||
);
|
||||
await subscriptions.load();
|
||||
await subscriptions.toggle(parseMewsPage(source: page, url: site));
|
||||
|
||||
// What the feed already listed is the back catalogue, not news.
|
||||
expect(await db.unreadCount(), 0);
|
||||
expect((await db.allSubscriptions()).single.atomHref, feed);
|
||||
|
||||
// A post added to the feed later is new.
|
||||
feeds[feed] = feedWith(const [
|
||||
('new.html', 'A newer post'),
|
||||
('old.html', 'An older post'),
|
||||
]);
|
||||
await subscriptions.refreshAll();
|
||||
expect(await db.unreadCount(), 1);
|
||||
final entries = await db.riverEntries(unreadOnly: true);
|
||||
expect(entries.single.title, 'A newer post');
|
||||
|
||||
// Checking again reports nothing, because an address identifies a post.
|
||||
await subscriptions.refreshAll();
|
||||
expect(await db.unreadCount(), 1);
|
||||
});
|
||||
|
||||
test('a page listing dated links is not fetched twice', () async {
|
||||
const site = 'https://both.example/';
|
||||
const page = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>Both</title>
|
||||
<meta name="mews-profile" content="0.1" />
|
||||
<link rel="alternate" type="application/atom+xml" href="feed.xml" />
|
||||
</head>
|
||||
<body><h1>Both</h1><ul>
|
||||
<li><a href="one.html">2026-10-08 The only post</a></li>
|
||||
</ul></body>
|
||||
</html>
|
||||
''';
|
||||
// The feed would answer, but a page that lists its own posts is enough,
|
||||
// so nothing asks for it.
|
||||
final subscriptions = SubscriptionsController(
|
||||
db: db,
|
||||
fetch: FakeFetch({site: page}, feeds: const {
|
||||
'https://both.example/feed.xml':
|
||||
'<feed xmlns="http://www.w3.org/2005/Atom"><entry><title>Duplicate</title>'
|
||||
'<link rel="alternate" href="one.html" /><updated>2026-10-08T09:30:00Z</updated>'
|
||||
'</entry></feed>',
|
||||
}),
|
||||
);
|
||||
await subscriptions.load();
|
||||
await subscriptions.toggle(parseMewsPage(source: page, url: site));
|
||||
await subscriptions.refreshAll();
|
||||
|
||||
// One post, under the title the page gave it.
|
||||
final entries = await db.riverEntries();
|
||||
expect(entries, hasLength(1));
|
||||
expect(entries.single.title, 'The only post');
|
||||
});
|
||||
|
||||
test('caches pages for offline reopening', () async {
|
||||
await db.saveCachedPage(CachedPagesCompanion.insert(
|
||||
url: 'https://example.com/page.html',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:actors/actors.dart';
|
||||
|
||||
import 'package:mews/application/actors/fetch_actor.dart';
|
||||
import 'package:mews/mews/atom.dart';
|
||||
import 'package:mews/mews/parser.dart';
|
||||
|
||||
/// Serves pages from an in-memory map without network or isolates, so
|
||||
|
|
@ -9,10 +10,13 @@ import 'package:mews/mews/parser.dart';
|
|||
/// what a site publishes between two fetches. An address with no source is
|
||||
/// unreachable, and the cached copy stands in for it if there is one.
|
||||
class FakeFetch implements Actor<FetchCommand, FetchResult> {
|
||||
FakeFetch(this.sources);
|
||||
FakeFetch(this.sources, {this.feeds = const {}});
|
||||
|
||||
final Map<String, String> sources;
|
||||
|
||||
/// Atom feeds by address, for sites that publish through one.
|
||||
final Map<String, String> feeds;
|
||||
|
||||
@override
|
||||
Future<FetchResult> send(FetchCommand message) async {
|
||||
return switch (message) {
|
||||
|
|
@ -25,6 +29,11 @@ class FakeFetch implements Actor<FetchCommand, FetchResult> {
|
|||
(null, final String cached) => _parse(url, cached),
|
||||
_ => const FetchNetworkError(),
|
||||
},
|
||||
FetchFeed(:final url) => switch (feeds[url]) {
|
||||
final String source =>
|
||||
FetchFeedOk(url: url, entries: parseAtomEntries(source: source, feedUrl: url)),
|
||||
null => const FetchNetworkError(),
|
||||
},
|
||||
_ => const FetchNetworkError(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,4 +240,44 @@ void main() {
|
|||
expect(quoteText, hasLength(3));
|
||||
expect((quoteText[0] as MewsText).text, 'Quoted words ');
|
||||
});
|
||||
|
||||
group('the atom feed link', () {
|
||||
String pageWith(String link) => '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>Signal Bars</title>
|
||||
<meta name="mews-profile" content="0.1" />
|
||||
$link
|
||||
</head>
|
||||
<body><h1>Signal Bars</h1></body>
|
||||
</html>
|
||||
''';
|
||||
|
||||
test('resolves against the page, so a relative href is usable', () {
|
||||
final page = parseMewsPage(
|
||||
source: pageWith(
|
||||
'<link rel="alternate" type="application/atom+xml" href="feed.xml" />'),
|
||||
url: 'https://example.com/posts/index.html',
|
||||
);
|
||||
expect(page.atomFeedHref, 'https://example.com/posts/feed.xml');
|
||||
});
|
||||
|
||||
test('is refused when it points at another site', () {
|
||||
// A client must not load another site's resources on its own
|
||||
// (specs 7.4 and 8.1).
|
||||
final page = parseMewsPage(
|
||||
source: pageWith(
|
||||
'<link rel="alternate" type="application/atom+xml" href="https://elsewhere.test/feed.xml" />'),
|
||||
url: 'https://example.com/index.html',
|
||||
);
|
||||
expect(page.atomFeedHref, isNull);
|
||||
});
|
||||
|
||||
test('is null when the page declares none', () {
|
||||
final page = parseMewsPage(
|
||||
source: pageWith(''), url: 'https://example.com/index.html');
|
||||
expect(page.atomFeedHref, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue