feat(app): wire server dedup - force passthrough + PHOTO_DUPLICATE classification & l10n
Task 8: close the gap between server-side duplicate detection (90dcc58)
and the app.
- ApiClient.mapDioException: normalize 409 wire body reusing
VALIDATION_FAILED with message=PHOTO_DUPLICATE to
ErrorCodes.photoDuplicate (message-based; auto-compat if the contract
later adds a dedicated code)
- l10n zh/en errorPhotoDuplicate; error_l10n maps the code (queue page
inherits via fallback)
- UploadEngine._classifyFailure: PHOTO_DUPLICATE joins the no-auto-retry
bucket (retrying the same init body would 409 again); force to skip
dedup travels only through the enqueue path
- UploadTasks drift table +force column (schema v2, addColumn migration;
legacy rows default false); UploadTaskDraft/UploadTask/prepare carry it
- enqueueForUpload +forcePaths; duplicate dialog "全部上传" (capture page
+ album detail page) now returns DuplicateDialogChoice so confirmed
duplicates enqueue with force=true (server adjudicates owner/admin)
- engine init body carries force only when true (absent otherwise)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
/// - datetime 文本落库的亚秒精度。
|
||||
library;
|
||||
|
||||
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';
|
||||
@@ -26,6 +28,7 @@ UploadTaskDraft draft({
|
||||
String filename = 'IMG_0001.jpg',
|
||||
int bytes = 1024,
|
||||
String contentType = 'image/jpeg',
|
||||
bool force = false,
|
||||
}) {
|
||||
return UploadTaskDraft(
|
||||
id: id,
|
||||
@@ -37,6 +40,7 @@ UploadTaskDraft draft({
|
||||
filename: filename,
|
||||
bytes: bytes,
|
||||
contentType: contentType,
|
||||
force: force,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +80,10 @@ Future<UploadTask> advanceTo(
|
||||
}
|
||||
|
||||
void main() {
|
||||
// 迁移组在同一测试内另建独立执行器的库(与 setUp 的内存库并存)——
|
||||
// drift 多实例告警在此是误报(见 helpers/fakes.dart 同款处理)。
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
|
||||
late TaskStore store;
|
||||
|
||||
setUp(() {
|
||||
@@ -103,6 +111,13 @@ void main() {
|
||||
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 {
|
||||
@@ -541,4 +556,66 @@ void main() {
|
||||
expect(await store.delete('task-0001'), 0, reason: '重复删除返回 0');
|
||||
});
|
||||
});
|
||||
|
||||
group('schema 迁移(v1 → v2:force 列,Task 8)', () {
|
||||
/// v1 底库(无 force 列)+ 一条既有行,user_version=1——升级后打开。
|
||||
UploadTaskDb legacyV1Db() => UploadTaskDb(
|
||||
NativeDatabase.memory(setup: (raw) {
|
||||
raw.execute('''
|
||||
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
|
||||
);
|
||||
''');
|
||||
raw.execute('''
|
||||
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'
|
||||
);
|
||||
''');
|
||||
raw.execute('PRAGMA user_version = 1;');
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user