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

674 lines
25 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 '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<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() {
// 迁移组在同一测试内另建独立执行器的库(与 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<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');
});
});
group('schema 迁移(v1 → v2:force 列,Task 8)', () {
/// v1 底库种子(建表无 force 列 + legacy 行 + user_version=1)。语句
/// 拆列供内存/文件两种打开方式复用(raw 类型由 setup 上下文推断,
/// 免引 sqlite3 传递依赖)。
const List<String> v1SeedStatements = <String>[
'''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: '重升级后新列仍可写');
});
});
}