46f9d93fd3
Review hardening for Task 8 (test-server workflow: testers install older APKs over a newer DB): - Old APK opening a v2 database hits drift's downgrade path, which leaves the force column in place but writes user_version back to 1. Reinstalling the new APK then re-runs onUpgrade(1,2) and addColumn crashes with "duplicate column name: force", failing the DB open. - onUpgrade now checks PRAGMA table_info(upload_tasks) and only adds the force column when absent; the normal v1 -> v2 path is unchanged. - Migration test simulates the downgrade-reupgrade cycle on a file-backed database (v2 open -> user_version forced back to 1 -> re-upgrade), asserting the open succeeds, legacy rows survive, and the column stays writable. RED before the guard (SqliteException: duplicate column name). Co-Authored-By: Claude Code <noreply@anthropic.com>
674 lines
25 KiB
Dart
674 lines
25 KiB
Dart
/// 上传任务库(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: '重升级后新列仍可写');
|
||
});
|
||
});
|
||
}
|