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:
@ -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 {
|
||||
final now = DateTime.utc(2026, 7, 22, 11, 15);
|
||||
await exerciseRepository.save(
|
||||
|
||||
Reference in New Issue
Block a user