From 0596fdec273bce2744bfb7493003c95dcc0f1f67 Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 8 Jun 2026 12:33:37 +0300 Subject: [PATCH] file download actor --- README.md | 37 +++++++++++++ lib/src/file_download_actor.dart | 57 ++++++++++++++++++++ lib/swarm.dart | 1 + test/file_download_actor_test.dart | 84 ++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 lib/src/file_download_actor.dart create mode 100644 test/file_download_actor_test.dart diff --git a/README.md b/README.md index 69c6edf..35a9709 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,43 @@ print((nb as NeighborsResult).neighbors['NORTH']); // u09tunu --- +## FileDownloadActor + +Downloads a file from a URL and saves it to disk via an injected `FileActor`. + +```dart +final actor = FileDownloadActor( + fileActor: FileActor(), +); +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `fileActor` | `FileActor` | required | Actor used to write the downloaded bytes to disk | +| `httpClient` | `http.Client?` | new client | Injectable HTTP client; provide a `MockClient` in tests | + +Call `actor.close()` when done to release the HTTP client. + +| Message | Result | Description | +|---|---|---| +| `DownloadFile(url, savePath)` | `FileDownloaded(savePath)` | GETs the URL and writes the response bytes to `savePath`. Throws if the response status is not 2xx. | + +```dart +final actor = FileDownloadActor(fileActor: FileActor()); + +final result = await actor.handle( + const DownloadFile( + url: 'https://example.com/image.jpg', + savePath: '/tmp/image.jpg', + ), +); +print((result as FileDownloaded).savePath); // /tmp/image.jpg + +await actor.close(); +``` + +--- + ## FileUploadActor Uploads files to a Nostr-authenticated remote storage service. Handles diff --git a/lib/src/file_download_actor.dart b/lib/src/file_download_actor.dart new file mode 100644 index 0000000..ce8f494 --- /dev/null +++ b/lib/src/file_download_actor.dart @@ -0,0 +1,57 @@ +import 'dart:typed_data'; + +import 'package:actors/actors.dart'; +import 'package:http/http.dart' as http; + +import 'file_actor.dart'; + +sealed class FileDownloadMessage { + const FileDownloadMessage(); +} + +final class DownloadFile extends FileDownloadMessage { + final String url; + final String savePath; + const DownloadFile({required this.url, required this.savePath}); +} + +sealed class FileDownloadResult { + const FileDownloadResult(); +} + +final class FileDownloaded extends FileDownloadResult { + final String savePath; + const FileDownloaded(this.savePath); +} + +class FileDownloadActor + with Handler { + final FileActor _fileActor; + final http.Client _httpClient; + + FileDownloadActor({ + required FileActor fileActor, + http.Client? httpClient, + }) : _fileActor = fileActor, + _httpClient = httpClient ?? http.Client(); + + @override + Future handle(FileDownloadMessage message) => + switch (message) { + DownloadFile(:final url, :final savePath) => _download(url, savePath), + }; + + @override + Future close() async => _httpClient.close(); + + Future _download(String url, String savePath) async { + final response = await _httpClient.get(Uri.parse(url)); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('Download failed: HTTP ${response.statusCode}'); + } + await _fileActor.handle( + SaveBinaryFile(savePath, Uint8List.fromList(response.bodyBytes)), + ); + return FileDownloaded(savePath); + } +} diff --git a/lib/swarm.dart b/lib/swarm.dart index 5210d99..99d12fe 100644 --- a/lib/swarm.dart +++ b/lib/swarm.dart @@ -5,4 +5,5 @@ export 'src/nostr_fetch_actor.dart'; export 'src/nostr_signer_actor.dart'; export 'src/geo_actor.dart'; export 'src/file_upload_actor.dart'; +export 'src/file_download_actor.dart'; export 'src/nostr_account_actor.dart'; diff --git a/test/file_download_actor_test.dart b/test/file_download_actor_test.dart new file mode 100644 index 0000000..5c25c57 --- /dev/null +++ b/test/file_download_actor_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory tmpDir; + + setUp(() async { + tmpDir = await Directory.systemTemp.createTemp('file_download_test_'); + }); + + tearDown(() async { + await tmpDir.delete(recursive: true); + }); + + FileDownloadActor makeActor(http.Client client) => FileDownloadActor( + fileActor: FileActor(), + httpClient: client, + ); + + test('returns FileDownloaded with correct savePath', () async { + final client = MockClient( + (_) async => http.Response.bytes([1, 2, 3], 200), + ); + final path = '${tmpDir.path}/output.bin'; + final result = await makeActor(client).handle( + DownloadFile(url: 'https://example.com/file', savePath: path), + ); + expect(result, isA()); + expect((result as FileDownloaded).savePath, equals(path)); + }); + + test('saves correct bytes to disk', () async { + final bytes = Uint8List.fromList([0xDE, 0xAD, 0xBE, 0xEF]); + final client = MockClient( + (_) async => http.Response.bytes(bytes, 200), + ); + final path = '${tmpDir.path}/data.bin'; + await makeActor(client).handle( + DownloadFile(url: 'https://example.com/file', savePath: path), + ); + expect(await File(path).readAsBytes(), equals(bytes)); + }); + + test('sends GET request to the correct URL', () async { + http.Request? captured; + final client = MockClient((request) async { + captured = request; + return http.Response.bytes([0], 200); + }); + await makeActor(client).handle( + DownloadFile( + url: 'https://example.com/assets/image.png', + savePath: '${tmpDir.path}/image.png', + ), + ); + expect(captured!.method, equals('GET')); + expect( + captured!.url.toString(), + equals('https://example.com/assets/image.png'), + ); + }); + + test('non-2xx response throws', () async { + for (final status in [400, 403, 404, 500]) { + final client = MockClient( + (_) async => http.Response.bytes([], status), + ); + await expectLater( + makeActor(client).handle( + DownloadFile( + url: 'https://example.com/file', + savePath: '${tmpDir.path}/out.bin', + ), + ), + throwsA(isA()), + ); + } + }); +}