test: cover local backup import export
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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>[];
|
||||
|
||||
Reference in New Issue
Block a user