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:
CedricandClaude Opus 5.5 committed 2026-10-16 14:53:37 +02:00
commit f12fb1dabb
159 files changed
+8738

No files matched your search

+124
View File
@@ -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;
}
+78
View File
@@ -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);
});
}
+119
View File
@@ -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'))),
);
});
}
+101
View File
@@ -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);
});
}
+46
View File
@@ -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"]'));
});
}
+98
View File
@@ -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 &amp; 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">&lt;p&gt;Microsoft hat zum Patchday zahlreiche Sicherheitslücken geschlossen.&lt;/p&gt;</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. &lt;img src=&quot;https://cpx.golem.de/cpx.php?aid=1&quot; width=&quot;1&quot; height=&quot;1&quot; /&gt;</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);
});
}
+56
View File
@@ -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)));
}
+85
View File
@@ -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);
});
}