test: cover local backup validation errors and media restore counting

Add coverage for invalidFile/incompatibleFormat/corrupted detection,
importFailedNoMutation wrapping of unexpected repository failures, and
missingMediaCount reflecting actual restore/checksum outcomes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:40:56 +02:00
parent 6d9456275f
commit d90c7ad700

View File

@ -2549,6 +2549,190 @@ void main() {
); );
}); });
test('Local backup codec rejects wrong kind and incompatible format', () {
final codec = LocalBackupCodec();
final wrongKind = utf8.encode(
jsonEncode({
'kind': 'other.app.backup',
'formatVersion': 1,
'minSupportedFormatVersion': 1,
'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(wrongKind)),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.invalidFile,
),
),
);
final missingVersion = utf8.encode(
jsonEncode({
'kind': 'gametime.localBackup',
'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(missingVersion)),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.incompatibleFormat,
),
),
);
});
test('Local backup codec rejects malformed data collections', () {
final codec = LocalBackupCodec();
final malformedData = utf8.encode(
jsonEncode({
'kind': 'gametime.localBackup',
'formatVersion': 1,
'minSupportedFormatVersion': 1,
'exportedAt': DateTime.utc(2026, 7, 22).toIso8601String(),
'data': 'not-a-map',
'mediaFiles': const [],
}),
);
expect(
() => codec.decode(Uint8List.fromList(malformedData)),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.corrupted,
),
),
);
});
test(
'Data import use case wraps unexpected repository failure as importFailedNoMutation',
() async {
final repository = _ThrowingLocalDataBackupRepository();
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<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.importFailedNoMutation,
),
),
);
},
);
test(
'Data import use case counts media assets not successfully restored',
() async {
final repository = _FakeLocalDataBackupRepository();
final mediaStore = _FakeLocalBackupMediaStore(
restored: const {
'media-restored': RestoredMediaFile(
mediaAssetId: 'media-restored',
localUri: 'file:///restored/media-restored.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-restored',
updatedAt: DateTime.utc(2026, 7, 22, 10),
payload: {
'id': 'media-restored',
'metadata': _backupMetadata(
'media-restored',
DateTime.utc(2026, 7, 22, 10),
),
'localUri': 'file:///old/media-restored.png',
},
),
_backupResource(
id: 'media-corrupted',
updatedAt: DateTime.utc(2026, 7, 22, 10),
payload: {
'id': 'media-corrupted',
'metadata': _backupMetadata(
'media-corrupted',
DateTime.utc(2026, 7, 22, 10),
),
'localUri': 'file:///old/media-corrupted.png',
},
),
],
mediaFiles: const [
EmbeddedBackupMediaFile(
mediaAssetId: 'media-restored',
role: 'original',
fileName: 'media-restored.png',
mimeType: 'image/png',
sizeBytes: 4,
sha256:
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
base64: 'dGVzdA==',
),
EmbeddedBackupMediaFile(
mediaAssetId: 'media-corrupted',
role: 'original',
fileName: 'media-corrupted.png',
mimeType: 'image/png',
sizeBytes: 4,
sha256: 'checksum-mismatch',
base64: 'dGVzdA==',
),
],
),
);
final result = await useCase.importFrom(
bytes,
mode: LocalBackupImportMode.merge,
);
expect(result.missingMediaCount, 1);
},
);
test('Program use case duplicates deeply with copy name conflicts', () async { test('Program use case duplicates deeply with copy name conflicts', () async {
final programRepository = _FakeProgramRepository(); final programRepository = _FakeProgramRepository();
final templateRepository = _FakeWorkoutTemplateRepository(); final templateRepository = _FakeWorkoutTemplateRepository();
@ -3645,6 +3829,31 @@ final class _FakeLocalDataBackupRepository
} }
} }
final class _ThrowingLocalDataBackupRepository
implements LocalDataBackupRepository {
@override
Future<LocalBackupImportResult> applyImportSnapshot({
required LocalDataExportSnapshot snapshot,
required LocalBackupImportMode mode,
required DateTime importedAt,
}) async {
throw Exception('transaction failed');
}
@override
Future<bool> hasAnyUserData() async => false;
@override
Future<bool> hasOpenActiveWorkoutSession() async => false;
@override
Future<LocalDataExportSnapshot> readExportSnapshot(
DateTime exportedAt,
) async {
return _backupSnapshot();
}
}
final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore { final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
_FakeLocalBackupMediaStore({this.files = const [], this.restored = const {}}); _FakeLocalBackupMediaStore({this.files = const [], this.restored = const {}});