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:
@ -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 {}});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user