test: cover local backup import export

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:27:48 +02:00
parent c3d5273869
commit 4384797e25
3 changed files with 615 additions and 1 deletions

View File

@ -5035,7 +5035,7 @@ List<domain.WorkoutHistoryStepResult> _workoutHistoryStepResultsFromPayload(
stepSnapshotId: _stringFromPayload(map, 'stepSnapshotId', ''), stepSnapshotId: _stringFromPayload(map, 'stepSnapshotId', ''),
stepNameSnapshot: _stringFromPayload(map, 'stepNameSnapshot', ''), stepNameSnapshot: _stringFromPayload(map, 'stepNameSnapshot', ''),
stepTypeSnapshot: _exerciseStepTypeFromDb( stepTypeSnapshot: _exerciseStepTypeFromDb(
map['stepTypeSnapshot'] as String? ?? 'work', map['stepTypeSnapshot'] as String? ?? 'reps',
), ),
targetValueSnapshot: map['targetValueSnapshot'] as int? ?? 0, targetValueSnapshot: map['targetValueSnapshot'] as int? ?? 0,
hasScoreSnapshot: map['hasScoreSnapshot'] == true, hasScoreSnapshot: map['hasScoreSnapshot'] == true,

View File

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart'; import 'package:gametime/application/application.dart';
@ -2333,6 +2334,223 @@ void main() {
]); ]);
}); });
test('Local backup codec writes v1 envelope and preview counts', () {
final codec = LocalBackupCodec();
final snapshot = _backupSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 12),
exercises: [
_backupResource(
id: 'exercise-1',
updatedAt: DateTime.utc(2026, 7, 22, 10),
payload: {
'id': 'exercise-1',
'metadata': _backupMetadata(
'exercise-1',
DateTime.utc(2026, 7, 22, 10),
),
'name': 'Shoot',
},
),
],
mediaFiles: const [
EmbeddedBackupMediaFile(
mediaAssetId: 'media-1',
role: 'original',
fileName: 'media-1.png',
mimeType: 'image/png',
sizeBytes: 4,
base64: 'dGVzdA==',
),
],
);
final decodedJson = jsonDecode(utf8.decode(codec.encode(snapshot))) as Map;
final preview = codec.preview(codec.encode(snapshot));
expect(decodedJson['kind'], 'gametime.localBackup');
expect(decodedJson['formatVersion'], 1);
expect(preview.counts.exercises, 1);
expect(preview.counts.embeddedMediaFiles, 1);
expect(preview.hasEmbeddedMedia, isTrue);
expect(preview.missingMediaCount, 0);
});
test('Local backup codec rejects invalid and future files', () {
final codec = LocalBackupCodec();
expect(
() => codec.decode(Uint8List.fromList(utf8.encode('not json'))),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.corrupted,
),
),
);
final future = utf8.encode(
jsonEncode({
'kind': 'gametime.localBackup',
'formatVersion': 2,
'minSupportedFormatVersion': 2,
'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
'data': {
'mediaAssets': const [],
'exercises': const [],
'programs': const [],
'workoutTemplates': const [],
'workoutHistories': const [],
},
'mediaFiles': const [],
}),
);
expect(
() => codec.decode(Uint8List.fromList(future)),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.newerVersion,
),
),
);
});
test('Data export use case embeds media and names gametime file', () async {
final repository = _FakeLocalDataBackupRepository(
snapshot: _backupSnapshot(
exportedAt: DateTime.utc(2026, 7, 22),
mediaAssets: [
_backupResource(
id: 'media-1',
updatedAt: DateTime.utc(2026, 7, 22, 10),
payload: {
'id': 'media-1',
'metadata': _backupMetadata(
'media-1',
DateTime.utc(2026, 7, 22, 10),
),
'localUri': 'file:///tmp/media-1.png',
},
),
],
),
);
final mediaStore = _FakeLocalBackupMediaStore(
files: const [
EmbeddedBackupMediaFile(
mediaAssetId: 'media-1',
role: 'original',
fileName: 'media-1.png',
mimeType: 'image/png',
sizeBytes: 4,
base64: 'dGVzdA==',
),
],
);
final useCase = DataExportUseCase(
repository: repository,
mediaStore: mediaStore,
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
);
final document = await useCase.exportAll();
final preview = const LocalBackupCodec().preview(document.bytes);
expect(document.fileName, 'gametime-sauvegarde-2026-07-22.gametime');
expect(mediaStore.requestedMediaIds, ['media-1']);
expect(preview.counts.mediaAssets, 1);
expect(preview.counts.embeddedMediaFiles, 1);
});
test('Data import use case blocks when active workout is open', () async {
final repository = _FakeLocalDataBackupRepository(hasOpenSession: true);
final useCase = DataImportUseCase(
repository: repository,
mediaStore: _FakeLocalBackupMediaStore(),
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
);
final bytes = const LocalBackupCodec().encode(_backupSnapshot());
await expectLater(
useCase.importFrom(bytes, mode: LocalBackupImportMode.merge),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.activeWorkoutInProgress,
),
),
);
expect(repository.appliedModes, isEmpty);
});
test(
'Data import use case restores media and applies selected mode',
() async {
final repository = _FakeLocalDataBackupRepository();
final mediaStore = _FakeLocalBackupMediaStore(
restored: const {
'media-1': RestoredMediaFile(
mediaAssetId: 'media-1',
localUri: 'file:///restored/media-1.png',
sizeBytes: 4,
),
},
);
final useCase = DataImportUseCase(
repository: repository,
mediaStore: mediaStore,
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
);
final bytes = const LocalBackupCodec().encode(
_backupSnapshot(
mediaAssets: [
_backupResource(
id: 'media-1',
updatedAt: DateTime.utc(2026, 7, 22, 10),
payload: {
'id': 'media-1',
'metadata': _backupMetadata(
'media-1',
DateTime.utc(2026, 7, 22, 10),
),
'localUri': 'file:///old/media-1.png',
},
),
],
mediaFiles: const [
EmbeddedBackupMediaFile(
mediaAssetId: 'media-1',
role: 'original',
fileName: 'media-1.png',
mimeType: 'image/png',
sizeBytes: 4,
base64: 'dGVzdA==',
),
],
),
);
final result = await useCase.importFrom(
bytes,
mode: LocalBackupImportMode.replaceAll,
);
expect(result.insertedCount, 0);
expect(repository.appliedModes, [LocalBackupImportMode.replaceAll]);
expect(
repository
.appliedSnapshots
.single
.mediaAssets
.single
.payload['localUri'],
'file:///restored/media-1.png',
);
},
);
test('Program use case duplicates deeply with copy name conflicts', () async { test('Program use case duplicates deeply with copy name conflicts', () async {
final programRepository = _FakeProgramRepository(); final programRepository = _FakeProgramRepository();
final templateRepository = _FakeWorkoutTemplateRepository(); final templateRepository = _FakeWorkoutTemplateRepository();
@ -3345,6 +3563,113 @@ final class _FakeExerciseRepository implements ExerciseRepository {
} }
} }
LocalDataExportSnapshot _backupSnapshot({
DateTime? exportedAt,
List<LocalBackupResource> mediaAssets = const [],
List<LocalBackupResource> exercises = const [],
List<LocalBackupResource> programs = const [],
List<LocalBackupResource> workoutTemplates = const [],
List<LocalBackupResource> workoutHistories = const [],
List<EmbeddedBackupMediaFile> mediaFiles = const [],
}) {
return LocalDataExportSnapshot(
exportedAt: exportedAt ?? DateTime.utc(2026, 7, 22, 12),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: mediaAssets,
exercises: exercises,
programs: programs,
workoutTemplates: workoutTemplates,
workoutHistories: workoutHistories,
mediaFiles: mediaFiles,
);
}
LocalBackupResource _backupResource({
required String id,
required DateTime updatedAt,
required Map<String, Object?> payload,
}) {
return LocalBackupResource(id: id, updatedAt: updatedAt, payload: payload);
}
Map<String, Object?> _backupMetadata(String id, DateTime updatedAt) => {
'id': id,
'createdAt': updatedAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
'deletedAt': null,
'schemaVersion': 1,
'syncState': 'synced',
'localRevision': 0,
'originDeviceId': 'device-1',
};
final class _FakeLocalDataBackupRepository
implements LocalDataBackupRepository {
_FakeLocalDataBackupRepository({
LocalDataExportSnapshot? snapshot,
this.hasOpenSession = false,
}) : snapshot = snapshot ?? _backupSnapshot();
final LocalDataExportSnapshot snapshot;
final bool hasOpenSession;
final appliedModes = <LocalBackupImportMode>[];
final appliedSnapshots = <LocalDataExportSnapshot>[];
@override
Future<LocalBackupImportResult> applyImportSnapshot({
required LocalDataExportSnapshot snapshot,
required LocalBackupImportMode mode,
required DateTime importedAt,
}) async {
appliedModes.add(mode);
appliedSnapshots.add(snapshot);
return const LocalBackupImportResult(
insertedCount: 0,
updatedCount: 0,
ignoredOlderCount: 0,
deletedByReplaceCount: 0,
missingMediaCount: 0,
);
}
@override
Future<bool> hasAnyUserData() async => false;
@override
Future<bool> hasOpenActiveWorkoutSession() async => hasOpenSession;
@override
Future<LocalDataExportSnapshot> readExportSnapshot(
DateTime exportedAt,
) async {
return snapshot.copyWith(mediaFiles: snapshot.mediaFiles);
}
}
final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
_FakeLocalBackupMediaStore({this.files = const [], this.restored = const {}});
final List<EmbeddedBackupMediaFile> files;
final Map<String, RestoredMediaFile> restored;
final requestedMediaIds = <String>[];
@override
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
List<LocalBackupResource> mediaAssets,
) async {
requestedMediaIds.addAll(mediaAssets.map((asset) => asset.id));
return files;
}
@override
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
List<EmbeddedBackupMediaFile> files,
) async {
return restored;
}
}
final class _FakeWorkoutTemplateRepository final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository { implements WorkoutTemplateRepository {
final templates = <WorkoutTemplate>[]; final templates = <WorkoutTemplate>[];

View File

@ -16,6 +16,7 @@ void main() {
late local.DriftWorkoutHistoryRepository historyRepository; late local.DriftWorkoutHistoryRepository historyRepository;
late local.DriftProgressionStatsRepository progressionStatsRepository; late local.DriftProgressionStatsRepository progressionStatsRepository;
late local.DriftLocalSyncChangeRepository syncChangeRepository; late local.DriftLocalSyncChangeRepository syncChangeRepository;
late local.DriftLocalDataBackupRepository localDataBackupRepository;
late local.DriftExercisePerformanceReferenceRepository late local.DriftExercisePerformanceReferenceRepository
performanceReferenceRepository; performanceReferenceRepository;
@ -30,6 +31,7 @@ void main() {
database, database,
); );
syncChangeRepository = local.DriftLocalSyncChangeRepository(database); syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
localDataBackupRepository = local.DriftLocalDataBackupRepository(database);
performanceReferenceRepository = performanceReferenceRepository =
local.DriftExercisePerformanceReferenceRepository(database); local.DriftExercisePerformanceReferenceRepository(database);
}); });
@ -262,6 +264,233 @@ void main() {
expect(template!.tags, isEmpty); expect(template!.tags, isEmpty);
}); });
test('local backup export includes tags and full workout history', () async {
final now = DateTime.utc(2026, 7, 22, 10);
await exerciseRepository.save(
Exercise(
metadata: _metadata('backup-exercise', now),
name: 'Backup Shoot',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
tags: const ['match'],
),
);
await historyRepository.save(
WorkoutHistory(
metadata: _metadata('backup-history', now),
nameSnapshot: 'Backup Session',
startedAt: now,
endedAt: now.add(const Duration(minutes: 10)),
totalActiveMs: 600000,
completed: true,
historySnapshotJson: '{"name":"Backup Session"}',
results: [
_historySetResult(
id: 'backup-result',
historyId: 'backup-history',
sourceExerciseId: 'backup-exercise',
setIndex: 0,
startedAt: now,
actualReps: 12,
),
],
stepResults: [
_historyStepResult(
id: 'backup-step-result',
historyId: 'backup-history',
sourceExerciseId: 'backup-exercise',
startedAt: now,
),
],
),
);
final snapshot = await localDataBackupRepository.readExportSnapshot(
DateTime.utc(2026, 7, 22, 12),
);
final document =
jsonDecode(utf8.decode(const LocalBackupCodec().encode(snapshot)))
as Map<String, Object?>;
final data = document['data'] as Map<String, Object?>;
final exercises = data['exercises'] as List;
final histories = data['workoutHistories'] as List;
final history = histories.single as Map;
expect(document['kind'], 'gametime.localBackup');
expect(exercises.single, containsPair('tags', ['match']));
expect(history['results'], hasLength(1));
expect(history['stepResults'], hasLength(1));
});
test('local backup merge applies LWW by type and stable id', () async {
final localTime = DateTime.utc(2026, 7, 22, 10);
await exerciseRepository.save(
Exercise(
metadata: _metadata('merge-existing', localTime),
name: 'Local older',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 8,
),
);
await exerciseRepository.save(
Exercise(
metadata: _metadata(
'merge-local-newer',
localTime.add(const Duration(hours: 3)),
),
name: 'Local newer',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 8,
),
);
final snapshot = LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 12),
appSchemaVersion: 19,
originDeviceId: 'device-backup',
mediaAssets: const [],
exercises: [
_backupExerciseResource(
id: 'merge-existing',
name: 'Backup newer',
updatedAt: localTime.add(const Duration(hours: 2)),
),
_backupExerciseResource(
id: 'merge-local-newer',
name: 'Backup older',
updatedAt: localTime.add(const Duration(hours: 1)),
),
_backupExerciseResource(
id: 'merge-new',
name: 'Backup inserted',
updatedAt: localTime.add(const Duration(hours: 1)),
),
],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
);
final result = await localDataBackupRepository.applyImportSnapshot(
snapshot: snapshot,
mode: LocalBackupImportMode.merge,
importedAt: DateTime.utc(2026, 7, 22, 14),
);
expect(result.insertedCount, 1);
expect(result.updatedCount, 1);
expect(result.ignoredOlderCount, 1);
expect(
(await exerciseRepository.findById('merge-existing'))!.name,
'Backup newer',
);
expect(
(await exerciseRepository.findById('merge-local-newer'))!.name,
'Local newer',
);
expect(
(await exerciseRepository.findById('merge-new'))!.name,
'Backup inserted',
);
});
test(
'local backup replaceAll soft deletes absent data and imports file',
() async {
final now = DateTime.utc(2026, 7, 22, 10);
await exerciseRepository.save(
Exercise(
metadata: _metadata('replace-old', now),
name: 'Old',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 8,
),
);
final snapshot = LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 12),
appSchemaVersion: 19,
originDeviceId: 'device-backup',
mediaAssets: const [],
exercises: [
_backupExerciseResource(
id: 'replace-new',
name: 'New',
updatedAt: now.add(const Duration(hours: 1)),
),
],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
);
final result = await localDataBackupRepository.applyImportSnapshot(
snapshot: snapshot,
mode: LocalBackupImportMode.replaceAll,
importedAt: DateTime.utc(2026, 7, 22, 14),
);
expect(result.deletedByReplaceCount, 1);
expect(await exerciseRepository.listActive(), hasLength(1));
expect((await exerciseRepository.findById('replace-new'))!.name, 'New');
final tombstones =
await (database.select(database.changeLogEntries)..where(
(table) =>
table.entityId.equals('replace-old') &
table.operation.equals('softDelete'),
))
.get();
expect(tombstones, hasLength(1));
},
);
test('local backup import is blocked by open active workout', () async {
final now = DateTime.utc(2026, 7, 22, 10);
await activeRepository.save(
ActiveWorkoutSession(
metadata: _metadata('active-import-block', now),
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: '{"programs":[]}',
),
);
await expectLater(
localDataBackupRepository.applyImportSnapshot(
snapshot: LocalDataExportSnapshot(
exportedAt: now,
appSchemaVersion: 19,
originDeviceId: 'device-backup',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
),
mode: LocalBackupImportMode.merge,
importedAt: now.add(const Duration(hours: 1)),
),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.activeWorkoutInProgress,
),
),
);
});
test('program duplication persists copied children through Drift', () async { test('program duplication persists copied children through Drift', () async {
final now = DateTime.utc(2026, 7, 22, 11, 15); final now = DateTime.utc(2026, 7, 22, 11, 15);
await exerciseRepository.save( await exerciseRepository.save(
@ -1783,6 +2012,37 @@ EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) {
); );
} }
LocalBackupResource _backupExerciseResource({
required String id,
required String name,
required DateTime updatedAt,
}) {
return LocalBackupResource(
id: id,
updatedAt: updatedAt,
payload: {
'id': id,
'metadata': {
'id': id,
'createdAt': updatedAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
'deletedAt': null,
'schemaVersion': 1,
'syncState': 'synced',
'localRevision': 0,
'originDeviceId': 'device-backup',
},
'name': name,
'hasTimeMeasure': false,
'hasRepsMeasure': true,
'hasScoreMeasure': false,
'defaultTargetReps': 10,
'tags': const ['backup'],
'steps': const [],
},
);
}
WorkoutHistory _history({ WorkoutHistory _history({
required String id, required String id,
required DateTime startedAt, required DateTime startedAt,
@ -1803,6 +2063,35 @@ WorkoutHistory _history({
); );
} }
WorkoutHistoryStepResult _historyStepResult({
required String id,
required String historyId,
required String sourceExerciseId,
required DateTime startedAt,
}) {
return WorkoutHistoryStepResult(
metadata: _metadata(id, startedAt),
workoutHistoryId: historyId,
programSnapshotId: 'program-snapshot',
exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 0,
stepSnapshotId: 'step-snapshot',
stepNameSnapshot: 'Step',
stepTypeSnapshot: ExerciseStepType.reps,
targetValueSnapshot: 10,
hasScoreSnapshot: false,
status: SetResultStatus.completed,
startedAt: startedAt,
completedAt: startedAt.add(const Duration(seconds: 10)),
actualReps: 10,
sourceExerciseIdSnapshot: sourceExerciseId,
);
}
WorkoutHistorySetResult _historySetResult({ WorkoutHistorySetResult _historySetResult({
required String id, required String id,
required String historyId, required String historyId,