Initial commit: Tagesbyte prototype
Flutter app that collects daily tech news from German and English RSS/Atom feeds, with a reader view, AI article summaries and daily briefings (Claude API or n8n webhook), paywall/sponsored filtering and ad/consent blocking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f12fb1dabb
159 files changed
+8738
No files matched your search
@@ -0,0 +1,124 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/article_extractor.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final _base = Uri.parse('https://news.example.com/2026/10/story.html');
|
||||
|
||||
const _longText = 'This paragraph is long enough to count as real article text for the reader.';
|
||||
|
||||
void main() {
|
||||
test('picks the main article, not teaser <article>s, and drops page furniture', () {
|
||||
const html = '''
|
||||
<html><body>
|
||||
<nav><p>$_longText nav</p></nav>
|
||||
<article class="teaser"><p>Short teaser that is not the story at all, just a link.</p></article>
|
||||
<article>
|
||||
<h1>Title shown from the feed</h1>
|
||||
<p>$_longText one</p>
|
||||
<h2>Background</h2>
|
||||
<p>$_longText two <img data-src="/img/photo.jpg" src="data:image/gif;base64,R0lG"></p>
|
||||
<ul><li>First point with enough characters</li><li>short</li></ul>
|
||||
<blockquote>We are very happy with the result.</blockquote>
|
||||
<pre>flutter pub add webview_flutter</pre>
|
||||
<figure><img src="https://cdn.example.com/chart.png"><figcaption>A chart</figcaption></figure>
|
||||
<img src="/pixel.gif" width="1">
|
||||
<div class="related-articles"><p>$_longText related</p></div>
|
||||
<div class="comments"><p>$_longText comment</p></div>
|
||||
</article>
|
||||
<footer><p>$_longText footer</p></footer>
|
||||
</body></html>''';
|
||||
|
||||
final content = ArticleExtractor.parse(html, _base);
|
||||
final types = content.blocks.map((b) => b.type).toList();
|
||||
expect(types, [
|
||||
BlockType.paragraph,
|
||||
BlockType.heading,
|
||||
BlockType.paragraph,
|
||||
BlockType.image,
|
||||
BlockType.listItem,
|
||||
BlockType.quote,
|
||||
BlockType.code,
|
||||
BlockType.image,
|
||||
BlockType.caption,
|
||||
]);
|
||||
expect(content.blocks[3].text, 'https://news.example.com/img/photo.jpg',
|
||||
reason: 'lazy-load src is resolved against the page URL');
|
||||
expect(content.plainText, isNot(contains('related')));
|
||||
expect(content.plainText, isNot(contains('comment')));
|
||||
expect(content.plainText, isNot(contains('footer')));
|
||||
expect(content.plainText, isNot(contains('Title shown')));
|
||||
});
|
||||
|
||||
test('skips link-only tables of contents and flattens nested lists', () {
|
||||
const html = '''
|
||||
<article>
|
||||
<ul class="toc">
|
||||
<li><a href="#a">Es herrscht eine beispiellose Knappheit</a>
|
||||
<ul><li><a href="#b">Ohne RAM geht nichts</a></li></ul>
|
||||
</li>
|
||||
</ul>
|
||||
<p>$_longText</p>
|
||||
<ul>
|
||||
<li>Outer point that is long enough
|
||||
<ul><li>Inner point with a <a href="/x">link</a> inside it</li></ul>
|
||||
</li>
|
||||
</ul>
|
||||
</article>''';
|
||||
final items = ArticleExtractor.parse(html, _base)
|
||||
.blocks
|
||||
.where((b) => b.type == BlockType.listItem)
|
||||
.map((b) => b.text)
|
||||
.toList();
|
||||
expect(items, ['Inner point with a link inside it']);
|
||||
});
|
||||
|
||||
test('a redirect to a consent page is reported, not shown as the article', () async {
|
||||
const consentHtml = '<article><p>Besuchen Sie Golem.de wie gewohnt mit Werbung und Tracking, '
|
||||
'indem Sie der Nutzung aller Cookies zustimmen.</p></article>';
|
||||
final extractor = ArticleExtractor(
|
||||
client: _RedirectingClient(
|
||||
Uri.parse('https://www.golem.de/sonstiges/zustimmung/auswahl.html?from=x'),
|
||||
consentHtml),
|
||||
);
|
||||
const article = Article(
|
||||
title: 'Story',
|
||||
link: 'https://www.golem.de/news/story.html',
|
||||
excerpt: 'Teaser',
|
||||
source: NewsSource(
|
||||
id: 'golem', name: 'Golem.de', feedUrl: '', homepage: '', language: SourceLanguage.de),
|
||||
);
|
||||
final content = await extractor.load(article);
|
||||
expect(content.consentWall, isTrue);
|
||||
expect(content.plainText, isNot(contains('Cookies')));
|
||||
});
|
||||
|
||||
test('feed content fragments are parsed as a whole', () {
|
||||
final content = ArticleExtractor.parse(
|
||||
'<p>$_longText</p><p>$_longText again</p>', _base,
|
||||
isFragment: true);
|
||||
expect(content.blocks, hasLength(2));
|
||||
});
|
||||
}
|
||||
|
||||
/// Answers every request as if the server had redirected to [finalUrl].
|
||||
class _RedirectingClient extends http.BaseClient {
|
||||
_RedirectingClient(this.finalUrl, this.body);
|
||||
|
||||
final Uri finalUrl;
|
||||
final String body;
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async =>
|
||||
_UrlStreamedResponse(finalUrl, Stream.value(utf8.encode(body)), 200);
|
||||
}
|
||||
|
||||
class _UrlStreamedResponse extends http.StreamedResponse implements http.BaseResponseWithUrl {
|
||||
_UrlStreamedResponse(this.url, super.stream, super.statusCode);
|
||||
|
||||
@override
|
||||
final Uri url;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:daily_tech_news/models/briefing.dart';
|
||||
import 'package:daily_tech_news/services/storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FakePathProvider extends PathProviderPlatform with MockPlatformInterfaceMixin {
|
||||
_FakePathProvider(this.dir);
|
||||
|
||||
final String dir;
|
||||
|
||||
@override
|
||||
Future<String?> getApplicationSupportPath() async => dir;
|
||||
}
|
||||
|
||||
DailyBriefing _briefing(DateTime at, String intro) => DailyBriefing(
|
||||
createdAt: at,
|
||||
intro: intro,
|
||||
generatedBy: 'test',
|
||||
topics: const [
|
||||
BriefingTopic(title: 'Topic', summary: 'Summary', sources: [
|
||||
ArticleRef(title: 'A', url: 'https://example.com/a', sourceName: 'Example'),
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
void main() {
|
||||
late Directory dir;
|
||||
|
||||
setUp(() {
|
||||
dir = Directory.systemTemp.createTempSync('tagesbyte_test');
|
||||
PathProviderPlatform.instance = _FakePathProvider(dir.path);
|
||||
});
|
||||
|
||||
tearDown(() => dir.deleteSync(recursive: true));
|
||||
|
||||
test('saves and loads the history, newest first', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final storage = await Storage.open();
|
||||
final older = _briefing(DateTime(2026, 10, 10, 8), 'Older');
|
||||
final newer = _briefing(DateTime(2026, 10, 11, 8), 'Newer');
|
||||
|
||||
await storage.saveBriefingHistory([older, newer]);
|
||||
final loaded = await storage.loadBriefingHistory();
|
||||
|
||||
expect(loaded.map((b) => b.intro), ['Newer', 'Older']);
|
||||
expect(loaded.first.topics.single.sources.single.url, 'https://example.com/a');
|
||||
expect(File('${dir.path}/briefings.json').existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('moves the briefing saved by earlier versions into the history', () async {
|
||||
final legacy = _briefing(DateTime(2026, 10, 9, 8), 'From before the update');
|
||||
SharedPreferences.setMockInitialValues(
|
||||
{'last_briefing': jsonEncode(legacy.toJson())});
|
||||
final storage = await Storage.open();
|
||||
|
||||
final loaded = await storage.loadBriefingHistory();
|
||||
expect(loaded.single.intro, 'From before the update');
|
||||
|
||||
// Migrated once: the old key is gone and loading again doesn't duplicate.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getString('last_briefing'), isNull);
|
||||
expect((await storage.loadBriefingHistory()), hasLength(1));
|
||||
});
|
||||
|
||||
test('a missing or broken file means an empty history', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final storage = await Storage.open();
|
||||
expect(await storage.loadBriefingHistory(), isEmpty);
|
||||
|
||||
File('${dir.path}/briefings.json').writeAsStringSync('{not json');
|
||||
expect(await storage.loadBriefingHistory(), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/claude_summarizer.dart';
|
||||
import 'package:daily_tech_news/services/summarizer.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
const _source = NewsSource(
|
||||
id: 'verge',
|
||||
name: 'The Verge',
|
||||
feedUrl: 'https://example.com/feed',
|
||||
homepage: 'https://example.com',
|
||||
language: SourceLanguage.en,
|
||||
);
|
||||
|
||||
const _articles = [
|
||||
Article(title: 'Chip launch', link: 'https://example.com/1', source: _source),
|
||||
Article(title: 'Big outage', link: 'https://example.com/2', source: _source),
|
||||
];
|
||||
|
||||
http.Response _ok(Map<String, dynamic> body) =>
|
||||
http.Response.bytes(utf8.encode(jsonEncode(body)), 200);
|
||||
|
||||
void main() {
|
||||
test('briefing request uses structured output and maps article ids', () async {
|
||||
late Map<String, dynamic> sent;
|
||||
late Map<String, String> headers;
|
||||
final client = MockClient((request) async {
|
||||
sent = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
headers = request.headers;
|
||||
return _ok({
|
||||
'stop_reason': 'end_turn',
|
||||
'content': [
|
||||
{'type': 'thinking', 'thinking': ''},
|
||||
{
|
||||
'type': 'text',
|
||||
'text': jsonEncode({
|
||||
'intro': 'Ein ruhiger Tag.',
|
||||
'topics': [
|
||||
{
|
||||
'title': 'Neuer Chip',
|
||||
'summary': 'Details',
|
||||
'article_ids': ['a1', 'a99'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
final summarizer =
|
||||
ClaudeSummarizer(apiKey: 'sk-test', model: 'claude-opus-5-5', client: client);
|
||||
final briefing = await summarizer.createBriefing(_articles, SourceLanguage.de);
|
||||
|
||||
expect(headers['x-api-key'], 'sk-test');
|
||||
expect(headers['anthropic-version'], '2023-06-01');
|
||||
expect(headers['anthropic-beta'], 'server-side-fallback-2026-07-01');
|
||||
expect(sent['model'], 'claude-opus-5-5');
|
||||
expect(sent['fallbacks'], 'default');
|
||||
expect(sent['output_config']['format']['type'], 'json_schema');
|
||||
expect(sent['messages'][0]['content'], contains('[a2] (The Verge) Big outage'));
|
||||
expect(sent['system'], contains('German'));
|
||||
|
||||
expect(briefing.intro, 'Ein ruhiger Tag.');
|
||||
expect(briefing.topics.single.sources.single.url, 'https://example.com/1',
|
||||
reason: 'unknown ids are dropped');
|
||||
});
|
||||
|
||||
test('haiku requests skip the fallback beta', () async {
|
||||
late http.Request req;
|
||||
final client = MockClient((request) async {
|
||||
req = request;
|
||||
return _ok({
|
||||
'stop_reason': 'end_turn',
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'TL;DR: ok'},
|
||||
],
|
||||
});
|
||||
});
|
||||
final summarizer =
|
||||
ClaudeSummarizer(apiKey: 'k', model: 'claude-haiku-5-5', client: client);
|
||||
final text =
|
||||
await summarizer.summarizeArticle(_articles.first, 'body', SourceLanguage.en);
|
||||
expect(text, 'TL;DR: ok');
|
||||
expect(req.headers.containsKey('anthropic-beta'), isFalse);
|
||||
expect((jsonDecode(req.body) as Map).containsKey('fallbacks'), isFalse);
|
||||
});
|
||||
|
||||
test('refusals and auth errors become readable messages', () async {
|
||||
final refusal = ClaudeSummarizer(
|
||||
apiKey: 'k',
|
||||
model: 'claude-opus-5-5',
|
||||
client: MockClient((_) async => _ok({'stop_reason': 'refusal', 'content': []})),
|
||||
);
|
||||
await expectLater(
|
||||
refusal.summarizeArticle(_articles.first, 'x', SourceLanguage.en),
|
||||
throwsA(isA<SummarizerException>()),
|
||||
);
|
||||
|
||||
final unauthorized = ClaudeSummarizer(
|
||||
apiKey: 'bad',
|
||||
model: 'claude-opus-5-5',
|
||||
client: MockClient((_) async => http.Response(
|
||||
jsonEncode({
|
||||
'type': 'error',
|
||||
'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'},
|
||||
}),
|
||||
401)),
|
||||
);
|
||||
await expectLater(
|
||||
unauthorized.summarizeArticle(_articles.first, 'x', SourceLanguage.en),
|
||||
throwsA(predicate((e) => e.toString().contains('Invalid Claude API key'))),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/article_extractor.dart';
|
||||
import 'package:daily_tech_news/services/consent_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
const _articleUrl = 'https://www.golem.de/news/story-2610-1.html';
|
||||
final _wallUrl = Uri.parse('https://www.golem.de/sonstiges/zustimmung/auswahl.html'
|
||||
'?from=${Uri.encodeComponent(_articleUrl)}');
|
||||
const _wallHtml = '<script>GolemConsent.setCustomConfig({"cookieName":"golem_consent20",'
|
||||
'"cookieVersion":"|260301"});</script><p>Cookies zustimmen</p>';
|
||||
const _articleHtml = '<article><p>Die Forscher analysierten aggregierte Daten von über '
|
||||
'300 Millionen Arbeitsereignissen in Softwareteams.</p></article>';
|
||||
|
||||
/// Behaves like golem.de: articles redirect to the consent page unless the
|
||||
/// consent cookie is sent; the accept endpoint sets that cookie.
|
||||
class _FakeGolem extends http.BaseClient {
|
||||
final requests = <http.BaseRequest>[];
|
||||
|
||||
@override
|
||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||
requests.add(request);
|
||||
final url = request.url;
|
||||
if (url.path == '/abo/setconsentcookie.php') {
|
||||
return http.StreamedResponse(Stream.value(utf8.encode('ok')), 200, headers: {
|
||||
'set-cookie': 'golem_consent20=simple${url.queryParameters['version']}; '
|
||||
'expires=Mon, 12-Oct-2026 10:43:36 GMT; Max-Age=86400; path=/; domain=golem.de; '
|
||||
'secure,golem_c20date=1791715416; expires=Mon, 12-Oct-2026 10:43:36 GMT; path=/',
|
||||
});
|
||||
}
|
||||
if (url.path.startsWith('/sonstiges/zustimmung/')) {
|
||||
return _Page(url, _wallHtml);
|
||||
}
|
||||
final consented = (request.headers['Cookie'] ?? '').contains('golem_consent20=simple');
|
||||
return consented ? _Page(url, _articleHtml) : _Page(_wallUrl, _wallHtml);
|
||||
}
|
||||
}
|
||||
|
||||
class _Page extends http.StreamedResponse implements http.BaseResponseWithUrl {
|
||||
_Page(this.url, String body) : super(Stream.value(utf8.encode(body)), 200);
|
||||
|
||||
@override
|
||||
final Uri url;
|
||||
}
|
||||
|
||||
const _article = Article(
|
||||
title: 'Story',
|
||||
link: _articleUrl,
|
||||
excerpt: 'Teaser',
|
||||
source: NewsSource(
|
||||
id: 'golem', name: 'Golem.de', feedUrl: '', homepage: '', language: SourceLanguage.de),
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('parseSetCookie splits joined headers without breaking on expires dates', () {
|
||||
final cookies = ConsentService.parseSetCookie(
|
||||
'a=1; expires=Mon, 12-Oct-2026 10:43:36 GMT; path=/,b=two|x; path=/, c=3');
|
||||
expect(cookies, {'a': '1', 'b': 'two|x', 'c': '3'});
|
||||
});
|
||||
|
||||
test('accepting Golem uses the version from the consent page', () async {
|
||||
final golem = _FakeGolem();
|
||||
final consent = ConsentService(client: golem);
|
||||
expect(consent.canAccept(_wallUrl), isTrue);
|
||||
expect(await consent.accept(_wallUrl, wallHtml: _wallHtml), isTrue);
|
||||
|
||||
final acceptRequest = golem.requests.single.url;
|
||||
expect(acceptRequest.queryParameters['type'], 'simple');
|
||||
expect(acceptRequest.queryParameters['version'], '|260301');
|
||||
expect(acceptRequest.queryParameters['referer'], _articleUrl);
|
||||
expect(consent.cookieHeader(Uri.parse(_articleUrl)),
|
||||
'golem_consent20=simple|260301; golem_c20date=1791715416');
|
||||
});
|
||||
|
||||
test('reader view accepts the wall once and then gets the article', () async {
|
||||
final golem = _FakeGolem();
|
||||
final consent = ConsentService(client: golem);
|
||||
final extractor = ArticleExtractor(client: golem, consent: consent);
|
||||
|
||||
final content = await extractor.load(_article, acceptConsentWalls: true);
|
||||
expect(content.consentWall, isFalse);
|
||||
expect(content.plainText, contains('300 Millionen'));
|
||||
|
||||
// The next article reuses the cookie: no second accept request.
|
||||
golem.requests.clear();
|
||||
await extractor.load(_article, acceptConsentWalls: true);
|
||||
expect(golem.requests.map((r) => r.url.path), ['/news/story-2610-1.html']);
|
||||
});
|
||||
|
||||
test('without the setting the wall is reported, not accepted', () async {
|
||||
final golem = _FakeGolem();
|
||||
final extractor =
|
||||
ArticleExtractor(client: golem, consent: ConsentService(client: golem));
|
||||
final content = await extractor.load(_article);
|
||||
expect(content.consentWall, isTrue);
|
||||
expect(golem.requests.where((r) => r.url.path.contains('setconsentcookie')), isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:daily_tech_news/services/content_blocking.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
RegExp pattern(int index, {bool ads = true, bool cookies = true}) => RegExp(
|
||||
ContentBlocking.blockers(ads: ads, cookies: cookies)[index].trigger.urlFilter);
|
||||
|
||||
test('ad pattern blocks ad hosts and their subdomains only', () {
|
||||
final ads = pattern(0);
|
||||
expect(ads.hasMatch('https://securepubads.g.doubleclick.net/tag/js/gpt.js'), isTrue);
|
||||
expect(ads.hasMatch('https://static.criteo.net/js/ld/publishertag.js'), isTrue);
|
||||
expect(ads.hasMatch('https://connect.facebook.net/en_US/fbevents.js'), isTrue);
|
||||
expect(ads.hasMatch('https://www.googletagmanager.com/gtm.js?id=1'), isTrue);
|
||||
|
||||
expect(ads.hasMatch('https://www.heise.de/news/doubleclick.net-story.html'), isFalse,
|
||||
reason: 'the host must match, not the path');
|
||||
expect(ads.hasMatch('https://notdoubleclick.net/x'), isFalse);
|
||||
expect(ads.hasMatch('https://www.facebook.com/heiseonline'), isFalse);
|
||||
expect(ads.hasMatch('https://www.spiegel.de/netzwelt/'), isFalse);
|
||||
});
|
||||
|
||||
test('consent patterns block CMP hosts including first-party cmp. subdomains', () {
|
||||
final consent = pattern(1);
|
||||
final firstParty = pattern(2);
|
||||
expect(consent.hasMatch('https://cdn.privacy-mgmt.com/wrapperMessagingWithoutDetection.js'),
|
||||
isTrue);
|
||||
expect(consent.hasMatch('https://cdn.cookielaw.org/scripttemplates/otSDKStub.js'), isTrue);
|
||||
expect(consent.hasMatch('https://cdn.consentmanager.net/delivery/cmp.min.js'), isTrue);
|
||||
expect(firstParty.hasMatch('https://cmp.heise.de/unified/wrapperMessagingWithoutDetection.js'),
|
||||
isTrue);
|
||||
expect(firstParty.hasMatch('https://www.heise.de/cmp.js'), isFalse);
|
||||
});
|
||||
|
||||
test('toggles control which blockers and styles are produced', () {
|
||||
expect(ContentBlocking.blockers(ads: false, cookies: false), isEmpty);
|
||||
expect(ContentBlocking.blockers(ads: true, cookies: false), hasLength(1));
|
||||
expect(ContentBlocking.cosmeticScript(ads: false, cookies: false), isNull);
|
||||
|
||||
final script = ContentBlocking.cosmeticScript(ads: false, cookies: true)!;
|
||||
expect(script, contains('#onetrust-consent-sdk'));
|
||||
expect(script, contains('overflow:auto!important'));
|
||||
expect(script, isNot(contains('adsbygoogle')));
|
||||
// Double-quoted selectors are safe inside the single-quoted JS literal.
|
||||
expect(script, contains('[id^="sp_message_container"]'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/feed_parser.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _source = NewsSource(
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
feedUrl: 'https://example.com/feed',
|
||||
homepage: 'https://example.com',
|
||||
language: SourceLanguage.de,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('parses RSS 2.0 items, strips html, finds images and dates', () {
|
||||
const rss = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<channel>
|
||||
<title>Test</title>
|
||||
<item>
|
||||
<title><![CDATA[Neue GPU & mehr]]></title>
|
||||
<link>https://example.com/a</link>
|
||||
<pubDate>Sun, 11 Oct 2026 06:18:59 +0200</pubDate>
|
||||
<dc:creator>Jane Doe</dc:creator>
|
||||
<description><![CDATA[<p>Die <b>neue</b> Grafikkarte ist da und sie ist schneller als je zuvor.</p>]]></description>
|
||||
<content:encoded><![CDATA[<p>Volltext</p><img src="https://example.com/img.jpg">]]></content:encoded>
|
||||
</item>
|
||||
<item>
|
||||
<title>HN style</title>
|
||||
<link>https://example.com/b</link>
|
||||
<pubDate>Sat, 10 Oct 2026 22:50:10 GMT</pubDate>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=1">Comments</a>]]></description>
|
||||
<media:thumbnail url="https://example.com/thumb.jpg"/>
|
||||
</item>
|
||||
<item><title>No link</title></item>
|
||||
</channel>
|
||||
</rss>''';
|
||||
|
||||
final items = FeedParser.parse(rss, _source);
|
||||
expect(items, hasLength(2));
|
||||
|
||||
final a = items[0];
|
||||
expect(a.title, 'Neue GPU & mehr');
|
||||
expect(a.link, 'https://example.com/a');
|
||||
expect(a.excerpt, startsWith('Die neue Grafikkarte'));
|
||||
expect(a.contentHtml, contains('<p>Volltext</p>'));
|
||||
expect(a.imageUrl, 'https://example.com/img.jpg');
|
||||
expect(a.author, 'Jane Doe');
|
||||
expect(a.published, DateTime.utc(2026, 10, 11, 4, 18, 59));
|
||||
|
||||
final b = items[1];
|
||||
expect(b.excerpt, isEmpty, reason: 'bare "Comments" links are dropped');
|
||||
expect(b.imageUrl, 'https://example.com/thumb.jpg');
|
||||
expect(b.published, DateTime.utc(2026, 10, 10, 22, 50, 10));
|
||||
});
|
||||
|
||||
test('parses Atom entries', () {
|
||||
const atom = '''<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>heise</title>
|
||||
<entry>
|
||||
<title type="html">Patchday: Microsoft schließt Lücken</title>
|
||||
<link rel="alternate" type="text/html" href="https://example.com/atom-1"/>
|
||||
<link rel="enclosure" href="https://example.com/x.mp3"/>
|
||||
<published>2026-10-11T09:30:00+02:00</published>
|
||||
<updated>2026-10-11T10:00:00+02:00</updated>
|
||||
<summary type="html"><p>Microsoft hat zum Patchday zahlreiche Sicherheitslücken geschlossen.</p></summary>
|
||||
<author><name>heise Security</name></author>
|
||||
</entry>
|
||||
</feed>''';
|
||||
final items = FeedParser.parse(atom, _source);
|
||||
expect(items, hasLength(1));
|
||||
expect(items.single.link, 'https://example.com/atom-1');
|
||||
expect(items.single.excerpt, startsWith('Microsoft hat zum Patchday'));
|
||||
expect(items.single.published, DateTime.utc(2026, 10, 11, 7, 30));
|
||||
expect(items.single.author, 'heise Security');
|
||||
});
|
||||
|
||||
test('ignores tracking pixels when picking the article image', () {
|
||||
const atom = '''<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<title type="html">Softwareentwicklung: KI-Flut</title>
|
||||
<link rel="alternate" type="text/html" href="https://www.golem.de/news/a.html"/>
|
||||
<summary type="html">Teaser text that is long enough. <img src="https://cpx.golem.de/cpx.php?aid=1" width="1" height="1" /></summary>
|
||||
</entry>
|
||||
</feed>''';
|
||||
expect(FeedParser.parse(atom, _source).single.imageUrl, isNull);
|
||||
});
|
||||
|
||||
test('parseDate handles named zones, short years and garbage', () {
|
||||
expect(FeedParser.parseDate('Mon, 05 Oct 2026 12:00:00 EST'),
|
||||
DateTime.utc(2026, 10, 5, 17));
|
||||
expect(FeedParser.parseDate('5 Oct 26 12:00 +0000'), DateTime.utc(2026, 10, 5, 12));
|
||||
expect(FeedParser.parseDate('not a date'), isNull);
|
||||
expect(FeedParser.parseDate(''), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Hits the real feeds. Run with: LIVE_FEEDS=1 flutter test test/live_feeds_test.dart
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:daily_tech_news/data/default_sources.dart';
|
||||
import 'package:daily_tech_news/services/article_extractor.dart';
|
||||
import 'package:daily_tech_news/services/consent_service.dart';
|
||||
import 'package:daily_tech_news/services/feed_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final live = Platform.environment['LIVE_FEEDS'] == '1';
|
||||
|
||||
test('all default feeds load and parse', () async {
|
||||
final service = FeedService();
|
||||
final failures = <String>[];
|
||||
for (final source in defaultSources) {
|
||||
try {
|
||||
final items = await service.fetchSource(source);
|
||||
final dated = items.where((a) => a.published != null).length;
|
||||
final withImage = items.where((a) => a.imageUrl != null).length;
|
||||
// ignore: avoid_print
|
||||
print('${source.name.padRight(28)} ${items.length.toString().padLeft(3)} items, '
|
||||
'$dated dated, $withImage with image');
|
||||
if (items.isEmpty) failures.add('${source.name}: no items');
|
||||
} catch (e) {
|
||||
failures.add('${source.name}: $e');
|
||||
}
|
||||
}
|
||||
expect(failures, isEmpty);
|
||||
}, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 3)));
|
||||
|
||||
test('reader view extracts the article body', () async {
|
||||
final service = FeedService();
|
||||
final extractor = ArticleExtractor(consent: ConsentService());
|
||||
final thin = <String>[];
|
||||
for (final source in defaultSources) {
|
||||
final article = (await service.fetchSource(source)).first;
|
||||
final content = await extractor.load(article, acceptConsentWalls: true);
|
||||
final counts = <BlockType, int>{};
|
||||
for (final b in content.blocks) {
|
||||
counts[b.type] = (counts[b.type] ?? 0) + 1;
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('${source.name.padRight(28)} teaser ${article.excerpt.length.toString().padLeft(4)} '
|
||||
'-> ${content.plainText.length.toString().padLeft(5)} chars, '
|
||||
'${counts[BlockType.paragraph] ?? 0} paragraphs, ${counts[BlockType.image] ?? 0} images');
|
||||
if (content.consentWall) {
|
||||
thin.add('${source.name} (consent wall)');
|
||||
} else if (content.plainText.length < 400) {
|
||||
thin.add(source.name);
|
||||
}
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('Teaser only (paywall / blocked / very short): $thin');
|
||||
}, skip: live ? false : 'set LIVE_FEEDS=1 to run', timeout: const Timeout(Duration(minutes: 5)));
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:daily_tech_news/models/article.dart';
|
||||
import 'package:daily_tech_news/models/news_source.dart';
|
||||
import 'package:daily_tech_news/services/paywall_service.dart';
|
||||
import 'package:daily_tech_news/services/storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
NewsSource _source({bool checkPage = false}) => NewsSource(
|
||||
id: 'src',
|
||||
name: 'Source',
|
||||
feedUrl: 'https://example.com/feed',
|
||||
homepage: 'https://example.com',
|
||||
language: SourceLanguage.de,
|
||||
checkPaywallOnPage: checkPage,
|
||||
);
|
||||
|
||||
Article _article(String title, {String? link, bool checkPage = false}) => Article(
|
||||
title: title, link: link ?? 'https://example.com/$title', source: _source(checkPage: checkPage));
|
||||
|
||||
Future<Storage> _storage() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
return Storage.open();
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('recognizes paid and sponsored titles', () async {
|
||||
final paywall = PaywallService(await _storage(), client: MockClient((_) async => http.Response('', 500)));
|
||||
expect(paywall.isPaid(_article('heise+ | Essbare Batterie')), isTrue);
|
||||
expect(paywall.isPaid(_article('(g+) Siri-Ersatz im Eigenbau')), isTrue);
|
||||
expect(paywall.isPaid(_article('SPIEGEL+ Test')), isTrue);
|
||||
expect(paywall.isPaid(_article('Apple reduziert iPhone-18-Pro-Bestellungen')), isFalse);
|
||||
expect(paywall.isPaid(_article('OnePlus 16 vorgestellt')), isFalse);
|
||||
|
||||
expect(paywall.isSponsored(_article('Anzeige: Logitech-Set zum Sparpreis')), isTrue);
|
||||
expect(paywall.isSponsored(_article('Werbung | Neues Angebot')), isTrue);
|
||||
expect(paywall.isSponsored(_article('Anzeigenmarkt bricht ein')), isFalse);
|
||||
});
|
||||
|
||||
test('checks pages only for flagged sources and remembers results', () async {
|
||||
final storage = await _storage();
|
||||
final requested = <String>[];
|
||||
final client = MockClient((request) async {
|
||||
requested.add(request.url.path);
|
||||
final free = request.url.path.contains('free');
|
||||
return http.Response(
|
||||
'<html><head><script type="application/ld+json">'
|
||||
'{"@type":"NewsArticle","isAccessibleForFree":${free ? 'true' : 'false'}}'
|
||||
'</script></head></html>',
|
||||
200);
|
||||
});
|
||||
final paywall = PaywallService(storage, client: client);
|
||||
final paid = _article('Paid story', link: 'https://example.com/paid', checkPage: true);
|
||||
final free = _article('Free story', link: 'https://example.com/free', checkPage: true);
|
||||
final unflagged = _article('Other', link: 'https://example.com/other');
|
||||
final titled = _article('heise+ | Known', link: 'https://example.com/titled', checkPage: true);
|
||||
|
||||
expect(await paywall.checkPages([paid, free, unflagged, titled]), isTrue);
|
||||
expect(requested..sort(), ['/free', '/paid'], reason: 'unflagged and title-marked skipped');
|
||||
expect(paywall.isPaid(paid), isTrue);
|
||||
expect(paywall.isPaid(free), isFalse);
|
||||
|
||||
// A fresh service reads the saved results instead of fetching again.
|
||||
requested.clear();
|
||||
final again = PaywallService(storage, client: client);
|
||||
expect(again.isPaid(paid), isTrue);
|
||||
expect(await again.checkPages([paid, free]), isFalse);
|
||||
expect(requested, isEmpty);
|
||||
});
|
||||
|
||||
test('network errors are retried on the next refresh', () async {
|
||||
final storage = await _storage();
|
||||
var calls = 0;
|
||||
final paywall = PaywallService(storage, client: MockClient((_) async {
|
||||
calls++;
|
||||
return http.Response('', 503);
|
||||
}));
|
||||
final a = _article('Story', link: 'https://example.com/a', checkPage: true);
|
||||
await paywall.checkPages([a]);
|
||||
await paywall.checkPages([a]);
|
||||
expect(calls, 2);
|
||||
expect(paywall.isPaid(a), isFalse);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user