Files
photo-live/apps/mobile/test/features/uploads/preparer_test.dart
T
2026-09-28 13:43:07 +08:00

320 lines
12 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,
int maxConcurrentPrepares = kMaxConcurrentPrepares,
}) =>
UploadPreparer(
store,
baseDirProvider: () async => managedBase,
maxBytes: maxBytes,
maxConcurrentPrepares: maxConcurrentPrepares,
);
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 风暴的上限钳位)', () {
/// 构造带并发探针的准备器:baseDirProvider 在 _prepare 内被 await,
/// 其重叠度是「在途 prepare 数」的可观测下界(且受信号量上限约束)。
(UploadPreparer, int Function()) probePreparer(int limit) {
var active = 0;
var peak = 0;
final p = UploadPreparer(
store,
baseDirProvider: () async {
active++;
if (active > peak) peak = active;
await Future<void>.delayed(const Duration(milliseconds: 30));
active--;
return managedBase;
},
maxConcurrentPrepares: limit,
);
return (p, () => peak);
}
test('并发 prepare 峰值 = maxConcurrentPrepares(≤上限且真并行),9 任务全部 queued', () async {
final (p, peakOf) = probePreparer(3);
final srcs = [
for (var i = 0; i < 9; i++)
sourceFile('par-$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, 9);
expect(tasks.map((t) => t.clientTaskId).toSet().length, 9);
for (final t in tasks) {
expect(File(t.sourceFilePath).existsSync(), isTrue);
}
// 峰值恰为 3:≤3 证明上限钳位(防连拍 IO 风暴初衷保留);
// 达到 3 证明未退化为串行。
expect(peakOf(), 3);
});
test('maxConcurrentPrepares=1 等价旧串行行为:峰值 1、任务仍全部成功', () async {
final (p, peakOf) = probePreparer(1);
final srcs = [
for (var i = 0; i < 4; i++)
sourceFile('ser-$i.jpg', _jpgBytes + [i]),
];
final tasks = await Future.wait([
for (final src in srcs)
p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path),
]);
expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued));
expect(peakOf(), 1, reason: '并发 1 时任意时刻在途 prepare 至多 1(串行)');
});
});
group('串行回归(maxConcurrentPrepares=1 等价旧全局串行队列)', () {
test('并发=1:prepare 按提交顺序串行完成,各自独立成行', () async {
final p = preparer(maxConcurrentPrepares: 1);
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 非降(并发=1 串行的间接证据:无交叉写库)。
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'));
});
}