test: cover local backup import export
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -5035,7 +5035,7 @@ List<domain.WorkoutHistoryStepResult> _workoutHistoryStepResultsFromPayload(
|
|||||||
stepSnapshotId: _stringFromPayload(map, 'stepSnapshotId', ''),
|
stepSnapshotId: _stringFromPayload(map, 'stepSnapshotId', ''),
|
||||||
stepNameSnapshot: _stringFromPayload(map, 'stepNameSnapshot', ''),
|
stepNameSnapshot: _stringFromPayload(map, 'stepNameSnapshot', ''),
|
||||||
stepTypeSnapshot: _exerciseStepTypeFromDb(
|
stepTypeSnapshot: _exerciseStepTypeFromDb(
|
||||||
map['stepTypeSnapshot'] as String? ?? 'work',
|
map['stepTypeSnapshot'] as String? ?? 'reps',
|
||||||
),
|
),
|
||||||
targetValueSnapshot: map['targetValueSnapshot'] as int? ?? 0,
|
targetValueSnapshot: map['targetValueSnapshot'] as int? ?? 0,
|
||||||
hasScoreSnapshot: map['hasScoreSnapshot'] == true,
|
hasScoreSnapshot: map['hasScoreSnapshot'] == true,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:gametime/application/application.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 {
|
test('Program use case duplicates deeply with copy name conflicts', () async {
|
||||||
final programRepository = _FakeProgramRepository();
|
final programRepository = _FakeProgramRepository();
|
||||||
final templateRepository = _FakeWorkoutTemplateRepository();
|
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
|
final class _FakeWorkoutTemplateRepository
|
||||||
implements WorkoutTemplateRepository {
|
implements WorkoutTemplateRepository {
|
||||||
final templates = <WorkoutTemplate>[];
|
final templates = <WorkoutTemplate>[];
|
||||||
|
|||||||
@ -16,6 +16,7 @@ void main() {
|
|||||||
late local.DriftWorkoutHistoryRepository historyRepository;
|
late local.DriftWorkoutHistoryRepository historyRepository;
|
||||||
late local.DriftProgressionStatsRepository progressionStatsRepository;
|
late local.DriftProgressionStatsRepository progressionStatsRepository;
|
||||||
late local.DriftLocalSyncChangeRepository syncChangeRepository;
|
late local.DriftLocalSyncChangeRepository syncChangeRepository;
|
||||||
|
late local.DriftLocalDataBackupRepository localDataBackupRepository;
|
||||||
late local.DriftExercisePerformanceReferenceRepository
|
late local.DriftExercisePerformanceReferenceRepository
|
||||||
performanceReferenceRepository;
|
performanceReferenceRepository;
|
||||||
|
|
||||||
@ -30,6 +31,7 @@ void main() {
|
|||||||
database,
|
database,
|
||||||
);
|
);
|
||||||
syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
|
syncChangeRepository = local.DriftLocalSyncChangeRepository(database);
|
||||||
|
localDataBackupRepository = local.DriftLocalDataBackupRepository(database);
|
||||||
performanceReferenceRepository =
|
performanceReferenceRepository =
|
||||||
local.DriftExercisePerformanceReferenceRepository(database);
|
local.DriftExercisePerformanceReferenceRepository(database);
|
||||||
});
|
});
|
||||||
@ -262,6 +264,233 @@ void main() {
|
|||||||
expect(template!.tags, isEmpty);
|
expect(template!.tags, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('local backup export includes tags and full workout history', () async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 10);
|
||||||
|
await exerciseRepository.save(
|
||||||
|
Exercise(
|
||||||
|
metadata: _metadata('backup-exercise', now),
|
||||||
|
name: 'Backup Shoot',
|
||||||
|
hasTimeMeasure: false,
|
||||||
|
hasRepsMeasure: true,
|
||||||
|
hasScoreMeasure: false,
|
||||||
|
defaultTargetReps: 10,
|
||||||
|
tags: const ['match'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await historyRepository.save(
|
||||||
|
WorkoutHistory(
|
||||||
|
metadata: _metadata('backup-history', now),
|
||||||
|
nameSnapshot: 'Backup Session',
|
||||||
|
startedAt: now,
|
||||||
|
endedAt: now.add(const Duration(minutes: 10)),
|
||||||
|
totalActiveMs: 600000,
|
||||||
|
completed: true,
|
||||||
|
historySnapshotJson: '{"name":"Backup Session"}',
|
||||||
|
results: [
|
||||||
|
_historySetResult(
|
||||||
|
id: 'backup-result',
|
||||||
|
historyId: 'backup-history',
|
||||||
|
sourceExerciseId: 'backup-exercise',
|
||||||
|
setIndex: 0,
|
||||||
|
startedAt: now,
|
||||||
|
actualReps: 12,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stepResults: [
|
||||||
|
_historyStepResult(
|
||||||
|
id: 'backup-step-result',
|
||||||
|
historyId: 'backup-history',
|
||||||
|
sourceExerciseId: 'backup-exercise',
|
||||||
|
startedAt: now,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final snapshot = await localDataBackupRepository.readExportSnapshot(
|
||||||
|
DateTime.utc(2026, 7, 22, 12),
|
||||||
|
);
|
||||||
|
final document =
|
||||||
|
jsonDecode(utf8.decode(const LocalBackupCodec().encode(snapshot)))
|
||||||
|
as Map<String, Object?>;
|
||||||
|
final data = document['data'] as Map<String, Object?>;
|
||||||
|
final exercises = data['exercises'] as List;
|
||||||
|
final histories = data['workoutHistories'] as List;
|
||||||
|
final history = histories.single as Map;
|
||||||
|
|
||||||
|
expect(document['kind'], 'gametime.localBackup');
|
||||||
|
expect(exercises.single, containsPair('tags', ['match']));
|
||||||
|
expect(history['results'], hasLength(1));
|
||||||
|
expect(history['stepResults'], hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local backup merge applies LWW by type and stable id', () async {
|
||||||
|
final localTime = DateTime.utc(2026, 7, 22, 10);
|
||||||
|
await exerciseRepository.save(
|
||||||
|
Exercise(
|
||||||
|
metadata: _metadata('merge-existing', localTime),
|
||||||
|
name: 'Local older',
|
||||||
|
hasTimeMeasure: false,
|
||||||
|
hasRepsMeasure: true,
|
||||||
|
hasScoreMeasure: false,
|
||||||
|
defaultTargetReps: 8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await exerciseRepository.save(
|
||||||
|
Exercise(
|
||||||
|
metadata: _metadata(
|
||||||
|
'merge-local-newer',
|
||||||
|
localTime.add(const Duration(hours: 3)),
|
||||||
|
),
|
||||||
|
name: 'Local newer',
|
||||||
|
hasTimeMeasure: false,
|
||||||
|
hasRepsMeasure: true,
|
||||||
|
hasScoreMeasure: false,
|
||||||
|
defaultTargetReps: 8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final snapshot = LocalDataExportSnapshot(
|
||||||
|
exportedAt: DateTime.utc(2026, 7, 22, 12),
|
||||||
|
appSchemaVersion: 19,
|
||||||
|
originDeviceId: 'device-backup',
|
||||||
|
mediaAssets: const [],
|
||||||
|
exercises: [
|
||||||
|
_backupExerciseResource(
|
||||||
|
id: 'merge-existing',
|
||||||
|
name: 'Backup newer',
|
||||||
|
updatedAt: localTime.add(const Duration(hours: 2)),
|
||||||
|
),
|
||||||
|
_backupExerciseResource(
|
||||||
|
id: 'merge-local-newer',
|
||||||
|
name: 'Backup older',
|
||||||
|
updatedAt: localTime.add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
_backupExerciseResource(
|
||||||
|
id: 'merge-new',
|
||||||
|
name: 'Backup inserted',
|
||||||
|
updatedAt: localTime.add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
programs: const [],
|
||||||
|
workoutTemplates: const [],
|
||||||
|
workoutHistories: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await localDataBackupRepository.applyImportSnapshot(
|
||||||
|
snapshot: snapshot,
|
||||||
|
mode: LocalBackupImportMode.merge,
|
||||||
|
importedAt: DateTime.utc(2026, 7, 22, 14),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.insertedCount, 1);
|
||||||
|
expect(result.updatedCount, 1);
|
||||||
|
expect(result.ignoredOlderCount, 1);
|
||||||
|
expect(
|
||||||
|
(await exerciseRepository.findById('merge-existing'))!.name,
|
||||||
|
'Backup newer',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await exerciseRepository.findById('merge-local-newer'))!.name,
|
||||||
|
'Local newer',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await exerciseRepository.findById('merge-new'))!.name,
|
||||||
|
'Backup inserted',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'local backup replaceAll soft deletes absent data and imports file',
|
||||||
|
() async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 10);
|
||||||
|
await exerciseRepository.save(
|
||||||
|
Exercise(
|
||||||
|
metadata: _metadata('replace-old', now),
|
||||||
|
name: 'Old',
|
||||||
|
hasTimeMeasure: false,
|
||||||
|
hasRepsMeasure: true,
|
||||||
|
hasScoreMeasure: false,
|
||||||
|
defaultTargetReps: 8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final snapshot = LocalDataExportSnapshot(
|
||||||
|
exportedAt: DateTime.utc(2026, 7, 22, 12),
|
||||||
|
appSchemaVersion: 19,
|
||||||
|
originDeviceId: 'device-backup',
|
||||||
|
mediaAssets: const [],
|
||||||
|
exercises: [
|
||||||
|
_backupExerciseResource(
|
||||||
|
id: 'replace-new',
|
||||||
|
name: 'New',
|
||||||
|
updatedAt: now.add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
programs: const [],
|
||||||
|
workoutTemplates: const [],
|
||||||
|
workoutHistories: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await localDataBackupRepository.applyImportSnapshot(
|
||||||
|
snapshot: snapshot,
|
||||||
|
mode: LocalBackupImportMode.replaceAll,
|
||||||
|
importedAt: DateTime.utc(2026, 7, 22, 14),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.deletedByReplaceCount, 1);
|
||||||
|
expect(await exerciseRepository.listActive(), hasLength(1));
|
||||||
|
expect((await exerciseRepository.findById('replace-new'))!.name, 'New');
|
||||||
|
final tombstones =
|
||||||
|
await (database.select(database.changeLogEntries)..where(
|
||||||
|
(table) =>
|
||||||
|
table.entityId.equals('replace-old') &
|
||||||
|
table.operation.equals('softDelete'),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
expect(tombstones, hasLength(1));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('local backup import is blocked by open active workout', () async {
|
||||||
|
final now = DateTime.utc(2026, 7, 22, 10);
|
||||||
|
await activeRepository.save(
|
||||||
|
ActiveWorkoutSession(
|
||||||
|
metadata: _metadata('active-import-block', now),
|
||||||
|
status: ActiveWorkoutStatus.running,
|
||||||
|
startedAt: now,
|
||||||
|
lastPersistedAt: now,
|
||||||
|
elapsedActiveMs: 0,
|
||||||
|
currentProgramIndex: 0,
|
||||||
|
currentExerciseIndex: 0,
|
||||||
|
currentSetIndex: 0,
|
||||||
|
resolvedTemplateSnapshotJson: '{"programs":[]}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
localDataBackupRepository.applyImportSnapshot(
|
||||||
|
snapshot: LocalDataExportSnapshot(
|
||||||
|
exportedAt: now,
|
||||||
|
appSchemaVersion: 19,
|
||||||
|
originDeviceId: 'device-backup',
|
||||||
|
mediaAssets: const [],
|
||||||
|
exercises: const [],
|
||||||
|
programs: const [],
|
||||||
|
workoutTemplates: const [],
|
||||||
|
workoutHistories: const [],
|
||||||
|
),
|
||||||
|
mode: LocalBackupImportMode.merge,
|
||||||
|
importedAt: now.add(const Duration(hours: 1)),
|
||||||
|
),
|
||||||
|
throwsA(
|
||||||
|
isA<LocalBackupException>().having(
|
||||||
|
(error) => error.error,
|
||||||
|
'error',
|
||||||
|
LocalBackupValidationError.activeWorkoutInProgress,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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(
|
||||||
@ -1783,6 +2012,37 @@ EntityMetadata _metadata(String id, DateTime now, [int localRevision = 0]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LocalBackupResource _backupExerciseResource({
|
||||||
|
required String id,
|
||||||
|
required String name,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
}) {
|
||||||
|
return LocalBackupResource(
|
||||||
|
id: id,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
payload: {
|
||||||
|
'id': id,
|
||||||
|
'metadata': {
|
||||||
|
'id': id,
|
||||||
|
'createdAt': updatedAt.toUtc().toIso8601String(),
|
||||||
|
'updatedAt': updatedAt.toUtc().toIso8601String(),
|
||||||
|
'deletedAt': null,
|
||||||
|
'schemaVersion': 1,
|
||||||
|
'syncState': 'synced',
|
||||||
|
'localRevision': 0,
|
||||||
|
'originDeviceId': 'device-backup',
|
||||||
|
},
|
||||||
|
'name': name,
|
||||||
|
'hasTimeMeasure': false,
|
||||||
|
'hasRepsMeasure': true,
|
||||||
|
'hasScoreMeasure': false,
|
||||||
|
'defaultTargetReps': 10,
|
||||||
|
'tags': const ['backup'],
|
||||||
|
'steps': const [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
WorkoutHistory _history({
|
WorkoutHistory _history({
|
||||||
required String id,
|
required String id,
|
||||||
required DateTime startedAt,
|
required DateTime startedAt,
|
||||||
@ -1803,6 +2063,35 @@ WorkoutHistory _history({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WorkoutHistoryStepResult _historyStepResult({
|
||||||
|
required String id,
|
||||||
|
required String historyId,
|
||||||
|
required String sourceExerciseId,
|
||||||
|
required DateTime startedAt,
|
||||||
|
}) {
|
||||||
|
return WorkoutHistoryStepResult(
|
||||||
|
metadata: _metadata(id, startedAt),
|
||||||
|
workoutHistoryId: historyId,
|
||||||
|
programSnapshotId: 'program-snapshot',
|
||||||
|
exerciseSnapshotId: 'exercise-snapshot-$sourceExerciseId',
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 0,
|
||||||
|
setIndex: 0,
|
||||||
|
passageIndex: 0,
|
||||||
|
stepIndex: 0,
|
||||||
|
stepSnapshotId: 'step-snapshot',
|
||||||
|
stepNameSnapshot: 'Step',
|
||||||
|
stepTypeSnapshot: ExerciseStepType.reps,
|
||||||
|
targetValueSnapshot: 10,
|
||||||
|
hasScoreSnapshot: false,
|
||||||
|
status: SetResultStatus.completed,
|
||||||
|
startedAt: startedAt,
|
||||||
|
completedAt: startedAt.add(const Duration(seconds: 10)),
|
||||||
|
actualReps: 10,
|
||||||
|
sourceExerciseIdSnapshot: sourceExerciseId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
WorkoutHistorySetResult _historySetResult({
|
WorkoutHistorySetResult _historySetResult({
|
||||||
required String id,
|
required String id,
|
||||||
required String historyId,
|
required String historyId,
|
||||||
|
|||||||
Reference in New Issue
Block a user