feat(mobile): android background upload bridge via workmanager
Kotlin 侧(MethodChannel photolive/uploads + EventChannel photolive/uploads/events):
- UploadPlugin:registerTask 入队 WorkManager OneTimeWorkRequest(unique
work=upload-task-{taskId},expedited + OutOfQuotaPolicy 回退、CONNECTED
约束、指数退避 10s 起)、cancelTask / queryTask / ackEvents
- UploadWorker(CoroutineWorker):dataSync 前台服务 + 进度通知,
OkHttp PUT(ETag 捕获、64KB 分片进度),结局分类:2xx→completed、
403→needsRenew、4xx→failed{HTTP_4XX}、网络/5xx→Result.retry(上限
5 次后 failed{NETWORK|SERVER_ERROR})、缺文件→failed{SOURCE_MISSING}
- UploadEventBus:终态事件单调 seq + 未 ack 缓存重放(上限 200),
重注册/取消作废旧缓存——进程重启/切后台恢复不丢完成信号
- Manifest:FOREGROUND_SERVICE(_DATA_SYNC)/POST_NOTIFICATIONS 权限 +
SystemForegroundService tools:node=merge 补 dataSync 类型(已验证
合并产物)
Dart 侧:Transport 接口(init/complete/renew 留引擎,仅 PUT 委托)——
DartHttpTransport(Task 6 逻辑抽出,403→renew 异常归一)+ NativeTransport
(registerTask→事件流终态/进度映射、终态 ack、取消联动);引擎传输选择:
Android 缺省原生,dart-define FORCE_DART_TRANSPORT=true 强制 Dart 路径;
失败分类与 dio 路径语义对齐(401 短退避/429 网络桶/SOURCE_MISSING 不
自动重试等)
测试:gradle testDebugUnitTest 29 例(executor MockWebServer 分类×10、
协议解析×11、事件总线×8);flutter test 219 全绿(新增 NativeTransport
事件映射/取消/ack/并发路由、引擎×Transport 委托与 needsRenew 重注册、
传输选择矩阵);flutter analyze 0;debug apk 构建通过
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
/// 传输层测试(P3 计划 Task 7)。
|
||||
///
|
||||
/// 覆盖:
|
||||
/// - NativeTransport:真实通道名 + 测试 messenger(TestDefaultBinaryMessenger
|
||||
/// 的 mock method/stream handler)——registerTask 参数形状、事件映射
|
||||
/// (progress/completed/needsRenew/failed×码表)、终态 ack、取消联动、
|
||||
/// 注册失败归一、多任务路由;
|
||||
/// - DartHttpTransport:dio 异常 → 传输异常归一(403→renew、取消、网络);
|
||||
/// - 传输选择:Android 默认原生、FORCE_DART_TRANSPORT 覆盖、VM 缺省
|
||||
/// DartHttp。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/features/uploads/native_transport.dart';
|
||||
|
||||
/// 捕获 Future 的异常对象(完成则失败测试)。
|
||||
Future<Object> captureError(Future<Object> future) async {
|
||||
try {
|
||||
await future;
|
||||
} on Object catch (e) {
|
||||
return e;
|
||||
}
|
||||
throw StateError('预期失败但 Future 成功完成');
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
const methodChannel = MethodChannel(UploadBridgeChannels.methods);
|
||||
const eventChannel = EventChannel(UploadBridgeChannels.events);
|
||||
|
||||
late NativeTransport transport;
|
||||
late List<MethodCall> methodCalls;
|
||||
MockStreamHandlerEventSink? sink;
|
||||
|
||||
setUp(() {
|
||||
methodCalls = [];
|
||||
sink = null;
|
||||
messenger.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
methodCalls.add(call);
|
||||
switch (call.method) {
|
||||
case 'registerTask':
|
||||
case 'cancelTask':
|
||||
case 'ackEvents':
|
||||
return true;
|
||||
case 'queryTask':
|
||||
return <String, Object?>{
|
||||
'state': 'RUNNING',
|
||||
'attempt': 1,
|
||||
'progress': <String, Object?>{'sent': 5, 'total': 10},
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
messenger.setMockStreamHandler(
|
||||
eventChannel,
|
||||
MockStreamHandler.inline(
|
||||
onListen: (arguments, events) {
|
||||
sink = events;
|
||||
},
|
||||
onCancel: (arguments) {},
|
||||
),
|
||||
);
|
||||
transport = NativeTransport();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
transport.dispose();
|
||||
messenger.setMockMethodCallHandler(methodChannel, null);
|
||||
messenger.setMockStreamHandler(eventChannel, null);
|
||||
});
|
||||
|
||||
/// 泵事件循环直到 registerTask 落账(事件由用例经 [sink] 注入)。
|
||||
Future<void> untilRegistered() async {
|
||||
for (var i = 0;
|
||||
i < 100 && !methodCalls.any((c) => c.method == 'registerTask');
|
||||
i++) {
|
||||
await pumpEventQueue();
|
||||
}
|
||||
expect(methodCalls.any((c) => c.method == 'registerTask'), isTrue,
|
||||
reason: 'registerTask 已发出');
|
||||
}
|
||||
|
||||
void emitEvent(Map<String, Object?> event) => sink!.success(event);
|
||||
|
||||
group('NativeTransport 命令与事件映射', () {
|
||||
test('registerTask 参数形状:taskId/url/method/headers/filePath/metadata',
|
||||
() async {
|
||||
final future = transport.send(
|
||||
taskId: 'task-1',
|
||||
url: 'http://obj.test/staging/a.jpg',
|
||||
method: 'PUT',
|
||||
headers: const {'Content-Type': 'image/jpeg'},
|
||||
filePath: '/data/a.jpg',
|
||||
metadata: const {'uploadId': 'upl-1', 'attempt': 2},
|
||||
);
|
||||
await untilRegistered();
|
||||
emitEvent({
|
||||
'event': 'completed',
|
||||
'taskId': 'task-1',
|
||||
'etag': '"e-1"',
|
||||
'seq': 7,
|
||||
});
|
||||
final result = await future;
|
||||
expect(result.etag, '"e-1"');
|
||||
|
||||
final call = methodCalls.firstWhere((c) => c.method == 'registerTask');
|
||||
final args = Map<String, Object?>.from(call.arguments as Map);
|
||||
expect(args['taskId'], 'task-1');
|
||||
expect(args['url'], 'http://obj.test/staging/a.jpg');
|
||||
expect(args['method'], 'PUT');
|
||||
expect(args['headers'], {'Content-Type': 'image/jpeg'});
|
||||
expect(args['filePath'], '/data/a.jpg');
|
||||
expect(args['metadata'], {'uploadId': 'upl-1', 'attempt': 2});
|
||||
|
||||
// 终态事件消费后 ack 回传 seq。
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
methodCalls.any(
|
||||
(c) => c.method == 'ackEvents' && (c.arguments as Map)['seq'] == 7,
|
||||
),
|
||||
isTrue,
|
||||
reason: '终态 ack 已回传',
|
||||
);
|
||||
});
|
||||
|
||||
test('progress 事件映射到 onProgress;completed 前不终结', () async {
|
||||
final samples = <(int, int)>[];
|
||||
final future = transport.send(
|
||||
taskId: 'task-2',
|
||||
url: 'http://obj.test/s',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: '/data/b.jpg',
|
||||
onProgress: (sent, total) => samples.add((sent, total)),
|
||||
);
|
||||
await untilRegistered();
|
||||
emitEvent({
|
||||
'event': 'progress',
|
||||
'taskId': 'task-2',
|
||||
'sent': 10,
|
||||
'total': 100,
|
||||
'seq': 1,
|
||||
});
|
||||
emitEvent({
|
||||
'event': 'progress',
|
||||
'taskId': 'task-2',
|
||||
'sent': 90,
|
||||
'total': 100,
|
||||
'seq': 2,
|
||||
});
|
||||
await pumpEventQueue();
|
||||
expect(samples, [(10, 100), (90, 100)]);
|
||||
emitEvent({
|
||||
'event': 'completed',
|
||||
'taskId': 'task-2',
|
||||
'etag': null,
|
||||
'seq': 3,
|
||||
});
|
||||
expect((await future).etag, isNull);
|
||||
});
|
||||
|
||||
test('needsRenew 事件 → TransportRenewRequiredException(403 语义)',
|
||||
() async {
|
||||
final future = transport.send(
|
||||
taskId: 'task-3',
|
||||
url: 'http://obj.test/s',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: '/data/c.jpg',
|
||||
);
|
||||
await untilRegistered();
|
||||
emitEvent({'event': 'needsRenew', 'taskId': 'task-3', 'seq': 1});
|
||||
final err = await captureError(future);
|
||||
expect(err, isA<TransportRenewRequiredException>());
|
||||
expect((err as TransportException).statusCode, 403);
|
||||
});
|
||||
|
||||
test('failed 事件码表:NETWORK / HTTP_4XX / SERVER_ERROR / SOURCE_MISSING',
|
||||
() async {
|
||||
Future<Object> drive(Map<String, Object?> failEvent) async {
|
||||
final taskId = failEvent['taskId']! as String;
|
||||
final future = transport.send(
|
||||
taskId: taskId,
|
||||
url: 'http://obj.test/s',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: '/data/x.jpg',
|
||||
);
|
||||
await untilRegistered();
|
||||
emitEvent(failEvent);
|
||||
return captureError(future);
|
||||
}
|
||||
|
||||
var err = await drive({
|
||||
'event': 'failed',
|
||||
'taskId': 't-net',
|
||||
'code': 'NETWORK',
|
||||
'seq': 1,
|
||||
});
|
||||
expect((err as TransportException).code, TransportErrorCodes.network);
|
||||
expect(err.statusCode, isNull);
|
||||
|
||||
err = await drive({
|
||||
'event': 'failed',
|
||||
'taskId': 't-4xx',
|
||||
'code': 'HTTP_4XX',
|
||||
'status': 400,
|
||||
'seq': 2,
|
||||
});
|
||||
expect((err as TransportException).code, TransportErrorCodes.http);
|
||||
expect(err.statusCode, 400);
|
||||
|
||||
err = await drive({
|
||||
'event': 'failed',
|
||||
'taskId': 't-5xx',
|
||||
'code': 'SERVER_ERROR',
|
||||
'status': 502,
|
||||
'seq': 3,
|
||||
});
|
||||
expect((err as TransportException).code, TransportErrorCodes.http);
|
||||
expect(err.statusCode, 502);
|
||||
|
||||
err = await drive({
|
||||
'event': 'failed',
|
||||
'taskId': 't-src',
|
||||
'code': 'SOURCE_MISSING',
|
||||
'seq': 4,
|
||||
});
|
||||
expect(
|
||||
(err as TransportException).code,
|
||||
TransportErrorCodes.sourceMissing,
|
||||
);
|
||||
});
|
||||
|
||||
test('取消令牌:cancelTask 下发 + cancelled 异常收敛', () async {
|
||||
final cancelToken = CancelToken();
|
||||
final future = transport.send(
|
||||
taskId: 'task-4',
|
||||
url: 'http://obj.test/s',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: '/data/d.jpg',
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
await untilRegistered();
|
||||
cancelToken.cancel('用户取消');
|
||||
final err = await captureError(future);
|
||||
expect((err as TransportException).code, TransportErrorCodes.cancelled);
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
methodCalls.any(
|
||||
(c) =>
|
||||
c.method == 'cancelTask' &&
|
||||
(c.arguments as Map)['taskId'] == 'task-4',
|
||||
),
|
||||
isTrue,
|
||||
reason: 'cancelTask 已下发原生',
|
||||
);
|
||||
});
|
||||
|
||||
test('registerTask 通道异常 → channel 归一(注册失败不悬挂)', () async {
|
||||
messenger.setMockMethodCallHandler(methodChannel, (call) async {
|
||||
throw PlatformException(code: 'bad_args', message: 'missing url');
|
||||
});
|
||||
final err = await captureError(
|
||||
transport.send(
|
||||
taskId: 'task-5',
|
||||
url: 'http://obj.test/s',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: '/data/e.jpg',
|
||||
),
|
||||
);
|
||||
expect((err as TransportException).code, TransportErrorCodes.channel);
|
||||
});
|
||||
|
||||
test('并发多任务:事件按 taskId 路由互不串扰', () async {
|
||||
final f1 = transport.send(
|
||||
taskId: 'a',
|
||||
url: 'http://obj.test/1',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: '/1.jpg',
|
||||
);
|
||||
final f2 = transport.send(
|
||||
taskId: 'b',
|
||||
url: 'http://obj.test/2',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: '/2.jpg',
|
||||
);
|
||||
await untilRegistered();
|
||||
emitEvent({'event': 'completed', 'taskId': 'b', 'etag': '"b"', 'seq': 1});
|
||||
emitEvent({
|
||||
'event': 'failed',
|
||||
'taskId': 'a',
|
||||
'code': 'NETWORK',
|
||||
'seq': 2,
|
||||
});
|
||||
expect((await f2).etag, '"b"');
|
||||
final err = await captureError(f1);
|
||||
expect((err as TransportException).code, TransportErrorCodes.network);
|
||||
});
|
||||
|
||||
test('queryTask 透传原生 WorkInfo 概览', () async {
|
||||
final info = await transport.queryTask('task-9');
|
||||
expect(info!['state'], 'RUNNING');
|
||||
expect((info['progress'] as Map)['sent'], 5);
|
||||
});
|
||||
});
|
||||
|
||||
group('DartHttpTransport dio 异常归一', () {
|
||||
Dio stubDio(Future<Object> Function(RequestOptions) router) {
|
||||
return Dio()..httpClientAdapter = _StubAdapter(router);
|
||||
}
|
||||
|
||||
test('2xx 返回 TransportResult(etag 头捕获)', () async {
|
||||
final dio = stubDio((o) async => const (200, '"etag-9"'));
|
||||
final dartTransport = DartHttpTransport(dio: dio);
|
||||
final file = _tempFile();
|
||||
|
||||
final result = await dartTransport.send(
|
||||
taskId: 't',
|
||||
url: 'http://obj.test/f.jpg',
|
||||
method: 'PUT',
|
||||
headers: const {'Content-Type': 'image/jpeg'},
|
||||
filePath: file.path,
|
||||
);
|
||||
expect(result.etag, '"etag-9"');
|
||||
dartTransport.dispose();
|
||||
});
|
||||
|
||||
test('403 → TransportRenewRequiredException', () async {
|
||||
final dio = stubDio((o) async => const (403, null));
|
||||
final dartTransport = DartHttpTransport(dio: dio);
|
||||
final err = await captureError(
|
||||
dartTransport.send(
|
||||
taskId: 't',
|
||||
url: 'http://obj.test/f.jpg',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: _tempFile().path,
|
||||
),
|
||||
);
|
||||
expect(err, isA<TransportRenewRequiredException>());
|
||||
dartTransport.dispose();
|
||||
});
|
||||
|
||||
test('连接失败 → network;取消令牌 → cancelled', () async {
|
||||
final file = _tempFile();
|
||||
final dio = stubDio((o) async {
|
||||
throw DioException(
|
||||
requestOptions: o,
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
});
|
||||
final dartTransport = DartHttpTransport(dio: dio);
|
||||
final err = await captureError(
|
||||
dartTransport.send(
|
||||
taskId: 't',
|
||||
url: 'http://obj.test/f.jpg',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: file.path,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(err as TransportException).code,
|
||||
TransportErrorCodes.network,
|
||||
);
|
||||
dartTransport.dispose();
|
||||
|
||||
final cancelDio = stubDio((o) async {
|
||||
throw DioException(
|
||||
requestOptions: o,
|
||||
type: DioExceptionType.cancel,
|
||||
);
|
||||
});
|
||||
final cancelTransport = DartHttpTransport(dio: cancelDio);
|
||||
final token = CancelToken()..cancel();
|
||||
final cerr = await captureError(
|
||||
cancelTransport.send(
|
||||
taskId: 't',
|
||||
url: 'http://obj.test/f.jpg',
|
||||
method: 'PUT',
|
||||
headers: const {},
|
||||
filePath: file.path,
|
||||
cancelToken: token,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(cerr as TransportException).code,
|
||||
TransportErrorCodes.cancelled,
|
||||
);
|
||||
cancelTransport.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('传输选择(Android 原生 / FORCE_DART_TRANSPORT)', () {
|
||||
test('选择矩阵:Android→native;强制 Dart / 非 Android→dart', () {
|
||||
expect(preferNativeTransport(isAndroid: true), isTrue);
|
||||
expect(preferNativeTransport(isAndroid: false), isFalse);
|
||||
expect(
|
||||
preferNativeTransport(isAndroid: true, forceDartTransport: true),
|
||||
isFalse,
|
||||
reason: 'FORCE_DART_TRANSPORT 覆盖',
|
||||
);
|
||||
expect(
|
||||
preferNativeTransport(isAndroid: true, forceDartTransport: false),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('缺省解析:VM 测试环境(非 Android)→ DartHttpTransport', () {
|
||||
final resolved = defaultUploadTransport();
|
||||
expect(resolved, isA<DartHttpTransport>());
|
||||
resolved.dispose();
|
||||
});
|
||||
|
||||
test('kForceDartTransport 常量缺省 false(未传 dart-define 时)', () {
|
||||
expect(kForceDartTransport, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File _tempFile() {
|
||||
final dir = Directory.systemTemp.createTempSync('transport-');
|
||||
return File('${dir.path}/f.bin')..writeAsBytesSync(List<int>.filled(16, 1));
|
||||
}
|
||||
|
||||
/// 打桩适配器:router 返回 (status, etag) 记录或抛异常;请求流消费后按
|
||||
/// 状态构造响应(headers 携带 ETag)。
|
||||
class _StubAdapter implements HttpClientAdapter {
|
||||
_StubAdapter(this.router);
|
||||
|
||||
final Future<Object> Function(RequestOptions options) router;
|
||||
|
||||
@override
|
||||
Future<ResponseBody> fetch(
|
||||
RequestOptions options,
|
||||
Stream? requestStream,
|
||||
Future? cancelFuture,
|
||||
) async {
|
||||
if (requestStream != null) {
|
||||
await requestStream.drain<Object?>();
|
||||
}
|
||||
final result = await router(options);
|
||||
if (result is (int, String?)) {
|
||||
final (status, etag) = result;
|
||||
return ResponseBody.fromString(
|
||||
'',
|
||||
status,
|
||||
headers: {
|
||||
if (etag != null) 'etag': [etag],
|
||||
},
|
||||
);
|
||||
}
|
||||
throw result;
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -15,6 +16,7 @@ import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/core/device_id.dart';
|
||||
import 'package:photo_live/core/error_codes.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
import 'package:photo_live/features/uploads/native_transport.dart';
|
||||
import 'package:photo_live/features/uploads/preparer.dart';
|
||||
import 'package:photo_live/features/uploads/task_state.dart';
|
||||
import 'package:photo_live/features/uploads/task_store.dart';
|
||||
@@ -81,12 +83,14 @@ class Harness {
|
||||
int maxParallel = 2,
|
||||
int keepCompletedDays = 14,
|
||||
void Function(String taskId, int sent, int total)? onProgress,
|
||||
Transport? transport,
|
||||
}) =>
|
||||
UploadEngine(
|
||||
store,
|
||||
api,
|
||||
deviceId,
|
||||
objectDio: objectDio,
|
||||
objectDio: transport == null ? objectDio : null,
|
||||
transport: transport,
|
||||
now: () => clock,
|
||||
maxParallel: maxParallel,
|
||||
keepCompletedDays: keepCompletedDays,
|
||||
@@ -246,6 +250,68 @@ class _FixedDeviceIdStore implements DeviceIdStore {
|
||||
Future<void> write(String value) async {}
|
||||
}
|
||||
|
||||
/// 一次 send 调用的快照(Task 7 传输委托断言)。
|
||||
class ScriptedTransportCall {
|
||||
const ScriptedTransportCall(
|
||||
this.taskId,
|
||||
this.url,
|
||||
this.method,
|
||||
this.headers,
|
||||
this.filePath,
|
||||
this.metadata,
|
||||
);
|
||||
final String taskId;
|
||||
final String url;
|
||||
final String method;
|
||||
final Map<String, String> headers;
|
||||
final String filePath;
|
||||
final Map<String, Object?>? metadata;
|
||||
}
|
||||
|
||||
/// 脚本化传输替身(P3 计划 Task 7:引擎 × Transport 接口集成)。
|
||||
///
|
||||
/// 每任务按队列依次弹出手本:[TransportResult] 即成功返回;其余视为
|
||||
/// 异常抛出(TransportException 族)。无手本任务默认成功并补发一次
|
||||
/// 全量进度回调。
|
||||
class ScriptedTransport implements Transport {
|
||||
final Map<String, ListQueue<Object>> script = {};
|
||||
final List<ScriptedTransportCall> calls = [];
|
||||
bool progressBeforeComplete = true;
|
||||
|
||||
void enqueue(String taskId, List<Object> outcomes) =>
|
||||
script.putIfAbsent(taskId, ListQueue.new).addAll(outcomes);
|
||||
|
||||
@override
|
||||
Future<TransportResult> send({
|
||||
required String taskId,
|
||||
required String url,
|
||||
required String method,
|
||||
required Map<String, String> headers,
|
||||
required String filePath,
|
||||
Map<String, Object?>? metadata,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
calls.add(
|
||||
ScriptedTransportCall(taskId, url, method, headers, filePath, metadata),
|
||||
);
|
||||
final queue = script[taskId];
|
||||
final outcome = queue == null || queue.isEmpty
|
||||
? const TransportResult()
|
||||
: queue.removeFirst();
|
||||
if (outcome is TransportResult) {
|
||||
if (progressBeforeComplete) {
|
||||
onProgress?.call(1024, 1024);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
throw outcome;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Harness h;
|
||||
late Directory tmp;
|
||||
@@ -872,4 +938,163 @@ void main() {
|
||||
// 文件检查在 PUT 阶段(init 已发生、无 PUT/complete)。
|
||||
expect(h.events, ['init:ct-q']);
|
||||
});
|
||||
|
||||
group('传输委托(Task 7 Transport 接口)', () {
|
||||
late ScriptedTransport transport;
|
||||
|
||||
setUp(() {
|
||||
transport = ScriptedTransport();
|
||||
});
|
||||
|
||||
test('引擎 PUT 委托 Transport:凭证透传 + init/complete 仍在 Dart 引擎',
|
||||
() async {
|
||||
final file = photoFile('t1.jpg');
|
||||
final progress = <String, List<int>>{};
|
||||
engine = h.buildEngine(
|
||||
transport: transport,
|
||||
onProgress: (id, sent, total) =>
|
||||
progress.putIfAbsent(id, () => <int>[]).add(sent),
|
||||
);
|
||||
engine.start('alice', period: const Duration(hours: 1));
|
||||
|
||||
final seeded = await h.seedTask(file, clientTaskId: 'ct-t1');
|
||||
await cycle();
|
||||
|
||||
final t = await task(seeded.id);
|
||||
expect(t.state, UploadTaskState.completed);
|
||||
expect(t.serverUploadId, 'upl1');
|
||||
// 原生路径绕过 dio:对象适配器无请求,API 序列只含 init/complete。
|
||||
expect(h.objectAdapter.requests, isEmpty);
|
||||
expect(h.events, ['init:ct-t1', 'complete:upl1']);
|
||||
|
||||
// 委托参数:init 返回的凭证原样透传(URL/方法/头/文件路径/元数据)。
|
||||
final call = transport.calls.single;
|
||||
expect(call.taskId, seeded.id);
|
||||
expect(call.url, 'http://object.test.local/staging/upl1/pho1.jpg');
|
||||
expect(call.method, 'PUT');
|
||||
expect(call.headers, {'Content-Type': 'image/jpeg'});
|
||||
expect(call.filePath, file.path);
|
||||
expect(call.metadata?['uploadId'], 'upl1');
|
||||
expect(call.metadata?['attempt'], 0);
|
||||
// 进度回调由 Transport 上报(队列 UI 挂接点不变)。
|
||||
expect(progress[seeded.id], [1024]);
|
||||
});
|
||||
|
||||
test('原生 needsRenew:引擎 renew 后携带新 URL 重新委托', () async {
|
||||
final file = photoFile('t2.jpg');
|
||||
engine = h.buildEngine(transport: transport);
|
||||
engine.start('alice', period: const Duration(hours: 1));
|
||||
|
||||
final seeded = await h.seedTask(file, clientTaskId: 'ct-t2');
|
||||
transport.enqueue(seeded.id, [
|
||||
const TransportRenewRequiredException(),
|
||||
const TransportResult(),
|
||||
]);
|
||||
await cycle();
|
||||
|
||||
final t = await task(seeded.id);
|
||||
expect(t.state, UploadTaskState.completed);
|
||||
expect(h.renewCalls, 1);
|
||||
expect(transport.calls, hasLength(2));
|
||||
expect(transport.calls.first.url, endsWith('upl1/pho1.jpg'));
|
||||
expect(transport.calls.last.url, endsWith('upl1/pho1.jpg?renewed=1'));
|
||||
expect(h.events, ['init:ct-t2', 'renew:upl1', 'complete:upl1']);
|
||||
});
|
||||
|
||||
test('原生 network 失败 → waitingNetwork 退避;恢复后重新委托完成',
|
||||
() async {
|
||||
final file = photoFile('t3.jpg');
|
||||
engine = h.buildEngine(transport: transport);
|
||||
engine.start('alice', period: const Duration(hours: 1));
|
||||
|
||||
final seeded = await h.seedTask(file, clientTaskId: 'ct-t3');
|
||||
transport.enqueue(seeded.id, [
|
||||
const TransportException(TransportErrorCodes.network),
|
||||
]);
|
||||
await cycle();
|
||||
|
||||
var t = await task(seeded.id);
|
||||
expect(t.state, UploadTaskState.waitingNetwork);
|
||||
expect(t.lastError, ErrorCodes.networkError);
|
||||
expect(t.attempt, 1);
|
||||
expect(t.nextRetryAt, h.clock.add(const Duration(seconds: 5)));
|
||||
|
||||
// 到期重试:无手本 → 默认成功。
|
||||
h.clock = h.clock.add(const Duration(seconds: 10));
|
||||
await cycle();
|
||||
t = await task(seeded.id);
|
||||
expect(t.state, UploadTaskState.completed);
|
||||
expect(transport.calls, hasLength(2));
|
||||
});
|
||||
|
||||
test('原生 HTTP_4XX(400) → retryableFailed(自动重试退避)', () async {
|
||||
final file = photoFile('t4.jpg');
|
||||
engine = h.buildEngine(transport: transport);
|
||||
engine.start('alice', period: const Duration(hours: 1));
|
||||
|
||||
final seeded = await h.seedTask(file, clientTaskId: 'ct-t4');
|
||||
transport.enqueue(seeded.id, [
|
||||
const TransportException(TransportErrorCodes.http, statusCode: 400),
|
||||
]);
|
||||
await cycle();
|
||||
|
||||
final t = await task(seeded.id);
|
||||
expect(t.state, UploadTaskState.retryableFailed);
|
||||
expect(t.lastError, ErrorCodes.httpError);
|
||||
expect(t.attempt, 1);
|
||||
expect(t.nextRetryAt, isNotNull, reason: 'attempt 1 < 3:仍自动重试');
|
||||
});
|
||||
|
||||
test('原生 HTTP 401 → waitingNetwork 短退避(会话语义)', () async {
|
||||
final file = photoFile('t5.jpg');
|
||||
engine = h.buildEngine(transport: transport);
|
||||
engine.start('alice', period: const Duration(hours: 1));
|
||||
|
||||
final seeded = await h.seedTask(file, clientTaskId: 'ct-t5');
|
||||
transport.enqueue(seeded.id, [
|
||||
const TransportException(TransportErrorCodes.http, statusCode: 401),
|
||||
]);
|
||||
await cycle();
|
||||
|
||||
final t = await task(seeded.id);
|
||||
expect(t.state, UploadTaskState.waitingNetwork);
|
||||
expect(t.lastError, ErrorCodes.authTokenExpired);
|
||||
expect(t.attempt, 0, reason: '会话过期不烧次数');
|
||||
expect(t.nextRetryAt, h.clock.add(const Duration(seconds: 30)));
|
||||
});
|
||||
|
||||
test('原生 SOURCE_MISSING → 不自动重试的 retryableFailed', () async {
|
||||
final file = photoFile('t6.jpg');
|
||||
engine = h.buildEngine(transport: transport);
|
||||
engine.start('alice', period: const Duration(hours: 1));
|
||||
|
||||
final seeded = await h.seedTask(file, clientTaskId: 'ct-t6');
|
||||
transport.enqueue(seeded.id, [
|
||||
const TransportException(TransportErrorCodes.sourceMissing),
|
||||
]);
|
||||
await cycle();
|
||||
|
||||
final t = await task(seeded.id);
|
||||
expect(t.state, UploadTaskState.retryableFailed);
|
||||
expect(t.lastError, UploadEngine.sourceMissingCode);
|
||||
expect(t.nextRetryAt, isNull);
|
||||
});
|
||||
|
||||
test('原生 SERVER_ERROR(500) → retryableFailed(5xx 语义桶)', () async {
|
||||
final file = photoFile('t7.jpg');
|
||||
engine = h.buildEngine(transport: transport);
|
||||
engine.start('alice', period: const Duration(hours: 1));
|
||||
|
||||
final seeded = await h.seedTask(file, clientTaskId: 'ct-t7');
|
||||
transport.enqueue(seeded.id, [
|
||||
const TransportException(TransportErrorCodes.http, statusCode: 500),
|
||||
]);
|
||||
await cycle();
|
||||
|
||||
final t = await task(seeded.id);
|
||||
expect(t.state, UploadTaskState.retryableFailed);
|
||||
expect(t.lastError, ErrorCodes.internalError);
|
||||
expect(t.attempt, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user