d96e56f653
- upload_tasks 表(accountId 隔离 + 三个复合索引)、状态列枚举转换器、 datetime 文本落库(UTC 归一,亚秒精度) - 纯状态机(task_state.dart):合法迁移表 + canTransition/assertTransition, completed/cancelled 终态 - TaskStore 仓储:create(clientTaskId 同账号同相册应用侧唯一)、 updateState(迁移守护+旁路字段)、账号隔离读路径(硬守护)、 countsByState 徽章、cleanupCandidates/markCleanable(仅终态可清理—— 评审缺口 1 硬守护,未完成源文件永不清理) - 测试 26 例(状态机表驱动全边 + 守护/隔离/筛选/保留策略); sqlite3 3.x build hooks 在 flutter test 宿主与 Android APK 构建均验证 (libsqlite3.so 已打包全 ABI,运行时验证留 Task 11) Co-Authored-By: Claude Code <noreply@anthropic.com>
492 lines
18 KiB
Dart
492 lines
18 KiB
Dart
/// 上传任务库(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>()),
|
||
);
|
||
});
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
}
|