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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:40:59 +02:00
parent d90c7ad700
commit 6c87674652

View File

@ -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<Exercise>().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 { 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(