/// 上传任务库(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 advanceTo( TaskStore store, String id, UploadTaskState target, { DateTime? at, }) async { var current = (await store.get('alice', id))!; const path = >{ 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()), ); // 唯一存在的仍是第一条。 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()), ); 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()), ); expect( (await store.get('alice', 'task-0001'))!.state, UploadTaskState.completed, ); }); test('不存在的 id 抛 StateError', () async { await expectLater( store.updateState('nope', UploadTaskState.queued), throwsA(isA()), ); }); 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(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()), ); 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'); }); }); }