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

545 lines
20 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.
/// 上传任务库(TaskStore)测试(P3 计划 Task 5)。
///
/// 内存库(drift NativeDatabase.memory)驱动,覆盖:
/// - CRUD 与幂等键(clientTaskId 同账号同相册唯一,应用侧校验);
/// - 状态迁移守护(updateState 非法迁移拒绝且不落库);
/// - 账号隔离硬守护(读路径无条件 accountId 过滤);
/// - 列表筛选(相册/状态/时间)与 FIFO 排序;
/// - countsByState(队列 UI 徽章);
/// - 源文件保留策略(cleanupCandidates 仅终态+过期;未完成永不清理——
/// 评审缺口 1 的硬守护测试);
/// - datetime 文本落库的亚秒精度。
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
/// 可覆盖默认值的 draft 构造器。
UploadTaskDraft draft({
String id = 'task-0001',
String accountId = 'alice',
String albumId = 'alb-1',
String? categoryId,
String clientTaskId = 'ct-0001',
String sourceFilePath = '/managed/uploads/alb-1/task-0001.jpg',
String filename = 'IMG_0001.jpg',
int bytes = 1024,
String contentType = 'image/jpeg',
}) {
return UploadTaskDraft(
id: id,
accountId: accountId,
albumId: albumId,
categoryId: categoryId,
clientTaskId: clientTaskId,
sourceFilePath: sourceFilePath,
filename: filename,
bytes: bytes,
contentType: contentType,
);
}
/// 将任务沿合法路径推进到目标状态(供 cleanup/counts 测试摆数据)。
Future<UploadTask> advanceTo(
TaskStore store,
String id,
UploadTaskState target, {
DateTime? at,
}) async {
var current = (await store.get('alice', id))!;
const path = <UploadTaskState, List<UploadTaskState>>{
UploadTaskState.queued: [UploadTaskState.queued],
UploadTaskState.uploading: [UploadTaskState.queued, UploadTaskState.uploading],
UploadTaskState.completed: [
UploadTaskState.queued,
UploadTaskState.uploading,
UploadTaskState.uploadedConfirming,
UploadTaskState.completed,
],
UploadTaskState.cancelled: [UploadTaskState.cancelled],
UploadTaskState.waitingNetwork: [
UploadTaskState.queued,
UploadTaskState.waitingNetwork,
],
UploadTaskState.retryableFailed: [
UploadTaskState.queued,
UploadTaskState.uploading,
UploadTaskState.retryableFailed,
],
};
for (final state in path[target]!) {
current = await store.updateState(id, state, now: at);
}
expect(current.state, target);
return current;
}
void main() {
late TaskStore store;
setUp(() {
store = TaskStore.memory();
});
tearDown(() async {
await store.close();
});
group('create(初始态与幂等键)', () {
test('初始状态 preparing、attempt=0、可空字段空、时间戳落库', () async {
final createdAt = DateTime.utc(2026, 9, 19, 1, 2, 3, 456);
final task = await store.create(draft(), now: createdAt);
expect(task.state, UploadTaskState.preparing);
expect(task.attempt, 0);
expect(task.nextRetryAt, isNull);
expect(task.serverUploadId, isNull);
expect(task.serverPhotoId, isNull);
expect(task.lastError, isNull);
expect(task.cleanedAt, isNull);
expect(task.accountId, 'alice');
expect(task.albumId, 'alb-1');
expect(task.bytes, 1024);
expect(task.createdAt, createdAt);
expect(task.updatedAt, createdAt);
});
test('datetime 文本落库保住亚秒/微秒精度,且本地输入统一 UTC 落库', () async {
final stamp = DateTime.utc(2026, 9, 19, 8, 30, 15, 123, 456);
await store.create(draft(), now: stamp);
final task = await store.get('alice', 'task-0001');
expect(task!.createdAt, stamp); // 完整相等而非秒级截断
expect(task.updatedAt, stamp);
// 本地时区输入(DateTime.now() 非 UTC):同一时刻、UTC 归一落库——
// 文本存储下字典序即时间序,SQL 时间比较(since/cutoff)不因
// `Z`/`+08:00` 后缀混存而错位。
final local = DateTime.now();
await store.create(draft(id: 'task-local', clientTaskId: 'ct-local'), now: local);
final normalized = await store.get('alice', 'task-local');
expect(normalized!.createdAt.isUtc, isTrue);
expect(normalized.createdAt, local.toUtc());
});
test('同账号同相册重复 clientTaskId 拒绝(应用侧唯一性)', () async {
await store.create(draft());
await expectLater(
store.create(draft(id: 'task-0002')),
throwsA(isA<StateError>()),
);
// 唯一存在的仍是第一条。
final tasks = await store.listByAccount('alice');
expect(tasks, hasLength(1));
expect(tasks.single.id, 'task-0001');
});
test('幂等键作用域=账号+相册:换相册或换账号可复用同一 clientTaskId', () async {
await store.create(draft());
await store.create(
draft(id: 'task-0002', albumId: 'alb-2'),
);
await store.create(
draft(id: 'task-0003', accountId: 'bob'),
);
expect(await store.countsByState('alice'), containsPair(
UploadTaskState.preparing, 2));
expect(await store.countsByState('bob'), containsPair(
UploadTaskState.preparing, 1));
});
});
group('updateState(迁移守护 + 旁路字段)', () {
test('全生命周期推进:patch 写入 serverId/attempt/nextRetryAt/lastError 并可在成功时清除', () async {
await store.create(draft());
final t0 = DateTime.utc(2026, 9, 19, 9);
final queued = await store.updateState(
'task-0001',
UploadTaskState.queued,
now: t0,
);
expect(queued.state, UploadTaskState.queued);
expect(queued.updatedAt, t0);
final uploading = await store.updateState(
'task-0001',
UploadTaskState.uploading,
patch: UploadTaskPatch(
serverUploadId: 'upl-1',
serverPhotoId: 'pho-1',
),
now: t0.add(const Duration(minutes: 1)),
);
expect(uploading.serverUploadId, 'upl-1');
expect(uploading.serverPhotoId, 'pho-1');
// 核验失败 → 重试路径:attempt+1、退避时间、错误码。
final failed = await store.updateState(
'task-0001',
UploadTaskState.retryableFailed,
patch: UploadTaskPatch(
attempt: 1,
nextRetryAt: t0.add(const Duration(minutes: 10)),
lastError: 'UPLOAD_VERIFY_FAILED',
),
now: t0.add(const Duration(minutes: 2)),
);
expect(failed.attempt, 1);
expect(failed.nextRetryAt, t0.add(const Duration(minutes: 10)));
expect(failed.lastError, 'UPLOAD_VERIFY_FAILED');
// 重试:retryableFailed → queued → uploading(合法路径)。
await store.updateState(
'task-0001',
UploadTaskState.queued,
now: t0.add(const Duration(minutes: 10)),
);
await store.updateState(
'task-0001',
UploadTaskState.uploading,
now: t0.add(const Duration(minutes: 11)),
);
await store.updateState(
'task-0001',
UploadTaskState.uploadedConfirming,
now: t0.add(const Duration(minutes: 12)),
);
final done = await store.updateState(
'task-0001',
UploadTaskState.completed,
patch: const UploadTaskPatch(
clearNextRetryAt: true,
clearLastError: true,
),
now: t0.add(const Duration(minutes: 13)),
);
expect(done.state, UploadTaskState.completed);
expect(done.nextRetryAt, isNull);
expect(done.lastError, isNull);
expect(done.cleanedAt, isNull, reason: '完成不等于已清理');
});
test('非法迁移抛 StateError 且不落库(守护)', () async {
final createdAt = DateTime.utc(2026, 9, 19, 9);
await store.create(draft(), now: createdAt);
await expectLater(
store.updateState('task-0001', UploadTaskState.completed),
throwsA(isA<StateError>()),
);
final task = await store.get('alice', 'task-0001');
expect(task!.state, UploadTaskState.preparing);
expect(task.updatedAt, createdAt, reason: '失败的迁移不得刷新 updatedAt');
});
test('终态出边一律拒绝(completed 后不可复活)', () async {
await store.create(draft());
await advanceTo(store, 'task-0001', UploadTaskState.completed);
await expectLater(
store.updateState('task-0001', UploadTaskState.queued),
throwsA(isA<StateError>()),
);
expect(
(await store.get('alice', 'task-0001'))!.state,
UploadTaskState.completed,
);
});
test('不存在的 id 抛 StateError', () async {
await expectLater(
store.updateState('nope', UploadTaskState.queued),
throwsA(isA<StateError>()),
);
});
test('patch 未提供的列保持不变(文档契约,引擎重试依赖)', () async {
// 场景(Task 6 引擎):失败写入 serverUploadId/attempt/lastError 后,
// 后续不带这些字段的迁移不得清空它们。
await store.create(draft());
await store.updateState('task-0001', UploadTaskState.queued);
await store.updateState(
'task-0001',
UploadTaskState.uploading,
patch: const UploadTaskPatch(
serverUploadId: 'upl-1',
serverPhotoId: 'pho-1',
),
);
await store.updateState(
'task-0001',
UploadTaskState.waitingNetwork,
patch: const UploadTaskPatch(
attempt: 2,
lastError: 'NETWORK_ERROR',
),
);
final kept = await store.updateState(
'task-0001',
UploadTaskState.uploading,
now: DateTime.utc(2026, 9, 19, 10),
);
expect(kept.serverUploadId, 'upl-1', reason: '未提供不得清空');
expect(kept.serverPhotoId, 'pho-1');
expect(kept.attempt, 2);
expect(kept.lastError, 'NETWORK_ERROR');
});
test('updateMeta:旁路回填不迁移状态(引擎 init 后回填会话 ID)', () async {
await store.create(draft());
await store.updateState('task-0001', UploadTaskState.queued);
await store.updateState('task-0001', UploadTaskState.uploading);
final at = DateTime.utc(2026, 9, 19, 10);
final updated = await store.updateMeta(
'task-0001',
serverUploadId: 'upl-9',
serverPhotoId: 'pho-9',
now: at,
);
expect(updated.state, UploadTaskState.uploading, reason: '状态不变');
expect(updated.serverUploadId, 'upl-9');
expect(updated.serverPhotoId, 'pho-9');
expect(updated.updatedAt, at);
// 未提供的字段保持不变(二次回填只更新一个)。
final again = await store.updateMeta('task-0001', serverUploadId: 'upl-x');
expect(again.serverUploadId, 'upl-x');
expect(again.serverPhotoId, 'pho-9');
});
});
group('账号隔离(硬守护)', () {
test('listByAccount/get/countsByState 均不可见他人任务', () async {
await store.create(draft(accountId: 'alice', clientTaskId: 'ct-a'));
await store.create(
draft(id: 'task-b', accountId: 'bob', clientTaskId: 'ct-b'),
);
final aliceTasks = await store.listByAccount('alice');
expect(aliceTasks.map((t) => t.accountId), everyElement('alice'));
expect(aliceTasks, hasLength(1));
// get 跨账号视同不存在。
expect(await store.get('alice', 'task-b'), isNull);
expect(await store.get('bob', 'task-0001'), isNull);
// 徽章计数同样隔离。
final bobCounts = await store.countsByState('bob');
expect(
bobCounts.values.fold<int>(0, (a, b) => a + b),
1,
reason: 'bob 只应见到自己的 1 条',
);
});
test('筛选组合不放松 accountId 过滤(相册/状态条件均在其内)', () async {
await store.create(draft(albumId: 'alb-1', clientTaskId: 'ct-1'));
await store.create(
draft(id: 'task-bob', accountId: 'bob', albumId: 'alb-1', clientTaskId: 'ct-1'),
);
final onlyAlice = await store.listByAccount(
'alice',
albumId: 'alb-1',
states: const {UploadTaskState.preparing},
);
expect(onlyAlice, hasLength(1));
expect(onlyAlice.single.accountId, 'alice');
});
});
group('listByAccount(三维筛选 + FIFO)', () {
test('albumId 筛选', () async {
await store.create(draft(albumId: 'alb-1', clientTaskId: 'ct-1'));
await store.create(
draft(id: 'task-2', albumId: 'alb-2', clientTaskId: 'ct-2'),
);
final tasks =
await store.listByAccount('alice', albumId: 'alb-2');
expect(tasks.map((t) => t.albumId), everyElement('alb-2'));
expect(tasks, hasLength(1));
});
test('states 筛选(集合内 IN)', () async {
await store.create(draft(clientTaskId: 'ct-1'));
await store.create(draft(id: 'task-2', clientTaskId: 'ct-2'));
await store.create(draft(id: 'task-3', clientTaskId: 'ct-3'));
await advanceTo(store, 'task-2', UploadTaskState.completed);
await advanceTo(store, 'task-3', UploadTaskState.cancelled);
final active = await store.listByAccount(
'alice',
states: {UploadTaskState.preparing, UploadTaskState.queued},
);
expect(active.map((t) => t.id), ['task-0001']);
final terminal = await store.listByAccount(
'alice',
states: {UploadTaskState.completed, UploadTaskState.cancelled},
);
expect(terminal.map((t) => t.id).toSet(), {'task-2', 'task-3'});
});
test('since 时间筛选(含边界:createdAt == since 计入)', () async {
final t0 = DateTime.utc(2026, 9, 19, 10);
await store.create(draft(clientTaskId: 'ct-1'), now: t0);
await store.create(
draft(id: 'task-2', clientTaskId: 'ct-2'),
now: t0.add(const Duration(hours: 1)),
);
final tasks = await store.listByAccount(
'alice',
since: t0.add(const Duration(hours: 1)),
);
expect(tasks.map((t) => t.id), ['task-2']);
});
test('按 createdAt 升序(队列 FIFO)+ limit', () async {
final t0 = DateTime.utc(2026, 9, 19, 10);
for (var i = 1; i <= 3; i++) {
await store.create(
draft(
id: 'task-$i',
clientTaskId: 'ct-$i',
),
now: t0.add(Duration(minutes: i)),
);
}
final tasks = await store.listByAccount('alice');
expect(tasks.map((t) => t.id), ['task-1', 'task-2', 'task-3']);
expect(
(await store.listByAccount('alice', limit: 2)).map((t) => t.id),
['task-1', 'task-2'],
);
});
});
group('countsByState(队列徽章)', () {
test('全状态键 + 无任务计 0 + 分组计数正确', () async {
await store.create(draft(clientTaskId: 'ct-1'));
await store.create(draft(id: 'task-2', clientTaskId: 'ct-2'));
await store.create(draft(id: 'task-3', clientTaskId: 'ct-3'));
await advanceTo(store, 'task-2', UploadTaskState.completed);
await advanceTo(store, 'task-3', UploadTaskState.waitingNetwork);
final counts = await store.countsByState('alice');
expect(counts.keys.toSet(), UploadTaskState.values.toSet(),
reason: '徽章需要全部状态键(含 0)');
expect(counts[UploadTaskState.preparing], 1);
expect(counts[UploadTaskState.completed], 1);
expect(counts[UploadTaskState.waitingNetwork], 1);
expect(counts[UploadTaskState.uploading], 0);
expect(counts[UploadTaskState.cancelled], 0);
});
});
group('源文件保留策略(评审缺口 1 硬守护)', () {
final now = DateTime.utc(2026, 9, 19, 12);
final ancient = DateTime.utc(2026, 8, 30, 12); // now - 20 天(超过保留期)。
test('cleanupCandidates:仅过期终态(completed+cancelled),新鲜与已清理的排除', () async {
await store.create(
draft(clientTaskId: 'ct-old-done'),
now: ancient,
);
await advanceTo(
store,
'task-0001',
UploadTaskState.completed,
at: ancient.add(const Duration(minutes: 5)),
);
await store.create(
draft(id: 'task-cancel', clientTaskId: 'ct-old-cancel'),
now: ancient,
);
await advanceTo(
store,
'task-cancel',
UploadTaskState.cancelled,
at: ancient.add(const Duration(minutes: 5)),
);
// 保留期内完成的任务。
await store.create(
draft(id: 'task-fresh', clientTaskId: 'ct-fresh'),
now: now.subtract(const Duration(days: 2)),
);
await advanceTo(
store,
'task-fresh',
UploadTaskState.completed,
at: now.subtract(const Duration(days: 2)).add(const Duration(minutes: 5)),
);
var candidates = await store.cleanupCandidates(14, now: now);
expect(candidates.map((t) => t.id).toSet(), {'task-0001', 'task-cancel'});
// 标记清理后不再出现(幂等)。
await store.markCleanable('task-0001', now: now);
candidates = await store.cleanupCandidates(14, now: now);
expect(candidates.map((t) => t.id), ['task-cancel']);
});
test('硬守护:过期但未完成的任务永不出现在清理候选(全部非终态)', () async {
// 各非终态摆一条"古老"任务(updatedAt 均早于保留期界)。
await store.create(draft(clientTaskId: 'ct-p'), now: ancient);
await store.create(draft(id: 'task-q', clientTaskId: 'ct-q'), now: ancient);
await advanceTo(store, 'task-q', UploadTaskState.queued, at: ancient);
await store.create(draft(id: 'task-u', clientTaskId: 'ct-u'), now: ancient);
await advanceTo(store, 'task-u', UploadTaskState.uploading, at: ancient);
await store.create(draft(id: 'task-w', clientTaskId: 'ct-w'), now: ancient);
await advanceTo(
store, 'task-w', UploadTaskState.waitingNetwork, at: ancient);
await store.create(draft(id: 'task-r', clientTaskId: 'ct-r'), now: ancient);
await advanceTo(
store, 'task-r', UploadTaskState.retryableFailed, at: ancient);
await store.create(draft(id: 'task-pf', clientTaskId: 'ct-pf'), now: ancient);
await store.updateState(
'task-pf',
UploadTaskState.preparationFailed,
now: ancient,
);
// uploading 停在核验中的古老任务。
await store.create(draft(id: 'task-uc', clientTaskId: 'ct-uc'), now: ancient);
await advanceTo(
store,
'task-uc',
UploadTaskState.uploading,
at: ancient,
);
await store.updateState(
'task-uc',
UploadTaskState.uploadedConfirming,
now: ancient,
);
final candidates = await store.cleanupCandidates(14, now: now);
expect(candidates, isEmpty,
reason: '全部为未完成的古老任务,绝不进入清理候选');
});
test('markCleanable:非终态拒绝(守护另一面)且不落 cleanedAt', () async {
await store.create(draft());
await expectLater(
store.markCleanable('task-0001'),
throwsA(isA<StateError>()),
);
expect((await store.get('alice', 'task-0001'))!.cleanedAt, isNull);
});
test('markCleanable:终态可标记且幂等;delete 移除记录', () async {
await store.create(draft());
await advanceTo(store, 'task-0001', UploadTaskState.completed);
final markAt = DateTime.utc(2026, 9, 20);
await store.markCleanable('task-0001', now: markAt);
expect((await store.get('alice', 'task-0001'))!.cleanedAt, markAt);
// 幂等:重复标记只刷新时间。
final markAgain = markAt.add(const Duration(days: 1));
await store.markCleanable('task-0001', now: markAgain);
expect((await store.get('alice', 'task-0001'))!.cleanedAt, markAgain);
expect(await store.delete('task-0001'), 1);
expect(await store.get('alice', 'task-0001'), isNull);
expect(await store.delete('task-0001'), 0, reason: '重复删除返回 0');
});
});
}