test: cover local backup import export

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 13:27:48 +02:00
parent c3d5273869
commit 4384797e25
3 changed files with 615 additions and 1 deletions

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
@ -2333,6 +2334,223 @@ void main() {
]);
});
test('Local backup codec writes v1 envelope and preview counts', () {
final codec = LocalBackupCodec();
final snapshot = _backupSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 12),
exercises: [
_backupResource(
id: 'exercise-1',
updatedAt: DateTime.utc(2026, 7, 22, 10),
payload: {
'id': 'exercise-1',
'metadata': _backupMetadata(
'exercise-1',
DateTime.utc(2026, 7, 22, 10),
),
'name': 'Shoot',
},
),
],
mediaFiles: const [
EmbeddedBackupMediaFile(
mediaAssetId: 'media-1',
role: 'original',
fileName: 'media-1.png',
mimeType: 'image/png',
sizeBytes: 4,
base64: 'dGVzdA==',
),
],
);
final decodedJson = jsonDecode(utf8.decode(codec.encode(snapshot))) as Map;
final preview = codec.preview(codec.encode(snapshot));
expect(decodedJson['kind'], 'gametime.localBackup');
expect(decodedJson['formatVersion'], 1);
expect(preview.counts.exercises, 1);
expect(preview.counts.embeddedMediaFiles, 1);
expect(preview.hasEmbeddedMedia, isTrue);
expect(preview.missingMediaCount, 0);
});
test('Local backup codec rejects invalid and future files', () {
final codec = LocalBackupCodec();
expect(
() => codec.decode(Uint8List.fromList(utf8.encode('not json'))),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.corrupted,
),
),
);
final future = utf8.encode(
jsonEncode({
'kind': 'gametime.localBackup',
'formatVersion': 2,
'minSupportedFormatVersion': 2,
'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(future)),
throwsA(
isA<LocalBackupException>().having(
(error) => error.error,
'error',
LocalBackupValidationError.newerVersion,
),
),
);
});
test('Data export use case embeds media and names gametime file', () async {
final repository = _FakeLocalDataBackupRepository(
snapshot: _backupSnapshot(
exportedAt: DateTime.utc(2026, 7, 22),
mediaAssets: [
_backupResource(
id: 'media-1',
updatedAt: DateTime.utc(2026, 7, 22, 10),
payload: {
'id': 'media-1',
'metadata': _backupMetadata(
'media-1',
DateTime.utc(2026, 7, 22, 10),
),
'localUri': 'file:///tmp/media-1.png',
},
),
],
),
);
final mediaStore = _FakeLocalBackupMediaStore(
files: const [
EmbeddedBackupMediaFile(
mediaAssetId: 'media-1',
role: 'original',
fileName: 'media-1.png',
mimeType: 'image/png',
sizeBytes: 4,
base64: 'dGVzdA==',
),
],
);
final useCase = DataExportUseCase(
repository: repository,
mediaStore: mediaStore,
clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)),
);
final document = await useCase.exportAll();
final preview = const LocalBackupCodec().preview(document.bytes);
expect(document.fileName, 'gametime-sauvegarde-2026-07-22.gametime');
expect(mediaStore.requestedMediaIds, ['media-1']);
expect(preview.counts.mediaAssets, 1);
expect(preview.counts.embeddedMediaFiles, 1);
});
test('Data import use case blocks when active workout is open', () async {
final repository = _FakeLocalDataBackupRepository(hasOpenSession: true);
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.activeWorkoutInProgress,
),
),
);
expect(repository.appliedModes, isEmpty);
});
test(
'Data import use case restores media and applies selected mode',
() async {
final repository = _FakeLocalDataBackupRepository();
final mediaStore = _FakeLocalBackupMediaStore(
restored: const {
'media-1': RestoredMediaFile(
mediaAssetId: 'media-1',
localUri: 'file:///restored/media-1.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-1',
updatedAt: DateTime.utc(2026, 7, 22, 10),
payload: {
'id': 'media-1',
'metadata': _backupMetadata(
'media-1',
DateTime.utc(2026, 7, 22, 10),
),
'localUri': 'file:///old/media-1.png',
},
),
],
mediaFiles: const [
EmbeddedBackupMediaFile(
mediaAssetId: 'media-1',
role: 'original',
fileName: 'media-1.png',
mimeType: 'image/png',
sizeBytes: 4,
base64: 'dGVzdA==',
),
],
),
);
final result = await useCase.importFrom(
bytes,
mode: LocalBackupImportMode.replaceAll,
);
expect(result.insertedCount, 0);
expect(repository.appliedModes, [LocalBackupImportMode.replaceAll]);
expect(
repository
.appliedSnapshots
.single
.mediaAssets
.single
.payload['localUri'],
'file:///restored/media-1.png',
);
},
);
test('Program use case duplicates deeply with copy name conflicts', () async {
final programRepository = _FakeProgramRepository();
final templateRepository = _FakeWorkoutTemplateRepository();
@ -3345,6 +3563,113 @@ final class _FakeExerciseRepository implements ExerciseRepository {
}
}
LocalDataExportSnapshot _backupSnapshot({
DateTime? exportedAt,
List<LocalBackupResource> mediaAssets = const [],
List<LocalBackupResource> exercises = const [],
List<LocalBackupResource> programs = const [],
List<LocalBackupResource> workoutTemplates = const [],
List<LocalBackupResource> workoutHistories = const [],
List<EmbeddedBackupMediaFile> mediaFiles = const [],
}) {
return LocalDataExportSnapshot(
exportedAt: exportedAt ?? DateTime.utc(2026, 7, 22, 12),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: mediaAssets,
exercises: exercises,
programs: programs,
workoutTemplates: workoutTemplates,
workoutHistories: workoutHistories,
mediaFiles: mediaFiles,
);
}
LocalBackupResource _backupResource({
required String id,
required DateTime updatedAt,
required Map<String, Object?> payload,
}) {
return LocalBackupResource(id: id, updatedAt: updatedAt, payload: payload);
}
Map<String, Object?> _backupMetadata(String id, DateTime updatedAt) => {
'id': id,
'createdAt': updatedAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
'deletedAt': null,
'schemaVersion': 1,
'syncState': 'synced',
'localRevision': 0,
'originDeviceId': 'device-1',
};
final class _FakeLocalDataBackupRepository
implements LocalDataBackupRepository {
_FakeLocalDataBackupRepository({
LocalDataExportSnapshot? snapshot,
this.hasOpenSession = false,
}) : snapshot = snapshot ?? _backupSnapshot();
final LocalDataExportSnapshot snapshot;
final bool hasOpenSession;
final appliedModes = <LocalBackupImportMode>[];
final appliedSnapshots = <LocalDataExportSnapshot>[];
@override
Future<LocalBackupImportResult> applyImportSnapshot({
required LocalDataExportSnapshot snapshot,
required LocalBackupImportMode mode,
required DateTime importedAt,
}) async {
appliedModes.add(mode);
appliedSnapshots.add(snapshot);
return const LocalBackupImportResult(
insertedCount: 0,
updatedCount: 0,
ignoredOlderCount: 0,
deletedByReplaceCount: 0,
missingMediaCount: 0,
);
}
@override
Future<bool> hasAnyUserData() async => false;
@override
Future<bool> hasOpenActiveWorkoutSession() async => hasOpenSession;
@override
Future<LocalDataExportSnapshot> readExportSnapshot(
DateTime exportedAt,
) async {
return snapshot.copyWith(mediaFiles: snapshot.mediaFiles);
}
}
final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
_FakeLocalBackupMediaStore({this.files = const [], this.restored = const {}});
final List<EmbeddedBackupMediaFile> files;
final Map<String, RestoredMediaFile> restored;
final requestedMediaIds = <String>[];
@override
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
List<LocalBackupResource> mediaAssets,
) async {
requestedMediaIds.addAll(mediaAssets.map((asset) => asset.id));
return files;
}
@override
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
List<EmbeddedBackupMediaFile> files,
) async {
return restored;
}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
final templates = <WorkoutTemplate>[];