3ddb0ea2d4
Co-Authored-By: Claude Code <noreply@anthropic.com>
876 lines
31 KiB
Dart
876 lines
31 KiB
Dart
/// 上传引擎(UploadEngine)测试(P3 计划 Task 6)。
|
||
///
|
||
/// 打桩面:FakeAdapter ×2(API 侧 /v1/* 与对象侧 presign PUT)+ 内存
|
||
/// TaskStore + 真实临时文件 + 可变时钟。覆盖:快乐路径调用序列、幂等
|
||
/// 重试、断网恢复、退避与自动重试上限、409/401/403 分类、取消、账号
|
||
/// 隔离、A15 归属不变、并行上限、崩溃恢复、进度回调、清理执行器。
|
||
library;
|
||
|
||
import 'dart:async';
|
||
import 'dart:io';
|
||
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
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/preparer.dart';
|
||
import 'package:photo_live/features/uploads/task_state.dart';
|
||
import 'package:photo_live/features/uploads/task_store.dart';
|
||
import 'package:photo_live/features/uploads/upload_engine.dart';
|
||
|
||
import '../../helpers/fakes.dart';
|
||
|
||
const String _kDeviceId = '0123456789abcdef0123456789abcdef';
|
||
|
||
/// 引擎测试装配:双适配器假服务 + 内存库 + 临时文件 + 可变时钟。
|
||
class Harness {
|
||
Harness() {
|
||
apiAdapter = FakeAdapter(_apiRouter);
|
||
objectAdapter = FakeAdapter(_objectRouter);
|
||
api = ApiClient(
|
||
sessionStore: sessionStore,
|
||
dio: Dio(BaseOptions(baseUrl: 'http://api.test.local'))
|
||
..httpClientAdapter = apiAdapter,
|
||
);
|
||
objectDio = Dio()..httpClientAdapter = objectAdapter;
|
||
store = TaskStore.memory();
|
||
deviceId = DeviceId(store: _FixedDeviceIdStore(_kDeviceId));
|
||
}
|
||
|
||
late final FakeAdapter apiAdapter;
|
||
late final FakeAdapter objectAdapter;
|
||
late final ApiClient api;
|
||
late final Dio objectDio;
|
||
late final TaskStore store;
|
||
late final DeviceId deviceId;
|
||
final FakeSessionStore sessionStore = FakeSessionStore()
|
||
..session = Session(token: 'tok-1', account: 'alice');
|
||
|
||
/// 可变时钟(测试推进)。
|
||
DateTime clock = DateTime.utc(2026, 9, 19, 12, 0, 0);
|
||
|
||
/// 服务端调用事件序列(跨适配器统一断言)。
|
||
final List<String> events = <String>[];
|
||
|
||
/// clientTaskId → init 响应体(幂等:同键回原会话)。
|
||
final Map<String, Map<String, dynamic>> sessionsByClientTaskId =
|
||
<String, Map<String, dynamic>>{};
|
||
|
||
/// uploadId → init 响应体(renew/complete 回显)。
|
||
final Map<String, Map<String, dynamic>> sessionsByUploadId =
|
||
<String, Map<String, dynamic>>{};
|
||
|
||
final List<Map<String, dynamic>> initBodies = <Map<String, dynamic>>[];
|
||
int renewCalls = 0;
|
||
|
||
// ── 故障注入旋钮(次数制)──
|
||
int initNetworkFailures = 0;
|
||
int initUnauthorized = 0;
|
||
int initAlbumDisabled = 0;
|
||
int completeVerifyFailures = 0;
|
||
int completeNetworkFailures = 0;
|
||
int putNetworkFailures = 0;
|
||
int put403Count = 0;
|
||
|
||
/// PUT 闸门(并行/取消测试:挂起全部 PUT 直到放开)。
|
||
Completer<void>? putGate;
|
||
|
||
UploadEngine buildEngine({
|
||
int maxParallel = 2,
|
||
int keepCompletedDays = 14,
|
||
void Function(String taskId, int sent, int total)? onProgress,
|
||
}) =>
|
||
UploadEngine(
|
||
store,
|
||
api,
|
||
deviceId,
|
||
objectDio: objectDio,
|
||
now: () => clock,
|
||
maxParallel: maxParallel,
|
||
keepCompletedDays: keepCompletedDays,
|
||
onProgress: onProgress,
|
||
);
|
||
|
||
Map<String, dynamic> _initBodyFor(String uploadId, String photoId,
|
||
{bool renewed = false}) {
|
||
return <String, dynamic>{
|
||
'uploadId': uploadId,
|
||
'photoId': photoId,
|
||
'objectKey': 'photo-live/dev/staging/$uploadId/$photoId.jpg',
|
||
'credential': <String, dynamic>{
|
||
'mode': 'presigned',
|
||
'url':
|
||
'http://object.test.local/staging/$uploadId/$photoId.jpg'
|
||
'${renewed ? '?renewed=1' : ''}',
|
||
'method': 'PUT',
|
||
'headers': {'Content-Type': 'image/jpeg'},
|
||
'expiresAt': '2026-09-19T13:00:00.000Z',
|
||
},
|
||
};
|
||
}
|
||
|
||
FutureOr<(int, Object?)> _apiRouter(RequestOptions o) {
|
||
if (o.method == 'POST' && o.path == '/v1/uploads') {
|
||
final body = Map<String, dynamic>.from(o.data as Map<dynamic, dynamic>);
|
||
initBodies.add(body);
|
||
final ct = body['clientTaskId'] as String;
|
||
if (initAlbumDisabled > 0) {
|
||
initAlbumDisabled--;
|
||
return (409, errorBody(ErrorCodes.albumUploadDisabled));
|
||
}
|
||
if (initUnauthorized > 0) {
|
||
initUnauthorized--;
|
||
return (401, errorBody(ErrorCodes.authTokenExpired));
|
||
}
|
||
if (initNetworkFailures > 0) {
|
||
initNetworkFailures--;
|
||
throw DioException(
|
||
requestOptions: o,
|
||
type: DioExceptionType.connectionError,
|
||
);
|
||
}
|
||
final existing = sessionsByClientTaskId[ct];
|
||
if (existing != null) {
|
||
events.add('init:$ct(idem)');
|
||
return (201, existing);
|
||
}
|
||
final n = sessionsByClientTaskId.length + 1;
|
||
final uploadId = 'upl$n';
|
||
final photoId = 'pho$n';
|
||
final initBody = _initBodyFor(uploadId, photoId);
|
||
sessionsByClientTaskId[ct] = initBody;
|
||
sessionsByUploadId[uploadId] = initBody;
|
||
events.add('init:$ct');
|
||
return (201, initBody);
|
||
}
|
||
final renew =
|
||
RegExp(r'^/v1/uploads/([^/]+)/renew$').firstMatch(o.path);
|
||
if (renew != null) {
|
||
renewCalls++;
|
||
final uploadId = renew.group(1)!;
|
||
events.add('renew:$uploadId');
|
||
final original = sessionsByUploadId[uploadId]!;
|
||
return (
|
||
200,
|
||
_initBodyFor(uploadId, original['photoId'] as String, renewed: true),
|
||
);
|
||
}
|
||
final complete =
|
||
RegExp(r'^/v1/uploads/([^/]+)/complete$').firstMatch(o.path);
|
||
if (complete != null) {
|
||
final uploadId = complete.group(1)!;
|
||
if (completeVerifyFailures > 0) {
|
||
completeVerifyFailures--;
|
||
events.add('complete-fail:$uploadId');
|
||
return (400, errorBody(ErrorCodes.uploadVerifyFailed));
|
||
}
|
||
if (completeNetworkFailures > 0) {
|
||
completeNetworkFailures--;
|
||
throw DioException(
|
||
requestOptions: o,
|
||
type: DioExceptionType.connectionError,
|
||
);
|
||
}
|
||
events.add('complete:$uploadId');
|
||
final original = sessionsByUploadId[uploadId]!;
|
||
return (
|
||
200,
|
||
<String, dynamic>{
|
||
'photoId': original['photoId'],
|
||
'processingState': 'processing',
|
||
},
|
||
);
|
||
}
|
||
throw StateError('意外的 API 请求:${o.method} ${o.path}');
|
||
}
|
||
|
||
FutureOr<(int, Object?)> _objectRouter(RequestOptions o) async {
|
||
events.add('put');
|
||
final gate = putGate;
|
||
if (gate != null) {
|
||
await gate.future;
|
||
}
|
||
if (put403Count > 0) {
|
||
put403Count--;
|
||
return (403, null);
|
||
}
|
||
if (putNetworkFailures > 0) {
|
||
putNetworkFailures--;
|
||
throw DioException(
|
||
requestOptions: o,
|
||
type: DioExceptionType.connectionError,
|
||
);
|
||
}
|
||
return (200, null);
|
||
}
|
||
|
||
/// 种子一条 queued 任务(真实临时文件做源)。
|
||
Future<UploadTask> seedTask(
|
||
File file, {
|
||
String accountId = 'alice',
|
||
String albumId = 'alb-1',
|
||
String? categoryId,
|
||
String clientTaskId = 'ct-default',
|
||
DateTime? createdAt,
|
||
}) async {
|
||
final draft = UploadTaskDraft(
|
||
id: 'task-$clientTaskId',
|
||
accountId: accountId,
|
||
albumId: albumId,
|
||
categoryId: categoryId,
|
||
clientTaskId: clientTaskId,
|
||
sourceFilePath: file.path,
|
||
filename: file.uri.pathSegments.last,
|
||
bytes: await file.length(),
|
||
contentType: 'image/jpeg',
|
||
);
|
||
final task = await store.create(draft, now: createdAt);
|
||
return store.updateState(
|
||
task.id,
|
||
UploadTaskState.queued,
|
||
now: createdAt,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FixedDeviceIdStore implements DeviceIdStore {
|
||
_FixedDeviceIdStore(this._value);
|
||
final String _value;
|
||
|
||
@override
|
||
Future<String?> read() async => _value;
|
||
|
||
@override
|
||
Future<void> write(String value) async {}
|
||
}
|
||
|
||
void main() {
|
||
late Harness h;
|
||
late Directory tmp;
|
||
late UploadEngine engine;
|
||
|
||
setUp(() async {
|
||
h = Harness();
|
||
tmp = await Directory.systemTemp.createTemp('engine-test-');
|
||
});
|
||
|
||
tearDown(() async {
|
||
engine.dispose();
|
||
await h.store.close();
|
||
// Windows:取消竞态下 dio 会孤儿化适配器 fetch(CancelableOperation
|
||
// 置 cancel),被孤儿化的 drain 仍短暂持有文件句柄(errno 32)——
|
||
// 短重试兜底等待句柄释放。
|
||
for (var i = 0; i < 20; i++) {
|
||
try {
|
||
await tmp.delete(recursive: true);
|
||
break;
|
||
} on FileSystemException {
|
||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||
}
|
||
}
|
||
});
|
||
|
||
File photoFile(String name, [int size = 1024]) {
|
||
final f = File('${tmp.path}/$name');
|
||
f.writeAsBytesSync(List<int>.generate(size, (i) => i % 251));
|
||
return f;
|
||
}
|
||
|
||
/// 驱动一轮完整调度并等待收敛(drain 折叠并发调用;双循环兜底
|
||
/// 完成回调触发的补位 drain)。
|
||
Future<void> cycle() async {
|
||
for (var i = 0; i < 3; i++) {
|
||
await engine.drain();
|
||
await engine.settle();
|
||
}
|
||
await engine.drain();
|
||
}
|
||
|
||
/// 泵事件循环直到条件成立(真实 IO/微任务链推进用)。
|
||
Future<void> until(bool Function() cond) async {
|
||
for (var i = 0; i < 100 && !cond(); i++) {
|
||
await pumpEventQueue();
|
||
}
|
||
expect(cond(), isTrue, reason: '等待条件在泵内未成立');
|
||
}
|
||
|
||
Future<UploadTask> task(String id) async {
|
||
final t = await h.store.get('alice', id);
|
||
if (t == null) {
|
||
throw StateError('任务不存在:$id');
|
||
}
|
||
return t;
|
||
}
|
||
|
||
group('快乐路径', () {
|
||
test('全序列 init → PUT → complete → completed;服务端调用与请求体断言',
|
||
() async {
|
||
final file = photoFile('a.jpg');
|
||
final progress = <String, List<int>>{};
|
||
engine = h.buildEngine(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-1');
|
||
await cycle();
|
||
|
||
final t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
expect(t.serverUploadId, 'upl1');
|
||
expect(t.serverPhotoId, 'pho1');
|
||
expect(t.lastError, isNull);
|
||
expect(t.nextRetryAt, isNull);
|
||
expect(h.events, ['init:ct-1', 'put', 'complete:upl1']);
|
||
|
||
// init 请求体:幂等键 + sourceDeviceId(A19)+ 元数据。
|
||
final body = h.initBodies.single;
|
||
expect(body['albumId'], 'alb-1');
|
||
expect(body['clientTaskId'], 'ct-1');
|
||
expect(body['filename'], 'a.jpg');
|
||
expect(body['bytes'], 1024);
|
||
expect(body['contentType'], 'image/jpeg');
|
||
expect(body['sourceDeviceId'], _kDeviceId);
|
||
|
||
// PUT:预签名 URL + 凭证头 + Content-Length;无 Authorization
|
||
// (裸 Dio——Bearer 会破坏 MinIO 预签名)。
|
||
final put = h.objectAdapter.requests.single;
|
||
expect(put.method, 'PUT');
|
||
expect(put.uri.toString(),
|
||
'http://object.test.local/staging/upl1/pho1.jpg');
|
||
expect(put.headers['Content-Type'], 'image/jpeg');
|
||
// dio 以原始键位(小写 const Headers.contentLengthHeader)落请求头。
|
||
expect(put.headers['content-length'], 1024);
|
||
expect(put.headers.containsKey('Authorization'), isFalse);
|
||
|
||
// 源文件即时保留(清理执行器按保留期处理,不随完成删除)。
|
||
expect(file.existsSync(), isTrue);
|
||
// 进度回调按真实字节计数上报(计满 1024)。
|
||
expect(progress[seeded.id]?.last, 1024);
|
||
});
|
||
|
||
test('与准备器集成:preparing → queued → completed(受管副本上传)',
|
||
() async {
|
||
final src = photoFile('src.jpg');
|
||
final managed = Directory('${tmp.path}/managed')..createSync();
|
||
final preparer = UploadPreparer(
|
||
h.store,
|
||
baseDirProvider: () async => managed,
|
||
);
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
|
||
final queued = await preparer.prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePath: src.path,
|
||
);
|
||
expect(queued.state, UploadTaskState.queued);
|
||
await cycle();
|
||
|
||
final t = await task(queued.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
// PUT 的 body 是受管副本(大小一致,路径在受管目录下)。
|
||
expect(t.sourceFilePath, contains('managed'));
|
||
expect(File(t.sourceFilePath).lengthSync(), 1024);
|
||
expect(h.events.last, 'complete:upl1');
|
||
});
|
||
});
|
||
|
||
group('幂等重试', () {
|
||
test('PUT 中途网络失败 → waitingNetwork → 到期重试:init 复用 '
|
||
'clientTaskId、服务端同会话同照片,最终单照片 completed', () async {
|
||
final file = photoFile('b.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
h.putNetworkFailures = 1;
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-b');
|
||
await cycle();
|
||
|
||
var t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.waitingNetwork);
|
||
expect(t.attempt, 1);
|
||
expect(t.serverUploadId, 'upl1', reason: 'init 已成功,会话 ID 保留');
|
||
expect(h.events, ['init:ct-b', 'put']);
|
||
|
||
// 未到退避时间(5s·2^0):不重试。
|
||
h.clock = h.clock.add(const Duration(seconds: 2));
|
||
await cycle();
|
||
expect(h.events, ['init:ct-b', 'put'], reason: '退避未到不重试');
|
||
|
||
// 到期重试:init 幂等(同 clientTaskId 返回原会话)→ 单照片完成。
|
||
h.clock = h.clock.add(const Duration(seconds: 10));
|
||
await cycle();
|
||
t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
expect(t.serverPhotoId, 'pho1');
|
||
expect(h.sessionsByClientTaskId, hasLength(1), reason: '服务端只建了一个会话');
|
||
expect(h.events, ['init:ct-b', 'put', 'init:ct-b(idem)', 'put', 'complete:upl1']);
|
||
expect(h.initBodies, hasLength(2));
|
||
expect(h.initBodies.map((b) => b['clientTaskId']).toSet(), ['ct-b']);
|
||
});
|
||
|
||
test('complete 网络失败 → waitingNetwork;重试只补 complete(PUT 已达)',
|
||
() async {
|
||
final file = photoFile('c.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
h.completeNetworkFailures = 1;
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-c');
|
||
await cycle();
|
||
|
||
var t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.waitingNetwork);
|
||
// 网络失败(非核验失败)不产生服务端事件:序列止于 PUT。
|
||
expect(h.events, ['init:ct-c', 'put']);
|
||
expect(h.events.where((e) => e.startsWith('complete')), isEmpty);
|
||
|
||
h.clock = h.clock.add(const Duration(seconds: 30));
|
||
await cycle();
|
||
t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
// 重试路径:init 幂等 + PUT 重传(服务端对象覆盖写)+ complete。
|
||
expect(h.events, ['init:ct-c', 'put', 'init:ct-c(idem)', 'put', 'complete:upl1']);
|
||
});
|
||
});
|
||
|
||
group('断网恢复', () {
|
||
test('网络失败退避等待;onNetworkRestored 立即绕过退避完成', () async {
|
||
final file = photoFile('d.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
h.initNetworkFailures = 1;
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-d');
|
||
await cycle();
|
||
|
||
var t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.waitingNetwork);
|
||
expect(t.lastError, ErrorCodes.networkError);
|
||
expect(t.nextRetryAt, h.clock.add(const Duration(seconds: 5)));
|
||
|
||
// 时钟未推进(退避未到),但网络恢复信号触发立即重试。
|
||
engine.onNetworkRestored();
|
||
await cycle();
|
||
|
||
t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
expect(h.initBodies, hasLength(2));
|
||
});
|
||
});
|
||
|
||
group('退避曲线与自动重试上限', () {
|
||
test('backoffFor:5s·2^(n-1) 封顶 10min', () {
|
||
expect(UploadEngine.backoffFor(1), const Duration(seconds: 5));
|
||
expect(UploadEngine.backoffFor(2), const Duration(seconds: 10));
|
||
expect(UploadEngine.backoffFor(3), const Duration(seconds: 20));
|
||
expect(UploadEngine.backoffFor(7), const Duration(seconds: 320));
|
||
// 5s·2^7=640s 越过封顶 → 600s。
|
||
expect(UploadEngine.backoffFor(8), const Duration(minutes: 10));
|
||
expect(UploadEngine.backoffFor(100), const Duration(minutes: 10));
|
||
expect(UploadEngine.backoffFor(0), const Duration(seconds: 5));
|
||
});
|
||
|
||
test('UPLOAD_VERIFY_FAILED 三次后停止自动重试(nextRetryAt=null 终止符)',
|
||
() async {
|
||
final file = photoFile('e.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
h.completeVerifyFailures = 3;
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-e');
|
||
await cycle();
|
||
|
||
var t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.retryableFailed);
|
||
expect(t.attempt, 1);
|
||
expect(t.lastError, ErrorCodes.uploadVerifyFailed);
|
||
expect(t.nextRetryAt, isNotNull);
|
||
|
||
h.clock = h.clock.add(const Duration(seconds: 10));
|
||
await cycle();
|
||
t = await task(seeded.id);
|
||
expect(t.attempt, 2);
|
||
|
||
h.clock = h.clock.add(const Duration(seconds: 30));
|
||
await cycle();
|
||
t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.retryableFailed);
|
||
expect(t.attempt, 3);
|
||
expect(t.nextRetryAt, isNull, reason: '达上限:不再自动重试');
|
||
|
||
final initCount = h.initBodies.length;
|
||
h.clock = h.clock.add(const Duration(hours: 1));
|
||
await cycle();
|
||
expect(h.initBodies.length, initCount, reason: '终止符后不再出队');
|
||
expect((await task(seeded.id)).state, UploadTaskState.retryableFailed);
|
||
});
|
||
|
||
test('retryNow 手动重试可复活终止符任务', () async {
|
||
final file = photoFile('e2.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
h.completeVerifyFailures = 3;
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-e2');
|
||
await cycle();
|
||
h.clock = h.clock.add(const Duration(seconds: 10));
|
||
await cycle();
|
||
h.clock = h.clock.add(const Duration(seconds: 30));
|
||
await cycle();
|
||
var t = await task(seeded.id);
|
||
expect(t.nextRetryAt, isNull);
|
||
|
||
h.completeVerifyFailures = 0; // 服务端修复。
|
||
await engine.retryNow('alice', seeded.id);
|
||
await cycle();
|
||
t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
});
|
||
});
|
||
|
||
group('服务端状态分类', () {
|
||
test('409 ALBUM_UPLOAD_DISABLED → retryableFailed 不自动重试', () async {
|
||
final file = photoFile('f.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
h.initAlbumDisabled = 1;
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-f');
|
||
await cycle();
|
||
|
||
final t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.retryableFailed);
|
||
expect(t.lastError, ErrorCodes.albumUploadDisabled);
|
||
expect(t.nextRetryAt, isNull);
|
||
expect(t.attempt, 0, reason: '客户端重试无意义,不烧次数');
|
||
|
||
final initCount = h.initBodies.length;
|
||
h.clock = h.clock.add(const Duration(hours: 1));
|
||
await cycle();
|
||
expect(h.initBodies.length, initCount, reason: '无自动重试');
|
||
});
|
||
|
||
test('401 会话过期 → waitingNetwork 短退避;重登录后(重试)完成,'
|
||
'attempt 不增长', () async {
|
||
final file = photoFile('g.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
h.initUnauthorized = 1;
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-g');
|
||
await cycle();
|
||
|
||
var t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.waitingNetwork);
|
||
expect(t.lastError, ErrorCodes.authTokenExpired);
|
||
expect(t.attempt, 0);
|
||
expect(t.nextRetryAt, h.clock.add(const Duration(seconds: 30)));
|
||
|
||
h.clock = h.clock.add(const Duration(seconds: 31));
|
||
await cycle();
|
||
t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
});
|
||
});
|
||
|
||
group('凭证续期(presign 403)', () {
|
||
test('403 → renewUpload → 新 URL 重传成功', () async {
|
||
final file = photoFile('h.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
h.put403Count = 1;
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-h');
|
||
await cycle();
|
||
|
||
final t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
expect(h.renewCalls, 1);
|
||
expect(h.events,
|
||
['init:ct-h', 'put', 'renew:upl1', 'put', 'complete:upl1']);
|
||
// 第二次 PUT 用续期 URL。
|
||
expect(h.objectAdapter.requests.last.uri.query, 'renewed=1');
|
||
});
|
||
|
||
test('403 持续超元数上限 → 分类失败(retryableFailed)', () async {
|
||
final file = photoFile('h2.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
h.put403Count = 99; // 永远 403。
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-h2');
|
||
await cycle();
|
||
|
||
final t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.retryableFailed);
|
||
expect(h.renewCalls, kMaxCredentialRenewals);
|
||
expect(t.attempt, 1, reason: '第 4 次 403 落入通用分类桶');
|
||
});
|
||
});
|
||
|
||
group('取消', () {
|
||
test('未开始(queued):删行 + 删受管文件', () async {
|
||
final file = photoFile('i.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-i');
|
||
final cancelled = await engine.cancel('alice', seeded.id);
|
||
expect(cancelled, isTrue);
|
||
expect(await h.store.get('alice', seeded.id), isNull, reason: '行已删');
|
||
expect(file.existsSync(), isFalse, reason: '受管文件已删');
|
||
expect(h.events, isEmpty, reason: '从未发起传输');
|
||
});
|
||
|
||
test('进行中(uploading):尽力取消——置 cancelled、文件保留、PUT 收敛后不再迁移',
|
||
() async {
|
||
final file = photoFile('j.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
final gate = Completer<void>();
|
||
h.putGate = gate;
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-j');
|
||
// 只启动(PUT 挂起),不 cycle 收敛。泵事件循环直到 PUT 真正发出。
|
||
await engine.drain();
|
||
await until(() => h.objectAdapter.requests.isNotEmpty);
|
||
expect(h.objectAdapter.requests, isNotEmpty, reason: 'PUT 已发出');
|
||
|
||
final cancelled = await engine.cancel('alice', seeded.id);
|
||
expect(cancelled, isTrue);
|
||
expect((await task(seeded.id)).state, UploadTaskState.cancelled);
|
||
|
||
gate.complete();
|
||
await engine.settle();
|
||
await cycle();
|
||
|
||
final t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.cancelled, reason: '取消是终态,PUT 结果不复活');
|
||
expect(file.existsSync(), isTrue, reason: '进行中取消:文件保留给清理执行器');
|
||
expect(h.events.where((e) => e.startsWith('complete')), isEmpty);
|
||
});
|
||
|
||
test('已终态(completed):取消无效返回 false', () async {
|
||
final file = photoFile('i2.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-i2');
|
||
await cycle();
|
||
expect(await engine.cancel('alice', seeded.id), isFalse);
|
||
});
|
||
});
|
||
|
||
group('账号隔离与 A15', () {
|
||
test('引擎只出队当前账号任务(他人队列不动)', () async {
|
||
final mine = photoFile('k1.jpg');
|
||
final theirs = photoFile('k2.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
|
||
final aliceTask = await h.seedTask(mine, clientTaskId: 'ct-k1');
|
||
final bobTask = await h.seedTask(
|
||
theirs,
|
||
accountId: 'bob',
|
||
clientTaskId: 'ct-k2',
|
||
);
|
||
await cycle();
|
||
|
||
expect((await task(aliceTask.id)).state, UploadTaskState.completed);
|
||
final bobRow = await h.store.get('bob', bobTask.id);
|
||
expect(bobRow!.state, UploadTaskState.queued, reason: 'bob 的任务不被触碰');
|
||
expect(h.initBodies.map((b) => b['clientTaskId']), ['ct-k1']);
|
||
});
|
||
|
||
test('A15:连拍中途切换相册/分类不影响既有任务(引擎只读行内目标)',
|
||
() async {
|
||
final f1 = photoFile('l1.jpg');
|
||
final f2 = photoFile('l2.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
|
||
// 第一张:相册 A 分类 X;随后"切到"相册 B 分类 Y 拍第二张。
|
||
final t1 = await h.seedTask(
|
||
f1,
|
||
albumId: 'alb-A',
|
||
categoryId: 'cat-X',
|
||
clientTaskId: 'ct-l1',
|
||
);
|
||
final t2 = await h.seedTask(
|
||
f2,
|
||
albumId: 'alb-B',
|
||
categoryId: 'cat-Y',
|
||
clientTaskId: 'ct-l2',
|
||
);
|
||
await cycle();
|
||
|
||
expect((await task(t1.id)).state, UploadTaskState.completed);
|
||
expect((await task(t2.id)).state, UploadTaskState.completed);
|
||
// 各自 init 携带各自入队时的目标(归属不变)。
|
||
expect(h.initBodies.firstWhere((b) => b['clientTaskId'] == 'ct-l1')
|
||
['albumId'], 'alb-A');
|
||
expect(h.initBodies.firstWhere((b) => b['clientTaskId'] == 'ct-l1')
|
||
['categoryId'], 'cat-X');
|
||
expect(h.initBodies.firstWhere((b) => b['clientTaskId'] == 'ct-l2')
|
||
['albumId'], 'alb-B');
|
||
expect(h.initBodies.firstWhere((b) => b['clientTaskId'] == 'ct-l2')
|
||
['categoryId'], 'cat-Y');
|
||
});
|
||
});
|
||
|
||
group('并行上限', () {
|
||
test('maxParallel=2:三任务闸门下同时只有 2 个 PUT,放开后全部完成',
|
||
() async {
|
||
final files = [
|
||
photoFile('m1.jpg'),
|
||
photoFile('m2.jpg'),
|
||
photoFile('m3.jpg'),
|
||
];
|
||
engine = h.buildEngine(maxParallel: 2);
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
final gate = Completer<void>();
|
||
h.putGate = gate;
|
||
|
||
for (var i = 0; i < files.length; i++) {
|
||
await h.seedTask(files[i], clientTaskId: 'ct-m${i + 1}');
|
||
}
|
||
await engine.drain();
|
||
// 泵到两个 PUT 都被适配器记录(协程 init 异步在途;第三个不发出)。
|
||
await until(() => h.objectAdapter.requests.length == 2);
|
||
|
||
expect(h.objectAdapter.requests, hasLength(2),
|
||
reason: '并行上限 2:第三个 PUT 不发出');
|
||
expect((await h.store.listByAccount('alice'))
|
||
.where((t) => t.state == UploadTaskState.uploading)
|
||
.length, 2);
|
||
|
||
gate.complete();
|
||
await cycle();
|
||
|
||
final states = await h.store.listByAccount('alice');
|
||
expect(states.map((t) => t.state),
|
||
everyElement(UploadTaskState.completed));
|
||
expect(h.objectAdapter.requests, hasLength(3));
|
||
});
|
||
});
|
||
|
||
group('崩溃恢复', () {
|
||
test('卡在 uploading 的任务(进程被杀痕迹)drain 前归位并完成', () async {
|
||
final file = photoFile('n.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
|
||
// 摆出"被杀时"的僵死状态:queued → uploading 后无 in-flight。
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-n');
|
||
await h.store.updateState(seeded.id, UploadTaskState.uploading);
|
||
|
||
await cycle();
|
||
final t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
expect(h.events, ['init:ct-n', 'put', 'complete:upl1']);
|
||
});
|
||
|
||
test('卡在 uploadedConfirming 的任务同样归位重试(complete 幂等)',
|
||
() async {
|
||
final file = photoFile('n2.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-n2');
|
||
await h.store.updateState(seeded.id, UploadTaskState.uploading);
|
||
await h.store.updateState(
|
||
seeded.id,
|
||
UploadTaskState.uploadedConfirming,
|
||
);
|
||
|
||
await cycle();
|
||
expect((await task(seeded.id)).state, UploadTaskState.completed);
|
||
});
|
||
});
|
||
|
||
group('清理执行器', () {
|
||
test('超过保留期的 completed:文件删除 + cleanedAt 标记 + 行保留为记录',
|
||
() async {
|
||
final file = photoFile('o.jpg');
|
||
engine = h.buildEngine(keepCompletedDays: 14);
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-o');
|
||
await cycle();
|
||
expect((await task(seeded.id)).state, UploadTaskState.completed);
|
||
|
||
// 保留期内不清理。
|
||
expect(await engine.cleanup(), 0);
|
||
expect(file.existsSync(), isTrue);
|
||
|
||
// 20 天后(回写 updatedAt 早于截止)。
|
||
final future = h.clock.add(const Duration(days: 20));
|
||
expect(await engine.cleanup(now: future), 1);
|
||
expect(file.existsSync(), isFalse);
|
||
final t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.completed);
|
||
expect(t.cleanedAt, future);
|
||
|
||
// 幂等:已清理不再重复。
|
||
expect(
|
||
await engine.cleanup(now: future.add(const Duration(days: 1))),
|
||
0,
|
||
);
|
||
});
|
||
|
||
test('未完成任务源文件永不清理(硬守护在引擎侧同样成立)', () async {
|
||
final queuedFile = photoFile('p1.jpg');
|
||
final waitingFile = photoFile('p2.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
|
||
final ancient = h.clock.subtract(const Duration(days: 100));
|
||
// queued 任务(seedTask 落库即 queued,updatedAt=ancient)。
|
||
await h.seedTask(
|
||
queuedFile,
|
||
clientTaskId: 'ct-p1',
|
||
createdAt: ancient,
|
||
);
|
||
// waitingNetwork 的古老任务(updatedAt 同样古老)。
|
||
final w = await h.seedTask(
|
||
waitingFile,
|
||
clientTaskId: 'ct-p2',
|
||
createdAt: ancient,
|
||
);
|
||
await h.store.updateState(
|
||
w.id,
|
||
UploadTaskState.waitingNetwork,
|
||
patch: UploadTaskPatch(
|
||
nextRetryAt: h.clock.add(const Duration(hours: 1)),
|
||
),
|
||
now: ancient,
|
||
);
|
||
// q 的 updatedAt 已是 ancient(seedTask 落库时写入)。
|
||
|
||
expect(await engine.cleanup(), 0);
|
||
expect(queuedFile.existsSync(), isTrue);
|
||
expect(waitingFile.existsSync(), isTrue);
|
||
});
|
||
});
|
||
|
||
test('源文件缺失:分类为不自动重试的 retryableFailed', () async {
|
||
final file = photoFile('q.jpg');
|
||
engine = h.buildEngine();
|
||
engine.start('alice', period: const Duration(hours: 1));
|
||
final seeded = await h.seedTask(file, clientTaskId: 'ct-q');
|
||
await file.delete(); // 受管副本被系统清理。
|
||
|
||
await cycle();
|
||
final t = await task(seeded.id);
|
||
expect(t.state, UploadTaskState.retryableFailed);
|
||
expect(t.lastError, UploadEngine.sourceMissingCode);
|
||
expect(t.nextRetryAt, isNull);
|
||
// 文件检查在 PUT 阶段(init 已发生、无 PUT/complete)。
|
||
expect(h.events, ['init:ct-q']);
|
||
});
|
||
}
|