From 6c87674652dbb488022618cdfaf7e57c92cab2cb Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 13:40:59 +0200 Subject: [PATCH] test: cover local backup round-trip and transactional rollback Add Drift-level tests that go through the real codec (export -> encode -> decode -> import) for both merge and replaceAll modes across two independent databases, plus a test proving a failure mid-import rolls back all mutations within the transaction. Co-Authored-By: Claude Sonnet 5 --- .../drift_repositories_test.dart | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index 6567d47..3a81daf 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -491,6 +491,244 @@ void main() { ); }); + test( + 'local backup round-trip merges exported data into a fresh database', + () async { + final now = DateTime.utc(2026, 7, 22, 10); + await exerciseRepository.save( + Exercise( + metadata: _metadata('roundtrip-exercise', now), + name: 'Roundtrip Shoot', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + tags: const ['match'], + ), + ); + await historyRepository.save( + WorkoutHistory( + metadata: _metadata('roundtrip-history', now), + nameSnapshot: 'Roundtrip Session', + startedAt: now, + endedAt: now.add(const Duration(minutes: 10)), + totalActiveMs: 600000, + completed: true, + historySnapshotJson: '{"name":"Roundtrip Session"}', + results: [ + _historySetResult( + id: 'roundtrip-result', + historyId: 'roundtrip-history', + sourceExerciseId: 'roundtrip-exercise', + setIndex: 0, + startedAt: now, + actualReps: 12, + ), + ], + stepResults: [ + _historyStepResult( + id: 'roundtrip-step-result', + historyId: 'roundtrip-history', + sourceExerciseId: 'roundtrip-exercise', + startedAt: now, + ), + ], + ), + ); + + final snapshot = await localDataBackupRepository.readExportSnapshot( + DateTime.utc(2026, 7, 22, 12), + ); + final bytes = const LocalBackupCodec().encode(snapshot); + final decoded = const LocalBackupCodec().decode(bytes); + + final targetDatabase = local.AppDatabase(NativeDatabase.memory()); + final targetExerciseRepository = local.DriftExerciseRepository( + targetDatabase, + ); + final targetHistoryRepository = local.DriftWorkoutHistoryRepository( + targetDatabase, + ); + final targetBackupRepository = local.DriftLocalDataBackupRepository( + targetDatabase, + ); + addTearDown(targetDatabase.close); + await targetExerciseRepository.save( + Exercise( + metadata: _metadata('target-existing', now), + name: 'Target existing', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 6, + ), + ); + + final result = await targetBackupRepository.applyImportSnapshot( + snapshot: decoded, + mode: LocalBackupImportMode.merge, + importedAt: DateTime.utc(2026, 7, 22, 14), + ); + + expect(result.insertedCount, 2); + final restoredExercise = await targetExerciseRepository.findById( + 'roundtrip-exercise', + ); + expect(restoredExercise!.name, 'Roundtrip Shoot'); + expect(restoredExercise.tags, ['match']); + final restoredHistory = await targetHistoryRepository.findById( + 'roundtrip-history', + ); + expect(restoredHistory!.results, hasLength(1)); + expect(restoredHistory.stepResults, hasLength(1)); + expect( + await targetExerciseRepository.findById('target-existing'), + isNotNull, + ); + }, + ); + + test( + 'local backup round-trip replaceAll restores exported data and purges the rest', + () async { + final now = DateTime.utc(2026, 7, 22, 10); + await exerciseRepository.save( + Exercise( + metadata: _metadata('roundtrip-replace-exercise', now), + name: 'Kept', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ), + ); + + final snapshot = await localDataBackupRepository.readExportSnapshot( + DateTime.utc(2026, 7, 22, 12), + ); + final bytes = const LocalBackupCodec().encode(snapshot); + final decoded = const LocalBackupCodec().decode(bytes); + + final targetDatabase = local.AppDatabase(NativeDatabase.memory()); + final targetExerciseRepository = local.DriftExerciseRepository( + targetDatabase, + ); + final targetBackupRepository = local.DriftLocalDataBackupRepository( + targetDatabase, + ); + addTearDown(targetDatabase.close); + await targetExerciseRepository.save( + Exercise( + metadata: _metadata('target-to-purge', now), + name: 'Should disappear', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 6, + ), + ); + + final result = await targetBackupRepository.applyImportSnapshot( + snapshot: decoded, + mode: LocalBackupImportMode.replaceAll, + importedAt: DateTime.utc(2026, 7, 22, 14), + ); + + expect(result.deletedByReplaceCount, 1); + expect( + await targetExerciseRepository.listActive(), + isNot( + contains( + isA().having( + (e) => e.metadata.id, + 'id', + 'target-to-purge', + ), + ), + ), + ); + expect( + (await targetExerciseRepository.findById( + 'target-to-purge', + ))!.metadata.deletedAt, + isNotNull, + ); + expect( + (await targetExerciseRepository.findById( + 'roundtrip-replace-exercise', + ))!.name, + 'Kept', + ); + final tombstones = + await (targetDatabase.select(targetDatabase.changeLogEntries)..where( + (table) => + table.entityId.equals('target-to-purge') & + table.operation.equals('softDelete'), + )) + .get(); + expect(tombstones, hasLength(1)); + }, + ); + + test( + 'local backup import rolls back all mutations when applying a resource fails', + () async { + final now = DateTime.utc(2026, 7, 22, 10); + await exerciseRepository.save( + Exercise( + metadata: _metadata('rollback-existing', now), + name: 'Existing', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ), + ); + final snapshot = LocalDataExportSnapshot( + exportedAt: DateTime.utc(2026, 7, 22, 12), + appSchemaVersion: 19, + originDeviceId: 'device-backup', + mediaAssets: const [], + exercises: [ + _backupExerciseResource( + id: 'rollback-ok', + name: 'Should not persist', + updatedAt: now.add(const Duration(hours: 1)), + ), + ], + programs: const [], + workoutTemplates: const [], + // totalActiveMs has the wrong type, which makes the cast throw while + // applying this resource, after the exercise above already inserted. + workoutHistories: [ + LocalBackupResource( + id: 'rollback-broken-history', + updatedAt: now.add(const Duration(hours: 1)), + payload: const { + 'id': 'rollback-broken-history', + 'totalActiveMs': 'not-a-number', + }, + ), + ], + ); + + await expectLater( + localDataBackupRepository.applyImportSnapshot( + snapshot: snapshot, + mode: LocalBackupImportMode.merge, + importedAt: DateTime.utc(2026, 7, 22, 14), + ), + throwsA(anything), + ); + + expect(await exerciseRepository.findById('rollback-ok'), isNull); + expect( + (await exerciseRepository.findById('rollback-existing'))!.name, + 'Existing', + ); + }, + ); + test('program duplication persists copied children through Drift', () async { final now = DateTime.utc(2026, 7, 22, 11, 15); await exerciseRepository.save(