Files
photo-live/apps/mobile/test/features/uploads/task_store_test.dart
T
浅念 46f9d93fd3 fix(app): guard force-column migration against downgrade-then-reupgrade
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>
2026-09-28 17:27:52 +08:00

674 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 上传任务库(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: '重升级后新列仍可写');
});
});
}