diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index bffec9c..d0c2d82 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -5035,7 +5035,7 @@ List _workoutHistoryStepResultsFromPayload( stepSnapshotId: _stringFromPayload(map, 'stepSnapshotId', ''), stepNameSnapshot: _stringFromPayload(map, 'stepNameSnapshot', ''), stepTypeSnapshot: _exerciseStepTypeFromDb( - map['stepTypeSnapshot'] as String? ?? 'work', + map['stepTypeSnapshot'] as String? ?? 'reps', ), targetValueSnapshot: map['targetValueSnapshot'] as int? ?? 0, hasScoreSnapshot: map['hasScoreSnapshot'] == true, diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 9a5ab05..eab0aac 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.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().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().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().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 { final programRepository = _FakeProgramRepository(); final templateRepository = _FakeWorkoutTemplateRepository(); @@ -3345,6 +3563,113 @@ final class _FakeExerciseRepository implements ExerciseRepository { } } +LocalDataExportSnapshot _backupSnapshot({ + DateTime? exportedAt, + List mediaAssets = const [], + List exercises = const [], + List programs = const [], + List workoutTemplates = const [], + List workoutHistories = const [], + List 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 payload, +}) { + return LocalBackupResource(id: id, updatedAt: updatedAt, payload: payload); +} + +Map _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 = []; + final appliedSnapshots = []; + + @override + Future 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 hasAnyUserData() async => false; + + @override + Future hasOpenActiveWorkoutSession() async => hasOpenSession; + + @override + Future readExportSnapshot( + DateTime exportedAt, + ) async { + return snapshot.copyWith(mediaFiles: snapshot.mediaFiles); + } +} + +final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore { + _FakeLocalBackupMediaStore({this.files = const [], this.restored = const {}}); + + final List files; + final Map restored; + final requestedMediaIds = []; + + @override + Future> readEmbeddableFiles( + List mediaAssets, + ) async { + requestedMediaIds.addAll(mediaAssets.map((asset) => asset.id)); + return files; + } + + @override + Future> restoreEmbeddedFiles( + List files, + ) async { + return restored; + } +} + final class _FakeWorkoutTemplateRepository implements WorkoutTemplateRepository { final templates = []; diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 24bf6bc..6567d47 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -16,6 +16,7 @@ void main() { late local.DriftWorkoutHistoryRepository historyRepository; late local.DriftProgressionStatsRepository progressionStatsRepository; late local.DriftLocalSyncChangeRepository syncChangeRepository; + late local.DriftLocalDataBackupRepository localDataBackupRepository; late local.DriftExercisePerformanceReferenceRepository performanceReferenceRepository; @@ -30,6 +31,7 @@ void main() { database, ); syncChangeRepository = local.DriftLocalSyncChangeRepository(database); + localDataBackupRepository = local.DriftLocalDataBackupRepository(database); performanceReferenceRepository = local.DriftExercisePerformanceReferenceRepository(database); }); @@ -262,6 +264,233 @@ void main() { 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; + final data = document['data'] as Map; + 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().having( + (error) => error.error, + 'error', + LocalBackupValidationError.activeWorkoutInProgress, + ), + ), + ); + }); + test('program duplication persists copied children through Drift', () async { final now = DateTime.utc(2026, 7, 22, 11, 15); 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({ required String id, 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({ required String id, required String historyId,