mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-14 12:23:43 +00:00
refactor: remove proxy API (#1596)
This commit is contained in:
98 files changed
+5958
-1225
No files matched your search
@@ -0,0 +1,232 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/api/gopeed_site_api.dart';
|
||||
import 'package:gopeed/core/network/app_update_client.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
|
||||
void main() {
|
||||
late Dio dio;
|
||||
late _Adapter adapter;
|
||||
late AppUpdateClient updates;
|
||||
late _RejectTransport transport;
|
||||
setUp(() {
|
||||
transport = _RejectTransport();
|
||||
api.setTransportForTesting(transport);
|
||||
dio = Dio();
|
||||
adapter = _Adapter();
|
||||
dio.httpClientAdapter = adapter;
|
||||
updates = AppUpdateClient(
|
||||
client: dio,
|
||||
site: GopeedSiteApi(client: dio),
|
||||
);
|
||||
});
|
||||
tearDown(() {
|
||||
dio.close(force: true);
|
||||
expect(transport.calls, 0);
|
||||
for (final request in adapter.requests) {
|
||||
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
|
||||
}
|
||||
});
|
||||
|
||||
test('update metadata comes directly from the official GitHub API without mirror lookup', () async {
|
||||
adapter.reply = (_, _) async => _json([
|
||||
{'tag_name': 'v2.0.0', 'body': 'notes'},
|
||||
]);
|
||||
final info = await updates.check('1.0.0');
|
||||
expect(info!.version, '2.0.0');
|
||||
expect(info.changeLog, 'notes');
|
||||
expect(info.releaseUrl, 'https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0');
|
||||
expect(adapter.requests.single.uri.toString(), AppUpdateClient.releasesUrl);
|
||||
});
|
||||
|
||||
test('release asset hashes are bound to the selected release and exact file name', () async {
|
||||
final good = 'aB' * 32;
|
||||
adapter.reply = (_, _) async => _json([
|
||||
{
|
||||
'tag_name': 'v1.9.9',
|
||||
'assets': [
|
||||
{'name': 'old.zip', 'digest': 'sha256:${'c' * 64}'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'tag_name': 'v2.0.0',
|
||||
'assets': [
|
||||
{'name': 'package.zip', 'digest': 'sha256:$good'},
|
||||
{'name': 'wrong-algorithm.zip', 'digest': 'md5:${'a' * 32}'},
|
||||
{'name': 'missing.zip', 'digest': null},
|
||||
{'name': 'bad-hex.zip', 'digest': 'sha256:${'g' * 64}'},
|
||||
{'name': 'short.zip', 'digest': 'sha256:1234'},
|
||||
],
|
||||
},
|
||||
]);
|
||||
final info = (await updates.check('1.0.0'))!;
|
||||
expect(info.assetSha256, {'package.zip': good.toLowerCase()});
|
||||
expect(() => info.assetSha256['package.zip'] = 'd' * 64, throwsUnsupportedError);
|
||||
expect(adapter.requests.single.followRedirects, isFalse);
|
||||
});
|
||||
|
||||
test('the official website fallback preserves asset digests', () async {
|
||||
final checksum = 'e' * 64;
|
||||
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
|
||||
? _json({'message': 'rate limited'}, status: 403)
|
||||
: _json([
|
||||
{
|
||||
'tag_name': 'v2.0.0',
|
||||
'assets': [
|
||||
{'name': 'package.dmg', 'digest': 'sha256:$checksum'},
|
||||
],
|
||||
},
|
||||
]);
|
||||
final info = (await updates.check('1.0.0'))!;
|
||||
expect(info.assetSha256, {'package.dmg': checksum});
|
||||
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
|
||||
});
|
||||
|
||||
test('API redirects fail instead of obtaining metadata from another host', () async {
|
||||
adapter.reply = (_, _) async => ResponseBody.fromString(
|
||||
'',
|
||||
302,
|
||||
headers: {
|
||||
'location': ['https://mirror.example/releases'],
|
||||
},
|
||||
);
|
||||
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
|
||||
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
expect(adapter.requests.every((request) => !request.followRedirects), isTrue);
|
||||
});
|
||||
|
||||
test('website fallback runs once after the official GitHub request fails', () async {
|
||||
adapter.reply = (request, _) async => switch (request.uri.host) {
|
||||
'api.github.com' => _json({'message': 'rate limited'}, status: 403),
|
||||
_ => _json([
|
||||
{'tag_name': 'v2.0.0'},
|
||||
]),
|
||||
};
|
||||
expect((await updates.check('1.0.0'))!.version, '2.0.0');
|
||||
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
final website = adapter.requests.where((r) => r.uri.host == 'gopeed.com').single;
|
||||
expect(website.method, 'GET');
|
||||
expect(website.uri.toString(), 'https://gopeed.com/api/releases?per_page=10');
|
||||
});
|
||||
|
||||
test('invalid GitHub metadata falls back to valid website metadata', () async {
|
||||
for (final invalid in [
|
||||
null,
|
||||
'<html>error</html>',
|
||||
{'message': 'error'},
|
||||
[
|
||||
{'tag_name': 'invalid'},
|
||||
],
|
||||
]) {
|
||||
adapter.requests.clear();
|
||||
adapter.reply = (request, _) async => request.uri.host == 'api.github.com'
|
||||
? _json(invalid)
|
||||
: _json([
|
||||
{'tag_name': 'v2.0.0'},
|
||||
]);
|
||||
expect((await updates.check('1.0.0'))!.version, '2.0.0');
|
||||
expect(adapter.requests.map((request) => request.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
}
|
||||
});
|
||||
|
||||
test('valid empty and no-update lists do not fall back', () async {
|
||||
for (final releases in [
|
||||
[],
|
||||
[
|
||||
{'tag_name': 'v1.0.0'},
|
||||
],
|
||||
]) {
|
||||
adapter.requests.clear();
|
||||
adapter.reply = (_, _) async => _json(releases);
|
||||
expect(await updates.check('1.0.0'), isNull);
|
||||
expect(adapter.requests.single.uri.host, 'api.github.com');
|
||||
}
|
||||
});
|
||||
|
||||
test('null, objects, invalid releases and failed website responses propagate', () async {
|
||||
for (final invalid in [
|
||||
null,
|
||||
{'message': 'error'},
|
||||
[
|
||||
{'tag_name': 'not a version'},
|
||||
],
|
||||
]) {
|
||||
adapter.requests.clear();
|
||||
adapter.reply = (_, _) async => _json(invalid);
|
||||
await expectLater(updates.check('1.0.0'), throwsFormatException);
|
||||
expect(adapter.requests.map((r) => r.uri.host), ['api.github.com', 'gopeed.com']);
|
||||
}
|
||||
adapter.reply = (_, _) async => _json({}, status: 503);
|
||||
await expectLater(updates.check('1.0.0'), throwsA(isA<DioException>()));
|
||||
});
|
||||
|
||||
test('SemVer selection preserves stable and same-series preview rules', () async {
|
||||
var releases = [
|
||||
{'tag_name': 'v2.1.0-beta.1', 'prerelease': true},
|
||||
{'tag_name': 'v2.0.0-beta.2', 'prerelease': true},
|
||||
{'tag_name': 'v2.0.0-rc.1', 'prerelease': true},
|
||||
{'tag_name': 'v1.9.9'},
|
||||
{'tag_name': 'v2.0.0', 'draft': true},
|
||||
];
|
||||
adapter.reply = (_, _) async => _json(releases);
|
||||
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-rc.1');
|
||||
expect((await updates.check('1.9.8'))!.version, '1.9.9');
|
||||
releases = [
|
||||
{'tag_name': 'v2.0.0-beta.10'},
|
||||
{'tag_name': 'v2.0.0-beta.2'},
|
||||
];
|
||||
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.0-beta.10');
|
||||
releases = [
|
||||
{'tag_name': 'v2.0.0'},
|
||||
{'tag_name': 'v2.0.1'},
|
||||
];
|
||||
expect((await updates.check('2.0.0-beta.1'))!.version, '2.0.1');
|
||||
releases = [
|
||||
{'tag_name': 'v2.0.0+build.2'},
|
||||
];
|
||||
expect(await updates.check('2.0.0+build.1'), isNull);
|
||||
await expectLater(updates.check('invalid'), throwsFormatException);
|
||||
});
|
||||
}
|
||||
|
||||
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
|
||||
jsonEncode(body),
|
||||
status,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
|
||||
class _Adapter implements HttpClientAdapter {
|
||||
final requests = <RequestOptions>[];
|
||||
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
|
||||
@override
|
||||
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
|
||||
requests.add(options);
|
||||
return reply(options, cancelFuture);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
class _RejectTransport implements GopeedTransport {
|
||||
int calls = 0;
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
calls++;
|
||||
throw StateError('Update checks must not use the Downloader or mirror API');
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:gopeed/core/network/candidate_download.dart';
|
||||
|
||||
void main() {
|
||||
final expectedSha256 = sha256.convert([for (var i = 1; i <= 3; i++) ...List.filled(32768, i)]).toString();
|
||||
late HttpServer server;
|
||||
var requests = 0;
|
||||
late Directory directory;
|
||||
late String savePath;
|
||||
late List<String> downloads;
|
||||
late Future<void> Function(HttpRequest request) handle;
|
||||
|
||||
setUp(() async {
|
||||
directory = await Directory.systemTemp.createTemp('gopeed-update-test-');
|
||||
savePath = '${directory.path}/package.zip';
|
||||
downloads = [];
|
||||
requests = 0;
|
||||
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.listen((request) async {
|
||||
requests++;
|
||||
if (request.method == 'GET') downloads.add(request.uri.path);
|
||||
await handle(request);
|
||||
await request.response.close();
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.close(force: true);
|
||||
await directory.delete(recursive: true);
|
||||
});
|
||||
|
||||
String url(String path) => 'http://127.0.0.1:${server.port}$path';
|
||||
|
||||
Future<void> package(HttpRequest request) async {
|
||||
const chunkSize = 32768;
|
||||
request.response.headers.contentType = ContentType.binary;
|
||||
request.response.contentLength = chunkSize * 3;
|
||||
if (request.method == 'HEAD') return;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
request.response.add(List.filled(chunkSize, i + 1));
|
||||
await request.response.flush();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
test('downloads the fastest successful probe and reports intermediate byte progress', () async {
|
||||
handle = (request) async {
|
||||
if (request.uri.path == '/slow' && request.method == 'HEAD') {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 120));
|
||||
}
|
||||
await package(request);
|
||||
};
|
||||
final progress = <(int, int)>[];
|
||||
await downloadFromCandidates(
|
||||
[url('/slow'), url('/fast'), url('/fast')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (received, total) => progress.add((received, total)),
|
||||
);
|
||||
expect(downloads, ['/fast']);
|
||||
expect(await File(savePath).length(), 98304);
|
||||
expect(progress.any((p) => p.$1 > 0 && p.$1 < p.$2), isTrue);
|
||||
expect(progress.last, (98304, 98304));
|
||||
});
|
||||
|
||||
test('falls back when a successful HEAD is followed by a failed GET', () async {
|
||||
handle = (request) async {
|
||||
if (request.uri.path == '/broken') {
|
||||
request.response.headers.contentType = ContentType.binary;
|
||||
if (request.method == 'GET') {
|
||||
request.response.statusCode = HttpStatus.serviceUnavailable;
|
||||
request.response.write('unavailable');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (request.method == 'HEAD') {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
}
|
||||
await package(request);
|
||||
};
|
||||
await downloadFromCandidates(
|
||||
[url('/broken'), url('/working')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (_, _) {},
|
||||
);
|
||||
expect(downloads, ['/broken', '/working']);
|
||||
final bytes = await File(savePath).readAsBytes();
|
||||
expect(bytes.length, 98304);
|
||||
expect(bytes.take(32768), everyElement(1));
|
||||
});
|
||||
|
||||
test('tries GET even when HEAD is unsupported', () async {
|
||||
handle = (request) async {
|
||||
if (request.method == 'HEAD') {
|
||||
request.response.statusCode = HttpStatus.methodNotAllowed;
|
||||
return;
|
||||
}
|
||||
await package(request);
|
||||
};
|
||||
await downloadFromCandidates(
|
||||
[url('/no-head')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (_, _) {},
|
||||
);
|
||||
expect(downloads, ['/no-head']);
|
||||
expect(await File(savePath).length(), 98304);
|
||||
});
|
||||
|
||||
test('bounds probe time and still allows a normal download', () async {
|
||||
handle = (request) async {
|
||||
if (request.method == 'HEAD') {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
return;
|
||||
}
|
||||
await package(request);
|
||||
};
|
||||
await downloadFromCandidates(
|
||||
[url('/head-timeout')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
probeTimeout: const Duration(milliseconds: 20),
|
||||
onReceiveProgress: (_, _) {},
|
||||
);
|
||||
expect(downloads, ['/head-timeout']);
|
||||
expect(await File(savePath).length(), 98304);
|
||||
});
|
||||
|
||||
test('rejects mirror landing pages and removes an unsuccessful download', () async {
|
||||
handle = (request) async {
|
||||
request.response.headers.contentType = ContentType.html;
|
||||
if (request.method == 'GET') request.response.write('<html>mirror error</html>');
|
||||
};
|
||||
await expectLater(
|
||||
downloadFromCandidates([url('/landing')], savePath, expectedSha256: expectedSha256, onReceiveProgress: (_, _) {}),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(await File(savePath).exists(), isFalse);
|
||||
});
|
||||
test('same-sized modified mirror bytes fail the official hash and fall back', () async {
|
||||
handle = (request) async {
|
||||
if (request.uri.path == '/tampered') {
|
||||
request.response.headers.contentType = ContentType.binary;
|
||||
// A mirror-provided hash must never replace the expected official hash.
|
||||
request.response.headers.set('x-checksum-sha256', sha256.convert(List.filled(98304, 9)).toString());
|
||||
request.response.contentLength = 98304;
|
||||
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
|
||||
return;
|
||||
}
|
||||
if (request.method == 'HEAD') await Future<void>.delayed(const Duration(milliseconds: 60));
|
||||
await package(request);
|
||||
};
|
||||
await downloadFromCandidates(
|
||||
[url('/tampered'), url('/official')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (_, _) {},
|
||||
);
|
||||
expect(downloads, ['/tampered', '/official']);
|
||||
expect((await sha256.bind(File(savePath).openRead()).first).toString(), expectedSha256);
|
||||
});
|
||||
|
||||
test('all checksum failures abort and delete the untrusted package', () async {
|
||||
handle = (request) async {
|
||||
request.response.headers.contentType = ContentType.binary;
|
||||
if (request.method == 'GET') request.response.add(List.filled(98304, 9));
|
||||
};
|
||||
await expectLater(
|
||||
downloadFromCandidates(
|
||||
[url('/bad-one'), url('/bad-two')],
|
||||
savePath,
|
||||
expectedSha256: expectedSha256,
|
||||
onReceiveProgress: (_, _) {},
|
||||
),
|
||||
throwsA(isA<DownloadChecksumException>()),
|
||||
);
|
||||
expect(downloads.toSet(), {'/bad-one', '/bad-two'});
|
||||
expect(await File(savePath).exists(), isFalse);
|
||||
});
|
||||
|
||||
test('missing or malformed official checksums cannot start a probe or download', () async {
|
||||
handle = package;
|
||||
for (final checksum in ['', 'sha256:$expectedSha256', '0' * 63, 'g' * 64, '$expectedSha256\n']) {
|
||||
await expectLater(
|
||||
downloadFromCandidates([url('/official')], savePath, expectedSha256: checksum, onReceiveProgress: (_, _) {}),
|
||||
throwsA(isA<DownloadChecksumException>()),
|
||||
);
|
||||
}
|
||||
expect(requests, 0);
|
||||
expect(await File(savePath).exists(), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/network/mirror_client.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
|
||||
void main() {
|
||||
late _CandidatesTransport transport;
|
||||
late Dio dio;
|
||||
late _Adapter adapter;
|
||||
late MirrorClient client;
|
||||
const resourceUrl = 'https://raw.githubusercontent.com/owner/repo/main/manifest.json';
|
||||
const mirror = 'https://mirror.example/https://raw.githubusercontent.com/owner/repo/main/manifest.json';
|
||||
setUp(() {
|
||||
transport = _CandidatesTransport([resourceUrl, mirror]);
|
||||
api.setTransportForTesting(transport);
|
||||
dio = Dio();
|
||||
adapter = _Adapter();
|
||||
dio.httpClientAdapter = adapter;
|
||||
client = MirrorClient(service: LocalAppCapabilities.instance.capabilities.gopeed, provider: 'github', client: dio);
|
||||
});
|
||||
tearDown(() {
|
||||
dio.close(force: true);
|
||||
for (final request in adapter.requests) {
|
||||
expect(request.headers.keys.map((key) => key.toLowerCase()), isNot(contains('x-api-token')));
|
||||
}
|
||||
});
|
||||
|
||||
test('an invalid fast mirror cannot hide a slower valid release list', () async {
|
||||
final official = Completer<ResponseBody>();
|
||||
final mirrorRead = Completer<void>();
|
||||
adapter.reply = (request, _) {
|
||||
if (request.uri.host == 'raw.githubusercontent.com') return official.future;
|
||||
mirrorRead.complete();
|
||||
return Future.value(_json('<html>mirror error</html>'));
|
||||
};
|
||||
final result = client.getJson(resourceUrl, decode: _decodeList);
|
||||
await mirrorRead.future;
|
||||
official.complete(
|
||||
_json([
|
||||
{'tag_name': 'v2.0.0', 'body': 'notes'},
|
||||
]),
|
||||
);
|
||||
final info = await result;
|
||||
expect(info.single['tag_name'], 'v2.0.0');
|
||||
expect(info.single['body'], 'notes');
|
||||
expect(adapter.requests.any((r) => r.uri.host == 'gopeed.com'), isFalse);
|
||||
expect(transport.requested, [resourceUrl]);
|
||||
});
|
||||
|
||||
test('a complete validated winner cancels a stalled candidate', () async {
|
||||
final officialStarted = Completer<void>();
|
||||
final canceled = Completer<void>();
|
||||
adapter.reply = (request, cancellation) async {
|
||||
if (request.uri.host == 'raw.githubusercontent.com') {
|
||||
officialStarted.complete();
|
||||
await cancellation;
|
||||
canceled.complete();
|
||||
throw StateError('canceled');
|
||||
}
|
||||
await officialStarted.future;
|
||||
return _json([
|
||||
{'tag_name': 'v2.0.0'},
|
||||
]);
|
||||
};
|
||||
expect((await client.getJson(resourceUrl, decode: _decodeList)).single['tag_name'], 'v2.0.0');
|
||||
await canceled.future;
|
||||
expect(adapter.requests.length, 2);
|
||||
});
|
||||
|
||||
test('the mirror client supports other providers and non-GitHub resources', () async {
|
||||
const resource = 'https://resources.example/manifest.json';
|
||||
transport.provider = 'resources';
|
||||
transport.urls = [resource, 'https://cdn.example/manifest.json'];
|
||||
adapter.reply = (request, _) async =>
|
||||
request.uri.host == 'resources.example' ? _json({}, status: 503) : _json({'revision': 42});
|
||||
final client = MirrorClient(
|
||||
service: LocalAppCapabilities.instance.capabilities.gopeed,
|
||||
provider: 'resources',
|
||||
client: dio,
|
||||
);
|
||||
final revision = await client.getJson<int>(resource, decode: (data) => (data as Map)['revision'] as int);
|
||||
expect(revision, 42);
|
||||
expect(transport.requested, [resource]);
|
||||
expect(adapter.requests.map((request) => request.uri.host).toSet(), {'resources.example', 'cdn.example'});
|
||||
});
|
||||
|
||||
test('metadata attempts have a total timeout even if the transport stalls', () async {
|
||||
final canceled = Completer<void>();
|
||||
adapter.reply = (_, cancellation) async {
|
||||
await cancellation;
|
||||
canceled.complete();
|
||||
throw StateError('canceled');
|
||||
};
|
||||
transport.urls = [resourceUrl];
|
||||
final github = MirrorClient(
|
||||
service: LocalAppCapabilities.instance.capabilities.gopeed,
|
||||
provider: 'github',
|
||||
client: dio,
|
||||
timeout: const Duration(milliseconds: 30),
|
||||
);
|
||||
await expectLater(github.getJson(resourceUrl, decode: (data) => data), throwsA(isA<TimeoutException>()));
|
||||
await canceled.future;
|
||||
});
|
||||
}
|
||||
|
||||
List<dynamic> _decodeList(dynamic data) {
|
||||
if (data is! List) throw const FormatException('Invalid manifest');
|
||||
return data;
|
||||
}
|
||||
|
||||
ResponseBody _json(dynamic body, {int status = 200}) => ResponseBody.fromString(
|
||||
jsonEncode(body),
|
||||
status,
|
||||
headers: {
|
||||
Headers.contentTypeHeader: [Headers.jsonContentType],
|
||||
},
|
||||
);
|
||||
|
||||
class _Adapter implements HttpClientAdapter {
|
||||
final requests = <RequestOptions>[];
|
||||
late Future<ResponseBody> Function(RequestOptions, Future<void>?) reply;
|
||||
@override
|
||||
Future<ResponseBody> fetch(RequestOptions options, Stream<List<int>>? requestStream, Future<void>? cancelFuture) {
|
||||
requests.add(options);
|
||||
return reply(options, cancelFuture);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
|
||||
class _CandidatesTransport implements GopeedTransport {
|
||||
_CandidatesTransport(this.urls);
|
||||
List<String> urls;
|
||||
String provider = 'github';
|
||||
bool fail = false;
|
||||
final requested = <String>[];
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
expect(path, 'api/v1/mirrors/$provider/candidates');
|
||||
expect(method, 'POST');
|
||||
requested.add((data as Map)['url'] as String);
|
||||
if (fail) throw StateError('Downloader unavailable');
|
||||
return {
|
||||
'code': 0,
|
||||
'data': {'urls': urls},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
Reference in new issue
Block a user