Files
photo-live/apps/mobile/test/features/uploads/task_store_test.dart
T

545 lines
20 KiB
Dart
Raw Normal View History

/// 上传任务库(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');
});
});
}