3ddb0ea2d4
Co-Authored-By: Claude Code <noreply@anthropic.com>
259 lines
9.2 KiB
Dart
259 lines
9.2 KiB
Dart
/// 上传准备器(UploadPreparer)测试(P3 计划 Task 6)。
|
||
///
|
||
/// 真实临时目录驱动(Directory.systemTemp)——复制/stat/魔数嗅探均为
|
||
/// 真实文件系统行为;任务库用内存 drift。
|
||
library;
|
||
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter_test/flutter_test.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';
|
||
|
||
/// JPEG/PNG 魔数最小字节。
|
||
final List<int> _jpgBytes = [
|
||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, // JFIF 头片段
|
||
0x4A, 0x46, 0x49, 0x46, 0x00,
|
||
];
|
||
final List<int> _pngBytes = [
|
||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG 签名
|
||
0x00, 0x00, 0x00, 0x0D,
|
||
];
|
||
|
||
void main() {
|
||
late TaskStore store;
|
||
late Directory tmpRoot;
|
||
late Directory sourceDir;
|
||
late Directory managedBase;
|
||
|
||
setUp(() async {
|
||
store = TaskStore.memory();
|
||
tmpRoot = await Directory.systemTemp.createTemp('preparer-test-');
|
||
sourceDir = Directory('${tmpRoot.path}/src')
|
||
..createSync(recursive: true);
|
||
managedBase = Directory('${tmpRoot.path}/managed')
|
||
..createSync(recursive: true);
|
||
});
|
||
|
||
tearDown(() async {
|
||
await store.close();
|
||
await tmpRoot.delete(recursive: true);
|
||
});
|
||
|
||
File sourceFile(String name, List<int> bytes) {
|
||
final f = File('${sourceDir.path}/$name');
|
||
f.writeAsBytesSync(bytes);
|
||
return f;
|
||
}
|
||
|
||
UploadPreparer preparer({int maxBytes = UploadPreparer.kMaxUploadBytes}) =>
|
||
UploadPreparer(
|
||
store,
|
||
baseDirProvider: () async => managedBase,
|
||
maxBytes: maxBytes,
|
||
);
|
||
|
||
group('prepare(快乐路径)', () {
|
||
test('jpg:受管副本存在、行 queued、魔数嗅探 contentType、bytes/filename 落库',
|
||
() async {
|
||
final src = sourceFile('IMG_0001.jpg', _jpgBytes);
|
||
|
||
final task = await preparer().prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
categoryId: 'cat-1',
|
||
sourcePath: src.path,
|
||
);
|
||
|
||
expect(task.state, UploadTaskState.queued);
|
||
expect(task.contentType, 'image/jpeg');
|
||
expect(task.bytes, _jpgBytes.length);
|
||
expect(task.filename, 'IMG_0001.jpg');
|
||
expect(task.categoryId, 'cat-1');
|
||
expect(task.lastError, isNull);
|
||
// 受管路径:{base}/uploads/{accountId}/{albumId}/{taskId}.jpg
|
||
expect(
|
||
task.sourceFilePath,
|
||
matches(RegExp(
|
||
r'[/\\]uploads[/\\]alice[/\\]alb-1[/\\][0-9a-f]{32}\.jpg$',
|
||
)),
|
||
);
|
||
expect(File(task.sourceFilePath).existsSync(), isTrue);
|
||
expect(task.id, task.sourceFilePath.split(RegExp(r'[/\\]')).last.substring(0, 32));
|
||
expect(File(task.sourceFilePath).readAsBytesSync(), _jpgBytes);
|
||
});
|
||
|
||
test('png 魔数覆盖错误扩展名(.png 伪装检查的反向:jpg 扩展实际 png)',
|
||
() async {
|
||
final src = sourceFile('photo.jpg', _pngBytes);
|
||
final task = await preparer().prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePath: src.path,
|
||
);
|
||
expect(task.contentType, 'image/png');
|
||
// 扩展名沿用源(副本文件名带 .jpg)——服务端以 contentType 为准。
|
||
expect(task.sourceFilePath, endsWith('.jpg'));
|
||
});
|
||
|
||
test('heic 无魔数走扩展名映射;filename 可覆盖缺省', () async {
|
||
final src = sourceFile('IMG_0002.heic', List.filled(64, 0x00));
|
||
final task = await preparer().prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePath: src.path,
|
||
filename: '连拍_0002.heic',
|
||
);
|
||
expect(task.contentType, 'image/heic');
|
||
expect(task.filename, '连拍_0002.heic');
|
||
});
|
||
|
||
test('入队回调触发(引擎唤醒挂接点)', () async {
|
||
final src = sourceFile('a.jpg', _jpgBytes);
|
||
var enqueued = 0;
|
||
final p = UploadPreparer(
|
||
store,
|
||
baseDirProvider: () async => managedBase,
|
||
onEnqueued: () => enqueued++,
|
||
);
|
||
await p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path);
|
||
expect(enqueued, 1);
|
||
});
|
||
});
|
||
|
||
group('prepare(预检失败 → preparationFailed)', () {
|
||
test('未知类型(.txt 且无魔数):unsupportedType,且不产副本', () async {
|
||
final src = sourceFile('notes.txt', List.filled(16, 0x41));
|
||
final task = await preparer().prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePath: src.path,
|
||
);
|
||
expect(task.state, UploadTaskState.preparationFailed);
|
||
expect(task.lastError, PrepErrorCodes.unsupportedType);
|
||
expect(File(task.sourceFilePath).existsSync(), isFalse);
|
||
});
|
||
|
||
test('源文件不存在:sourceUnreadable', () async {
|
||
final task = await preparer().prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePath: '${sourceDir.path}/missing.jpg',
|
||
);
|
||
expect(task.state, UploadTaskState.preparationFailed);
|
||
expect(task.lastError, PrepErrorCodes.sourceUnreadable);
|
||
});
|
||
|
||
test('超本地尺寸上限:tooLarge(上限可注入小值)', () async {
|
||
final src = sourceFile('big.jpg', _jpgBytes + List.filled(200, 0x00));
|
||
final task = await preparer(maxBytes: 100).prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePath: src.path,
|
||
);
|
||
expect(task.state, UploadTaskState.preparationFailed);
|
||
expect(task.lastError, PrepErrorCodes.tooLarge);
|
||
});
|
||
|
||
test('复制阶段失败(源在预检后、复制前被删):copyFailed', () async {
|
||
final src = sourceFile('race.jpg', _jpgBytes);
|
||
final p = preparer();
|
||
// 竞态模拟:stat/嗅探通过后、copy 前删源——通过先跑一次拿路径再
|
||
// 直接构造不可复制场景:用目录当源(copy 抛错)。
|
||
final dirAsSource = Directory('${sourceDir.path}/adir')
|
||
..createSync();
|
||
final task = await p.prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePath: dirAsSource.path,
|
||
);
|
||
// 目录 length() 在部分平台返回 stat——Windows 下非 0 会走到 copy 失败;
|
||
// Linux 下目录 stat 为 4096 同样走 copy 失败;若返回 0 则是
|
||
// sourceUnreadable——两者都是 preparationFailed,断言状态为主。
|
||
expect(task.state, UploadTaskState.preparationFailed);
|
||
expect(
|
||
task.lastError,
|
||
anyOf(PrepErrorCodes.copyFailed, PrepErrorCodes.sourceUnreadable),
|
||
);
|
||
expect(src.existsSync(), isTrue, reason: '不相干文件不受影响');
|
||
});
|
||
});
|
||
|
||
group('retry(preparationFailed → queued)', () {
|
||
test('提供新源后重试成功并清除错误', () async {
|
||
final src = sourceFile('r1.jpg', _jpgBytes);
|
||
final p = preparer();
|
||
final failed = await p.prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePath: '${sourceDir.path}/missing.jpg',
|
||
);
|
||
expect(failed.state, UploadTaskState.preparationFailed);
|
||
|
||
final retried = await p.retry(
|
||
accountId: 'alice',
|
||
taskId: failed.id,
|
||
sourcePath: src.path,
|
||
);
|
||
expect(retried.state, UploadTaskState.queued);
|
||
expect(retried.lastError, isNull);
|
||
expect(File(retried.sourceFilePath).existsSync(), isTrue);
|
||
});
|
||
|
||
test('非 preparationFailed 状态拒绝重试', () async {
|
||
final src = sourceFile('r2.jpg', _jpgBytes);
|
||
final p = preparer();
|
||
final queued = await p.prepare(
|
||
accountId: 'alice',
|
||
albumId: 'alb-1',
|
||
sourcePath: src.path,
|
||
);
|
||
await expectLater(
|
||
p.retry(accountId: 'alice', taskId: queued.id, sourcePath: src.path),
|
||
throwsA(isA<StateError>()),
|
||
);
|
||
});
|
||
});
|
||
|
||
group('串行执行(连拍防 IO 风暴)', () {
|
||
test('并发 prepare 按提交顺序串行完成,各自独立成行', () async {
|
||
final p = preparer();
|
||
final srcs = [
|
||
for (var i = 0; i < 3; i++)
|
||
sourceFile('burst-$i.jpg', _jpgBytes + [i]),
|
||
];
|
||
final tasks = await Future.wait([
|
||
for (final src in srcs)
|
||
p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path),
|
||
]);
|
||
// 全部入队、ID/幂等键互不相同、副本都在。
|
||
expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued));
|
||
expect(tasks.map((t) => t.id).toSet().length, 3);
|
||
expect(tasks.map((t) => t.clientTaskId).toSet().length, 3);
|
||
for (final t in tasks) {
|
||
expect(File(t.sourceFilePath).existsSync(), isTrue);
|
||
}
|
||
// createdAt 严格递增(串行执行的间接证据:无交叉写库)。
|
||
final stamps = tasks.map((t) => t.createdAt).toList();
|
||
for (var i = 1; i < stamps.length; i++) {
|
||
expect(!stamps[i].isBefore(stamps[i - 1]), isTrue);
|
||
}
|
||
});
|
||
});
|
||
|
||
test('账号/相册隔离的受管目录(不同账号不同目录树)', () async {
|
||
final s1 = sourceFile('u1.jpg', _jpgBytes);
|
||
final s2 = sourceFile('u2.jpg', _jpgBytes);
|
||
final p = preparer();
|
||
final t1 = await p.prepare(
|
||
accountId: 'alice', albumId: 'alb-1', sourcePath: s1.path);
|
||
final t2 = await p.prepare(
|
||
accountId: 'bob', albumId: 'alb-2', sourcePath: s2.path);
|
||
expect(t1.sourceFilePath, contains('alice'));
|
||
expect(t1.sourceFilePath, contains('alb-1'));
|
||
expect(t2.sourceFilePath, contains('bob'));
|
||
expect(t2.sourceFilePath, contains('alb-2'));
|
||
});
|
||
}
|