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 {
|
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user