import 'dart:typed_data'; import 'package:actors/actors.dart'; import 'package:mews/application/actors/fetch_actor.dart'; import 'package:mews/mews/atom.dart'; import 'package:mews/mews/gemtext.dart'; import 'package:mews/mews/parser.dart'; import 'package:mews/protocols/tofu.dart'; /// Serves pages from an in-memory map without network or isolates, so /// navigation tests are deterministic. [sources] wins over the cached source /// a caller offers, the way a server answering 200 does, so a test can change /// 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. /// /// A gemini or spartan address is served as gemtext, the way the real handler /// routes protocol pages, and an address in [changedCertificates] refuses its /// connection the way a changed server certificate does. class FakeFetch implements Actor { FakeFetch(this.sources, {this.feeds = const {}, this.changedCertificates = const {}, this.certificateRequired = const {}, this.images = const {}}); final Map sources; /// Atom feeds by address, for sites that publish through one. final Map feeds; /// Gemini addresses whose server certificate is not the pinned one, with /// the pin of what was presented instead. final Map changedCertificates; /// Gemini addresses that ask for a client certificate. They answer with a /// certificate request until an identity is presented, then serve the page. final Set certificateRequired; /// Raw bodies by address, the way a capsule serves an image. final Map images; @override Future send(FetchCommand message) async { return switch (message) { ParseSource(:final url, :final source) => _parse(url, source), FetchPage(:final url, :final cachedSource, :final clientCert) => _serve(url, cachedSource, clientCert), SubmitForm(:final url, :final fields) => _serve(_withQuery(url, fields), null, null), FetchBytes(:final url) => switch (images[url]) { final Uint8List data => FetchBytesOk(data), null => const FetchNetworkError(), }, FetchFeed(:final url) => switch (feeds[url]) { final String source => FetchFeedOk(url: url, entries: parseAtomEntries(source: source, feedUrl: url)), null => const FetchNetworkError(), }, }; } /// An answer to a form on a protocol page carries the fields as the /// address's query string; a web form posts to its action unchanged. String _withQuery(String url, Map fields) { final uri = Uri.tryParse(url); if (uri == null) return url; if (uri.scheme != 'gemini' && uri.scheme != 'spartan') return url; return uri.replace(queryParameters: {...uri.queryParameters, ...fields}).toString(); } FetchResult _serve(String url, String? cachedSource, String? clientCert) { final uri = Uri.tryParse(url); if (uri != null && (uri.scheme == 'gemini' || uri.scheme == 'spartan')) { if (certificateRequired.contains(url) && clientCert == null) { return const FetchClientCertificateRequired(status: 60, meta: ''); } final presented = changedCertificates[url]; if (presented != null) { return FetchCertificateChanged( host: uri.host, port: uri.hasPort ? uri.port : (uri.scheme == 'gemini' ? 1965 : 300), presented: presented); } return switch ((sources[url], cachedSource)) { (final String fresh, _) => FetchPageOk( page: parseGemtextPage(source: fresh, url: url), source: fresh), (null, final String cached) => FetchPageOk( page: parseGemtextPage(source: cached, url: url), source: cached, notModified: true), _ => const FetchNetworkError(), }; } return switch ((sources[url], cachedSource)) { (final String fresh, _) => _parse(url, fresh), (null, final String cached) => _parse(url, cached), _ => const FetchNetworkError(), }; } FetchResult _parse(String url, String source) { try { return FetchPageOk(page: parseMewsPage(source: source, url: url), source: source); } on MewsParseException catch (e) { return FetchParseError(e.message, source); } } @override Future close() async {} @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); }