Files
gopeed/ui/flutter/test/features/tasks/application/pending_download_test.dart
T

273 lines
12 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/application/pending_download.dart';
PendingDownload source(String id, {String name = 'bundle', String rid = '', int count = 2, String? url}) =>
PendingDownload(
id: id,
request: Request(url: url ?? 'https://example.com/$id'),
options: Options(path: '/downloads'),
)
..result = ResolveResult(
id: rid,
res: Resource(
name: name,
files: List.generate(
count,
(i) => FileInfo(
name: '$i.txt',
size: 100 + i,
req: Request(url: 'https://example.com/$id/$i'),
),
),
),
)
..selected.addAll(List.generate(count, (i) => i));
GopeedService service(CapabilityRegistry registry) => GopeedService(LocalCapabilityInvoker(registry));
void main() {
test('one named extension group and all independent sources produce exactly two batches', () {
final group = source('a')..selected.remove(0);
final unnamed = source('b', name: '');
final torrent = source('c', rid: 'bt-rid')..selected.remove(0);
final http = source('d', rid: 'http-rid', count: 1, name: '');
final batches = planDownloads([group, unnamed, torrent, http]);
expect(batches, hasLength(2));
final grouped = batches.first.request;
expect(grouped.group, isTrue);
expect(grouped.groupName, 'bundle');
expect(grouped.opts!.path, '/downloads');
expect(grouped.reqs!.single.req!.url, 'https://example.com/a/1');
expect(grouped.toJson(), isNot(contains('groupId')));
final normal = batches.last.request;
expect(normal.group, isFalse);
expect(normal.reqs, hasLength(4));
expect(normal.reqs!.take(2).every((item) => item.req != null && item.rid == null), isTrue);
expect(normal.reqs![2].rid, 'bt-rid');
expect(normal.reqs![2].opts!.selectFiles, [1]);
expect(normal.reqs![3].rid, 'http-rid');
expect(normal.reqs![3].req, isNull);
});
test('successful batches are complete even when only some task IDs are returned', () async {
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (_) {
calls++;
return CreateTaskBatchResult(taskIds: ['only-one']);
});
final sources = [source('a', name: ''), source('b', rid: 'rid')];
await planDownloads(sources).single.submit(service(registry));
expect(sources.every((s) => s.created && s.createError == null), isTrue);
expect(planDownloads(sources), isEmpty);
expect(calls, 1);
});
test('retry only submits failed batches and never appends to the successful group', () async {
final submitted = <CreateTaskBatch>[];
var failNormal = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted.add(batch);
if (!batch.group && failNormal) throw StateError('all failed');
return CreateTaskBatchResult(taskIds: ['created'], parentId: batch.group ? 'parent' : null);
});
final sources = [source('group'), source('normal', name: '')];
for (final batch in planDownloads(sources)) {
await batch.submit(service(registry));
}
expect(sources.first.created, isTrue);
expect(sources.last.created, isFalse);
expect(sources.last.createError, contains('all failed'));
failNormal = false;
final retry = planDownloads(sources);
expect(retry, hasLength(1));
await retry.single.submit(service(registry));
expect(submitted.where((batch) => batch.group), hasLength(1));
expect(planDownloads(sources), isEmpty);
});
test('single extension keeps nested resource directory and empty base remains empty', () {
final single = source('single', count: 1, name: 'album');
single.result!.res.files.single.path = 'disc1';
expect(single.isGroup, isFalse);
expect(planDownloads([single]).single.request.reqs!.single.opts!.path, '/downloads/album/disc1');
final plain = source('plain', count: 1, name: '')..options.path = '';
expect(plain.creationItems().single.opts!.path, '');
});
for (final parent in ['', 'downloads', './downloads', '/downloads']) {
test('group members use paths relative to their group for parent "$parent"', () {
final pending = source('group')..options.path = parent;
pending.files[1].path = 'nested';
final batch = planDownloads([pending]).single.request;
expect(batch.group, isTrue);
expect(batch.opts!.path, parent);
expect(batch.reqs!.map((item) => item.opts!.path), ['', 'nested']);
expect(pending.options.path, parent);
});
}
test('unnamed extension members retain their ordinary batch destination', () {
final pending = source('independent', name: '')..options.path = 'downloads';
pending.files[1].path = 'nested';
final batch = planDownloads([pending]).single.request;
expect(batch.group, isFalse);
expect(batch.reqs!.map((item) => item.opts!.path), ['downloads', 'downloads/nested']);
});
test('members preserve directory intent, HTTP sizes and per-file checksum rules', () {
final pending = source('group');
pending.options.extra = OptsExtraHttp(
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
).toJson();
final members = pending.creationItems();
expect(members.first.opts!.size, 100);
expect(members.first.opts!.asDefaultPath, isFalse);
expect(members.first.opts!.skipAutoCategorize, isTrue);
expect(members.first.opts!.extra as Map, isNot(contains('checksum')));
pending.selected.remove(0);
expect(pending.creationItems().single.opts!.extra as Map, contains('checksum'));
expect(pending.options.extra as Map, contains('checksum'));
pending.result!.res.files[1].req = Request(url: 'magnet:?xt=urn:btih:test');
expect(pending.creationItems().single.opts!.size, 0);
});
test('FTP extension members use FTP options and keep their request TLS mode', () {
final pending = source('group');
pending.options.extra = OptsExtraHttp(
connections: 64,
autoExtract: true,
archivePassword: 'secret',
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
).toJson();
pending.files[0].req = Request(
url: 'ftp://files.example/archive.zip',
extra: ReqExtraFtp(tls: 'explicit').toJson(),
);
pending.selected.remove(1);
final item = pending.creationItems().single;
expect(item.req!.extra, {'tls': 'explicit'});
expect(item.opts!.size, 0);
final extra = item.opts!.extra as Map;
expect(extra['connections'], 32);
expect(extra['autoExtract'], isTrue);
expect(extra['archivePassword'], 'secret');
expect(extra.containsKey('checksum'), isFalse);
expect((pending.options.extra as Map)['connections'], 64);
});
test('member option changes do not affect other members or source options', () {
final pending = source('group');
pending.options
..name = 'custom'
..size = 500
..selectFiles = [0, 1]
..asDefaultPath = true;
final original = pending.options.toJson();
final members = pending.creationItems();
members.first.opts!
..name = 'changed.txt'
..path = '/elsewhere'
..size = 1
..selectFiles.add(99);
expect(members.last.opts!.name, '1.txt');
expect(members.last.opts!.path, '');
expect(members.last.opts!.size, 101);
expect(members.last.opts!.selectFiles, [0, 1]);
expect(pending.options.toJson(), original);
});
for (final rid in ['', 'resolved-rid']) {
test('empty resource preserves options and selects the creation method for rid="$rid"', () {
final pending = source('empty', rid: rid, count: 0)..selected.add(0);
pending.options
..name = 'custom'
..selectFiles = [2];
final item = pending.creationItems().single;
expect(item.rid, rid.isEmpty ? null : rid);
expect(item.req, rid.isEmpty ? pending.request : null);
expect(identical(item.opts, pending.options), isFalse);
expect(item.opts!.toJson(), pending.options.toJson());
});
}
test('a magnet resolved by an extension keeps its HTTP members and group identity', () {
final pending = source('extension', url: 'magnet:?xt=urn:btih:infohash');
expect(pending.isBt, isFalse);
expect(pending.isGroup, isTrue);
final batch = planDownloads([pending]).single.request;
expect(batch.group, isTrue);
expect(batch.reqs, hasLength(2));
expect(batch.reqs!.every((item) => item.rid == null && item.req!.url.startsWith('https:')), isTrue);
});
test('root names use HTTP file names, BT resource names and URL fallback', () {
final http = source('http', rid: 'http-rid', count: 1, name: 'unused-resource-name');
expect(http.name, '0.txt');
final bt = source('bt', rid: 'bt-rid', count: 1, name: 'torrent', url: 'magnet:?xt=urn:btih:infohash');
bt.result!.res.hash = 'infohash';
expect(bt.isBt, isTrue);
expect(bt.name, 'torrent');
final unnamed = source('empty', rid: 'rid', count: 0, name: '');
expect(unnamed.name, unnamed.request.url);
});
for (final entry in <String, bool>{
'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/': false,
'https://example.com/file': false,
'https://example.com/file.torrent': false,
'ftp://files.example/file.torrent': false,
'ftps://files.example/file.torrent': false,
'FTPES://files.example/file.torrent': false,
'magnet:?xt=urn:btih:infohash': true,
'MAGNET:?xt=urn:btih:infohash': true,
'file:///downloads/file.torrent': true,
'/downloads/FILE.TORRENT': true,
'file:///downloads/file.torrent?query=1': true,
'data:application/x-bittorrent;base64,AAAA': true,
'data:application/octet-stream;base64,AAAA': false,
}.entries) {
test('source protocol determines BT identity for ${entry.key}', () {
final pending = source('protocol', rid: 'rid', count: 1, name: 'resource', url: entry.key);
if (RegExp(r'^ftp', caseSensitive: false).hasMatch(entry.key)) pending.result!.res.name = '';
pending.result!.res.hash = '0123456789ABCDEF0123456789ABCDEF';
expect(pending.isBt, entry.value);
expect(pending.name, entry.value ? 'resource' : '0.txt');
});
}
test('failed resolutions are excluded and each newly opened source resolves fresh', () async {
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) {
calls++;
return ResolveResult(
id: 'rid-$calls',
res: Resource(files: [FileInfo(name: 'file')]),
);
});
final first = PendingDownload(
id: 'a',
request: Request(url: 'https://example.com'),
options: Options(),
);
await first.resolve(service(registry));
await first.resolve(service(registry));
expect(calls, 1);
final reopened = PendingDownload(id: 'a', request: first.request, options: Options());
await reopened.resolve(service(registry));
expect(calls, 2);
final failed = PendingDownload(id: 'b', request: first.request, options: Options())..resolveError = 'failed';
expect(planDownloads([failed, reopened]).single.sources, [reopened]);
});
}