/// 上传准备器(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 _jpgBytes = [ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, // JFIF 头片段 0x4A, 0x46, 0x49, 0x46, 0x00, ]; final List _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 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()), ); }); }); 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')); }); }