Files
photo-live/apps/mobile/test/features/uploads/preparer_test.dart
T
2026-09-19 02:50:40 +08:00

259 lines
9.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 上传准备器(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'));
});
}