/// 上传任务库(TaskStore)测试(P3 计划 Task 5)。 /// /// 内存库(drift NativeDatabase.memory)驱动,覆盖: /// - CRUD 与幂等键(clientTaskId 同账号同相册唯一,应用侧校验); /// - 状态迁移守护(updateState 非法迁移拒绝且不落库); /// - 账号隔离硬守护(读路径无条件 accountId 过滤); /// - 列表筛选(相册/状态/时间)与 FIFO 排序; /// - countsByState(队列 UI 徽章); /// - 源文件保留策略(cleanupCandidates 仅终态+过期;未完成永不清理—— /// 评审缺口 1 的硬守护测试); /// - datetime 文本落库的亚秒精度。 library; import 'dart:io'; import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:drift/native.dart' show NativeDatabase; 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', bool force = false, }) { return UploadTaskDraft( id: id, accountId: accountId, albumId: albumId, categoryId: categoryId, clientTaskId: clientTaskId, sourceFilePath: sourceFilePath, filename: filename, bytes: bytes, contentType: contentType, force: force, ); } /// 将任务沿合法路径推进到目标状态(供 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() { // 迁移组在同一测试内另建独立执行器的库(与 setUp 的内存库并存)—— // drift 多实例告警在此是误报(见 helpers/fakes.dart 同款处理)。 driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; 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); expect(task.force, isFalse, reason: '缺省不强传(服务端查重正常裁决)'); }); test('force=true 落库(弹窗「全部上传」确认强传的任务级标记)', () async { final task = await store.create(draft(force: true)); expect(task.force, isTrue); expect((await store.get('alice', 'task-0001'))!.force, isTrue); }); 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'); }); }); group('schema 迁移(v1 → v2:force 列,Task 8)', () { /// v1 底库种子(建表无 force 列 + legacy 行 + user_version=1)。语句 /// 拆列供内存/文件两种打开方式复用(raw 类型由 setup 上下文推断, /// 免引 sqlite3 传递依赖)。 const List v1SeedStatements = [ '''CREATE TABLE IF NOT EXISTS "upload_tasks" ( "id" TEXT NOT NULL PRIMARY KEY, "account_id" TEXT NOT NULL, "album_id" TEXT NOT NULL, "category_id" TEXT NULL, "client_task_id" TEXT NOT NULL, "source_file_path" TEXT NOT NULL, "filename" TEXT NOT NULL, "bytes" INTEGER NOT NULL, "content_type" TEXT NOT NULL, "state" TEXT NOT NULL, "attempt" INTEGER NOT NULL DEFAULT 0, "next_retry_at" TEXT NULL, "server_upload_id" TEXT NULL, "server_photo_id" TEXT NULL, "last_error" TEXT NULL, "cleaned_at" TEXT NULL, "created_at" TEXT NOT NULL, "updated_at" TEXT NOT NULL );''', '''INSERT INTO upload_tasks ( "id", "account_id", "album_id", "client_task_id", "source_file_path", "filename", "bytes", "content_type", "state", "created_at", "updated_at" ) VALUES ( 'task-legacy', 'alice', 'alb-1', 'ct-legacy', '/managed/uploads/alb-1/legacy.jpg', 'IMG_LEGACY.jpg', 2048, 'image/jpeg', 'completed', '2026-09-01T00:00:00.000Z', '2026-09-01T00:01:00.000Z' );''', 'PRAGMA user_version = 1;', ]; void runSeed(raw) { for (final statement in v1SeedStatements) { raw.execute(statement); } } /// v1 底库(无 force 列)+ 一条既有行,user_version=1——升级后打开。 UploadTaskDb legacyV1Db() => UploadTaskDb(NativeDatabase.memory(setup: runSeed)); test('旧库升级:既有行保留、force=false 默认、新列可写', () async { final db = legacyV1Db(); final migrated = TaskStore(db); addTearDown(migrated.close); final legacy = await migrated.get('alice', 'task-legacy'); expect(legacy, isNotNull, reason: '升级不丢既有行'); expect(legacy!.state, UploadTaskState.completed); expect(legacy.filename, 'IMG_LEGACY.jpg'); expect(legacy.bytes, 2048); expect(legacy.force, isFalse, reason: 'v1 行无 force 列:迁移默认 false'); // 升级后新任务可写 force=true(列已由迁移补齐)。 final created = await migrated.create( draft(id: 'task-new', clientTaskId: 'ct-new', force: true), ); expect(created.force, isTrue); }); test('降级重开后再升级:force 列已存在不重复补列(旧 APK 覆盖安装防护)', () async { final tmp = await Directory.systemTemp.createTemp('store-mig-'); addTearDown(() async { try { await tmp.delete(recursive: true); } on Object { // Windows 句柄延迟释放:残留交由系统临时目录清理。 } }); final dbFile = File('${tmp.path}/upload_tasks.sqlite'); // ① 新 APK 打开 v1 底库:升级补 force 列(user_version → 2)。 final first = TaskStore( UploadTaskDb(NativeDatabase(dbFile, setup: runSeed)), ); expect((await first.get('alice', 'task-legacy'))!.force, isFalse, reason: '前置:v1 → v2 升级成功'); await first.close(); // ② 模拟旧 APK 覆盖安装后的降级打开残留态:drift 降级路径不删列、 // 只把 user_version 写回 1(force 列留存)——setup 直接置 1。 // ③ 再装回新 APK:from=1 重跑 onUpgrade——不得因列已存在抛 // 「duplicate column name: force」致库打不开(测试服务器工作流: // 测试员旧包覆盖装新包的常态路径)。 final second = TaskStore( UploadTaskDb( NativeDatabase( dbFile, setup: (raw) => raw.execute('PRAGMA user_version = 1;'), ), ), ); addTearDown(second.close); final legacy = await second.get('alice', 'task-legacy'); expect(legacy, isNotNull, reason: '降级-重升级循环不丢既有行'); expect(legacy!.filename, 'IMG_LEGACY.jpg'); final created = await second.create( draft(id: 'task-after', clientTaskId: 'ct-after', force: true), ); expect(created.force, isTrue, reason: '重升级后新列仍可写'); }); }); }