Covers F1/F2 (#84): panel visibility in both profile states, export success/failure snackbars, merge success, replace-all double confirmation (including cancel), invalid file dialog, active-session block, and silent cancellation of the file picker.
1088 lines
31 KiB
Dart
1088 lines
31 KiB
Dart
import 'dart:convert';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:gametime/application/application.dart';
|
||
import 'package:gametime/domain/domain.dart';
|
||
import 'package:gametime/presentation/profile_screen.dart';
|
||
import 'package:share_plus/share_plus.dart';
|
||
|
||
void main() {
|
||
testWidgets('le profil déconnecté invite sans bloquer le reste', (
|
||
tester,
|
||
) async {
|
||
final harness = _AuthHarness();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(find.text('Compte optionnel'), findsOneWidget);
|
||
expect(find.text('Créer un compte'), findsOneWidget);
|
||
expect(find.text('Se connecter'), findsOneWidget);
|
||
expect(find.byType(AlertDialog), findsNothing);
|
||
});
|
||
|
||
testWidgets('la connexion invalide affiche une erreur inline', (
|
||
tester,
|
||
) async {
|
||
final harness = _AuthHarness()
|
||
..remote.loginFailure = RemoteAuthFailure.invalidCredentials;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Se connecter'));
|
||
await tester.pumpAndSettle();
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, 'Email'),
|
||
'a@b.fr',
|
||
);
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, 'Mot de passe'),
|
||
'password1',
|
||
);
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
|
||
expect(find.byType(AlertDialog), findsNothing);
|
||
});
|
||
|
||
testWidgets('la connexion réussie affiche le profil connecté', (
|
||
tester,
|
||
) async {
|
||
final harness = _AuthHarness();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Se connecter'));
|
||
await tester.pumpAndSettle();
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, 'Email'),
|
||
'alex@example.com',
|
||
);
|
||
await tester.enterText(
|
||
find.widgetWithText(TextFormField, 'Mot de passe'),
|
||
'password1',
|
||
);
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('alex@example.com'), findsOneWidget);
|
||
expect(find.text('Synchronisation'), findsOneWidget);
|
||
expect(find.text('Partages reçus'), findsOneWidget);
|
||
expect(harness.syncRemote.pullCalls, 1);
|
||
});
|
||
|
||
testWidgets('la déconnexion repasse au profil déconnecté', (tester) async {
|
||
final harness = _AuthHarness(
|
||
initialSession: UserAccountSession(
|
||
id: 'account-1',
|
||
serverUserId: 'server-user-1',
|
||
email: 'alex@example.com',
|
||
displayName: 'Alex',
|
||
isLoggedIn: true,
|
||
createdAt: DateTime.utc(2026, 7, 17),
|
||
updatedAt: DateTime.utc(2026, 7, 17),
|
||
),
|
||
initialToken: 'token-1',
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(find.text('Alex'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Se déconnecter'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Se déconnecter'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(harness.tokenStore.token, isNull);
|
||
expect(harness.accountRepository.session?.isLoggedIn, isFalse);
|
||
expect(find.text('Compte optionnel'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets(
|
||
'le bouton de synchronisation lance une synchronisation manuelle',
|
||
(tester) async {
|
||
final harness = _AuthHarness.connected();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(find.text('Synchroniser maintenant'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Synchroniser maintenant'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(harness.syncRemote.pullCalls, 1);
|
||
},
|
||
);
|
||
|
||
testWidgets('un souci de synchronisation reste neutre dans le profil', (
|
||
tester,
|
||
) async {
|
||
final harness = _AuthHarness.connected()..syncRemote.throwOnPull = true;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Synchroniser maintenant'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(harness.syncMetadata.metadata.status, OnlineSyncStatus.failure);
|
||
expect(find.text('Synchronisation en attente'), findsOneWidget);
|
||
expect(find.textContaining('Erreur'), findsNothing);
|
||
expect(find.byType(AlertDialog), findsNothing);
|
||
});
|
||
|
||
testWidgets('le statut se met à jour après une synchronisation réussie', (
|
||
tester,
|
||
) async {
|
||
final harness = _AuthHarness.connected();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(find.text('Synchronisation en attente'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Synchroniser maintenant'));
|
||
await tester.pump();
|
||
await tester.pump();
|
||
await tester.pump();
|
||
|
||
expect(find.textContaining('Dernière synchro :'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('partages reçus ouvre la boîte de réception', (tester) async {
|
||
final harness = _AuthHarness.connected();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Partages reçus'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Aucun partage reçu'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets(
|
||
'la section Sauvegarde locale est visible déconnecté et connecté',
|
||
(tester) async {
|
||
final harness = _AuthHarness();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(find.text('Sauvegarde locale'), findsOneWidget);
|
||
expect(find.text('Exporter mes données'), findsOneWidget);
|
||
expect(find.text('Importer des données'), findsOneWidget);
|
||
|
||
final connectedHarness = _AuthHarness.connected();
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: connectedHarness.useCases,
|
||
syncUseCases: connectedHarness.syncUseCases,
|
||
shareUseCases: connectedHarness.shareUseCases,
|
||
dataExportUseCase: connectedHarness.dataExportUseCase,
|
||
dataImportUseCase: connectedHarness.dataImportUseCase,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
expect(find.text('Sauvegarde locale'), findsOneWidget);
|
||
expect(find.text('Exporter mes données'), findsOneWidget);
|
||
expect(find.text('Importer des données'), findsOneWidget);
|
||
},
|
||
);
|
||
|
||
testWidgets(
|
||
'export réussi affiche le message de sauvegarde exportée',
|
||
(tester) async {
|
||
final harness = _AuthHarness();
|
||
final exporter = _FakeBackupFileExporter(
|
||
status: ShareResultStatus.success,
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
backupFileExporter: exporter,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Exporter mes données'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
|
||
expect(exporter.exportedFileName, isNotNull);
|
||
expect(exporter.exportedFileName, endsWith('.gametime'));
|
||
},
|
||
);
|
||
|
||
testWidgets('un échec d’export affiche un message neutre', (tester) async {
|
||
final harness = _AuthHarness();
|
||
final exporter = _FakeBackupFileExporter()..throwOnExport = true;
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
backupFileExporter: exporter,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Exporter mes données'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Export impossible pour le moment.'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('import fusion réussie affiche les données importées', (
|
||
tester,
|
||
) async {
|
||
final harness = _AuthHarness()..backupRepository.hasData = true;
|
||
final bytes = const LocalBackupCodec().encode(
|
||
LocalDataExportSnapshot(
|
||
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
|
||
appSchemaVersion: 19,
|
||
originDeviceId: 'device-1',
|
||
mediaAssets: const [],
|
||
exercises: const [],
|
||
programs: const [],
|
||
workoutTemplates: const [],
|
||
workoutHistories: const [],
|
||
mediaFiles: const [],
|
||
),
|
||
);
|
||
final picker = _FakeBackupFilePicker(
|
||
file: LocalBackupPickedFile(
|
||
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
|
||
bytes: bytes,
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
backupFilePicker: picker,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Importer des données'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Importer cette sauvegarde ?'), findsOneWidget);
|
||
expect(find.text('Fusionner'), findsOneWidget);
|
||
expect(find.text('Remplacer tout'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Fusionner'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Données importées.'), findsOneWidget);
|
||
expect(harness.backupRepository.appliedModes, [
|
||
LocalBackupImportMode.merge,
|
||
]);
|
||
});
|
||
|
||
testWidgets(
|
||
'remplacer tout exige une seconde confirmation destructive',
|
||
(tester) async {
|
||
final harness = _AuthHarness()..backupRepository.hasData = true;
|
||
final bytes = const LocalBackupCodec().encode(
|
||
LocalDataExportSnapshot(
|
||
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
|
||
appSchemaVersion: 19,
|
||
originDeviceId: 'device-1',
|
||
mediaAssets: const [],
|
||
exercises: const [],
|
||
programs: const [],
|
||
workoutTemplates: const [],
|
||
workoutHistories: const [],
|
||
mediaFiles: const [],
|
||
),
|
||
);
|
||
final picker = _FakeBackupFilePicker(
|
||
file: LocalBackupPickedFile(
|
||
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
|
||
bytes: bytes,
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
backupFilePicker: picker,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Importer des données'));
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.text('Remplacer tout'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
find.text('Remplacer toutes les données locales ?'),
|
||
findsOneWidget,
|
||
);
|
||
expect(harness.backupRepository.appliedModes, isEmpty);
|
||
|
||
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(harness.backupRepository.appliedModes, isEmpty);
|
||
|
||
await tester.tap(find.text('Importer des données'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Remplacer tout'));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.widgetWithText(FilledButton, 'Remplacer tout'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Sauvegarde restaurée.'), findsOneWidget);
|
||
expect(harness.backupRepository.appliedModes, [
|
||
LocalBackupImportMode.replaceAll,
|
||
]);
|
||
},
|
||
);
|
||
|
||
testWidgets('un fichier invalide affiche le message dédié', (tester) async {
|
||
final harness = _AuthHarness();
|
||
final picker = _FakeBackupFilePicker(
|
||
file: LocalBackupPickedFile(
|
||
fileName: 'notes.txt',
|
||
bytes: Uint8List.fromList(utf8.encode('{"kind":"something.else"}')),
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
backupFilePicker: picker,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Importer des données'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Fichier invalide'), findsOneWidget);
|
||
expect(
|
||
find.text('Ce fichier n’est pas une sauvegarde GameTime valide.'),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('une séance active bloque l’import', (tester) async {
|
||
final harness = _AuthHarness()
|
||
..backupRepository.hasOpenSession = true
|
||
..backupRepository.hasData = true;
|
||
final bytes = const LocalBackupCodec().encode(
|
||
LocalDataExportSnapshot(
|
||
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
|
||
appSchemaVersion: 19,
|
||
originDeviceId: 'device-1',
|
||
mediaAssets: const [],
|
||
exercises: const [],
|
||
programs: const [],
|
||
workoutTemplates: const [],
|
||
workoutHistories: const [],
|
||
mediaFiles: const [],
|
||
),
|
||
);
|
||
final picker = _FakeBackupFilePicker(
|
||
file: LocalBackupPickedFile(
|
||
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
|
||
bytes: bytes,
|
||
),
|
||
);
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
backupFilePicker: picker,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Importer des données'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Importer cette sauvegarde ?'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Fusionner'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Séance en cours'), findsOneWidget);
|
||
expect(
|
||
find.text(
|
||
'Termine ou sauvegarde ta séance avant d’importer des données.',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('annuler la sélection de fichier reste silencieux', (
|
||
tester,
|
||
) async {
|
||
final harness = _AuthHarness();
|
||
final picker = _FakeBackupFilePicker();
|
||
|
||
await tester.pumpWidget(
|
||
MaterialApp(
|
||
home: ProfileScreen(
|
||
authUseCases: harness.useCases,
|
||
syncUseCases: harness.syncUseCases,
|
||
shareUseCases: harness.shareUseCases,
|
||
dataExportUseCase: harness.dataExportUseCase,
|
||
dataImportUseCase: harness.dataImportUseCase,
|
||
backupFilePicker: picker,
|
||
),
|
||
),
|
||
);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Importer des données'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byType(AlertDialog), findsNothing);
|
||
expect(find.byType(SnackBar), findsNothing);
|
||
});
|
||
}
|
||
|
||
final class _AuthHarness {
|
||
_AuthHarness({UserAccountSession? initialSession, String? initialToken})
|
||
: tokenStore = _FakeAuthTokenStore(initialToken),
|
||
accountRepository = _FakeOnlineAccountRepository(initialSession),
|
||
remote = _FakeRemoteAuthApi(),
|
||
syncRemote = _FakeRemoteSyncApi(),
|
||
syncMetadata = _FakeSyncMetadataRepository(),
|
||
syncMappings = _FakeRemoteResourceMappingRepository(),
|
||
syncChanges = _FakeLocalSyncChangeRepository(),
|
||
shareRemote = _FakeRemoteShareApi(),
|
||
shareInbox = _FakeShareInboxRepository(),
|
||
pendingShares = _FakePendingShareActionRepository(),
|
||
programRepository = _FakeProgramRepository(),
|
||
templateRepository = _FakeWorkoutTemplateRepository(),
|
||
backupRepository = _FakeLocalDataBackupRepository(),
|
||
backupMediaStore = _FakeLocalBackupMediaStore() {
|
||
useCases = AuthUseCases(
|
||
tokenStore: tokenStore,
|
||
accountRepository: accountRepository,
|
||
remoteAuthApi: remote,
|
||
clock: const _FakeClock(),
|
||
ids: _FakeIds(),
|
||
);
|
||
syncUseCases = SyncUseCases(
|
||
tokenStore: tokenStore,
|
||
remoteSyncApi: syncRemote,
|
||
metadataRepository: syncMetadata,
|
||
mappingRepository: syncMappings,
|
||
localChanges: syncChanges,
|
||
clock: const _FakeClock(),
|
||
deviceId: 'device-1',
|
||
);
|
||
shareUseCases = ShareUseCases(
|
||
tokenStore: tokenStore,
|
||
remoteShareApi: shareRemote,
|
||
inboxRepository: shareInbox,
|
||
pendingActionRepository: pendingShares,
|
||
localChanges: syncChanges,
|
||
programRepository: programRepository,
|
||
templateRepository: templateRepository,
|
||
clock: const _FakeClock(),
|
||
ids: _FakeIds(),
|
||
originDeviceId: 'device-1',
|
||
);
|
||
dataExportUseCase = DataExportUseCase(
|
||
repository: backupRepository,
|
||
mediaStore: backupMediaStore,
|
||
clock: const _FakeClock(),
|
||
);
|
||
dataImportUseCase = DataImportUseCase(
|
||
repository: backupRepository,
|
||
mediaStore: backupMediaStore,
|
||
clock: const _FakeClock(),
|
||
);
|
||
}
|
||
|
||
factory _AuthHarness.connected() {
|
||
return _AuthHarness(
|
||
initialSession: UserAccountSession(
|
||
id: 'account-1',
|
||
serverUserId: 'server-user-1',
|
||
email: 'alex@example.com',
|
||
displayName: 'Alex',
|
||
isLoggedIn: true,
|
||
createdAt: DateTime.utc(2026, 7, 17),
|
||
updatedAt: DateTime.utc(2026, 7, 17),
|
||
),
|
||
initialToken: 'token-1',
|
||
);
|
||
}
|
||
|
||
final _FakeAuthTokenStore tokenStore;
|
||
final _FakeOnlineAccountRepository accountRepository;
|
||
final _FakeRemoteAuthApi remote;
|
||
final _FakeRemoteSyncApi syncRemote;
|
||
final _FakeSyncMetadataRepository syncMetadata;
|
||
final _FakeRemoteResourceMappingRepository syncMappings;
|
||
final _FakeLocalSyncChangeRepository syncChanges;
|
||
final _FakeRemoteShareApi shareRemote;
|
||
final _FakeShareInboxRepository shareInbox;
|
||
final _FakePendingShareActionRepository pendingShares;
|
||
final _FakeProgramRepository programRepository;
|
||
final _FakeWorkoutTemplateRepository templateRepository;
|
||
final _FakeLocalDataBackupRepository backupRepository;
|
||
final _FakeLocalBackupMediaStore backupMediaStore;
|
||
late final AuthUseCases useCases;
|
||
late final SyncUseCases syncUseCases;
|
||
late final ShareUseCases shareUseCases;
|
||
late final DataExportUseCase dataExportUseCase;
|
||
late final DataImportUseCase dataImportUseCase;
|
||
}
|
||
|
||
final class _FakeClock implements Clock {
|
||
const _FakeClock();
|
||
|
||
@override
|
||
DateTime now() => DateTime.utc(2026, 7, 17, 12);
|
||
}
|
||
|
||
final class _FakeIds implements IdGenerator {
|
||
var _next = 0;
|
||
|
||
@override
|
||
String newId() {
|
||
_next += 1;
|
||
return 'local-account-$_next';
|
||
}
|
||
}
|
||
|
||
final class _FakeAuthTokenStore implements AuthTokenStore {
|
||
_FakeAuthTokenStore(this.token);
|
||
|
||
String? token;
|
||
|
||
@override
|
||
Future<void> clearToken() async {
|
||
token = null;
|
||
}
|
||
|
||
@override
|
||
Future<String?> readToken() async => token;
|
||
|
||
@override
|
||
Future<void> saveToken(String token, DateTime expiresAt) async {
|
||
this.token = token;
|
||
}
|
||
}
|
||
|
||
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
|
||
_FakeOnlineAccountRepository(this.session);
|
||
|
||
UserAccountSession? session;
|
||
|
||
@override
|
||
Future<void> clearSession() async {
|
||
session = null;
|
||
}
|
||
|
||
@override
|
||
Future<UserAccountSession?> currentSession() async => session;
|
||
|
||
@override
|
||
Future<void> saveSession(UserAccountSession session) async {
|
||
this.session = session;
|
||
}
|
||
}
|
||
|
||
final class _FakeRemoteAuthApi implements RemoteAuthApi {
|
||
RemoteAuthFailure? loginFailure;
|
||
RemoteAuthFailure? registerFailure;
|
||
|
||
@override
|
||
Future<RemoteAuthResult> login({
|
||
required String email,
|
||
required String password,
|
||
}) async {
|
||
final failure = loginFailure;
|
||
if (failure != null) {
|
||
throw RemoteAuthException(failure);
|
||
}
|
||
return RemoteAuthResult(
|
||
userId: 'server-user-1',
|
||
email: email.trim(),
|
||
token: 'token-1',
|
||
expiresAt: DateTime.utc(2026, 7, 18),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<void> logout(String token) async {}
|
||
|
||
@override
|
||
Future<RemoteAuthResult> register({
|
||
required String email,
|
||
required String password,
|
||
String? displayName,
|
||
}) async {
|
||
final failure = registerFailure;
|
||
if (failure != null) {
|
||
throw RemoteAuthException(failure);
|
||
}
|
||
return RemoteAuthResult(
|
||
userId: 'server-user-1',
|
||
email: email.trim(),
|
||
token: 'token-1',
|
||
expiresAt: DateTime.utc(2026, 7, 18),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _FakeRemoteSyncApi implements RemoteSyncApi {
|
||
var pullCalls = 0;
|
||
var pushCalls = 0;
|
||
var throwOnPull = false;
|
||
|
||
@override
|
||
Future<RemoteSyncPushResult> push({
|
||
required String deviceId,
|
||
required List<RemoteSyncPushItem> items,
|
||
required String token,
|
||
}) async {
|
||
pushCalls += 1;
|
||
return const RemoteSyncPushResult(serverCursor: null, results: []);
|
||
}
|
||
|
||
@override
|
||
Future<RemoteSyncPullResult> pull({
|
||
required String? since,
|
||
required String token,
|
||
}) async {
|
||
pullCalls += 1;
|
||
if (throwOnPull) {
|
||
throw Exception('temporary unavailable');
|
||
}
|
||
return const RemoteSyncPullResult(serverCursor: 'cursor-1', items: []);
|
||
}
|
||
}
|
||
|
||
final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
|
||
var metadata = const SyncMetadataSnapshot();
|
||
|
||
@override
|
||
Future<SyncMetadataSnapshot> read() async => metadata;
|
||
|
||
@override
|
||
Future<void> save(SyncMetadataSnapshot metadata) async {
|
||
this.metadata = metadata;
|
||
}
|
||
}
|
||
|
||
final class _FakeRemoteResourceMappingRepository
|
||
implements RemoteResourceMappingRepository {
|
||
final _mappings = <String, RemoteResourceMapping>{};
|
||
|
||
@override
|
||
Future<RemoteResourceMapping?> find({
|
||
required SyncResourceType resourceType,
|
||
required String clientId,
|
||
}) async {
|
||
return _mappings['${resourceType.name}:$clientId'];
|
||
}
|
||
|
||
@override
|
||
Future<void> save(RemoteResourceMapping mapping) async {
|
||
_mappings['${mapping.resourceType.name}:${mapping.clientId}'] = mapping;
|
||
}
|
||
}
|
||
|
||
final class _FakeLocalSyncChangeRepository
|
||
implements LocalSyncChangeRepository {
|
||
@override
|
||
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
|
||
|
||
@override
|
||
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
|
||
|
||
@override
|
||
Future<void> markChangesSynced(
|
||
List<String> changeLogIds,
|
||
DateTime syncedAt,
|
||
) async {}
|
||
}
|
||
|
||
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||
@override
|
||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
||
return RemoteSyncedItem(
|
||
resourceType: SyncResourceType.program,
|
||
clientId: 'program-remote',
|
||
serverId: 'program-server',
|
||
schemaVersion: 1,
|
||
clientUpdatedAt: DateTime.utc(2026, 7, 17),
|
||
serverUpdatedAt: DateTime.utc(2026, 7, 17),
|
||
deletedAt: null,
|
||
payload: const {'name': 'Programme partagé'},
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<void> declineShare(String shareId, String token) async {}
|
||
|
||
@override
|
||
Future<List<ShareInboxItem>> fetchInbox(String token) async => const [];
|
||
|
||
@override
|
||
Future<void> revokeShare(String shareId, String token) async {}
|
||
|
||
@override
|
||
Future<RemoteShareSendResult> sendShare({
|
||
required ShareResourceType resourceType,
|
||
required Map<String, Object?> payload,
|
||
required List<String> recipientEmails,
|
||
required String token,
|
||
}) async {
|
||
return const RemoteShareSendResult(
|
||
shareId: 'share-1',
|
||
recipientUserIds: ['recipient-1'],
|
||
unresolvedEmails: [],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _FakeShareInboxRepository implements ShareInboxRepository {
|
||
final items = <ShareInboxItem>[];
|
||
|
||
@override
|
||
Future<ShareInboxItem?> findByShareId(String shareId) async {
|
||
return items.cast<ShareInboxItem?>().firstWhere(
|
||
(item) => item?.shareId == shareId,
|
||
orElse: () => null,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<List<ShareInboxItem>> listAll() async => List.of(items);
|
||
|
||
@override
|
||
Future<void> markStatus(
|
||
String shareId,
|
||
ShareInboxStatus status,
|
||
DateTime respondedAt,
|
||
) async {
|
||
final index = items.indexWhere((item) => item.shareId == shareId);
|
||
if (index == -1) return;
|
||
items[index] = items[index].copyWith(
|
||
status: status,
|
||
respondedAt: respondedAt,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<void> upsert(ShareInboxItem item) async {
|
||
items.removeWhere((existing) => existing.shareId == item.shareId);
|
||
items.add(item);
|
||
}
|
||
|
||
@override
|
||
Future<void> upsertAll(List<ShareInboxItem> items) async {
|
||
for (final item in items) {
|
||
await upsert(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
final class _FakePendingShareActionRepository
|
||
implements PendingShareActionRepository {
|
||
final actions = <PendingShareAction>[];
|
||
|
||
@override
|
||
Future<void> add(PendingShareAction action) async {
|
||
actions.add(action);
|
||
}
|
||
|
||
@override
|
||
Future<List<PendingShareAction>> listPending() async => actions;
|
||
|
||
@override
|
||
Future<void> markFailed(String id, DateTime attemptedAt) async {}
|
||
|
||
@override
|
||
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
|
||
}
|
||
|
||
final class _FakeProgramRepository implements ProgramRepository {
|
||
@override
|
||
Future<Program?> findById(String id) async => null;
|
||
|
||
@override
|
||
Future<List<Program>> listActive() async => const [];
|
||
|
||
@override
|
||
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
|
||
|
||
@override
|
||
Future<void> save(Program program) async {}
|
||
|
||
@override
|
||
Future<void> saveExercise(ProgramExercise exercise) async {}
|
||
}
|
||
|
||
final class _FakeWorkoutTemplateRepository
|
||
implements WorkoutTemplateRepository {
|
||
@override
|
||
Future<WorkoutTemplate?> findById(String id) async => null;
|
||
|
||
@override
|
||
Future<List<WorkoutTemplate>> listActive() async => const [];
|
||
|
||
@override
|
||
Future<void> replaceComposition(
|
||
WorkoutTemplate template,
|
||
DateTime deletedAt,
|
||
) async {}
|
||
|
||
@override
|
||
Future<void> save(WorkoutTemplate template) async {}
|
||
|
||
@override
|
||
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
|
||
|
||
@override
|
||
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
|
||
}
|
||
|
||
final class _FakeLocalDataBackupRepository
|
||
implements LocalDataBackupRepository {
|
||
var hasOpenSession = false;
|
||
var hasData = false;
|
||
var throwOnApply = false;
|
||
final appliedModes = <LocalBackupImportMode>[];
|
||
|
||
@override
|
||
Future<LocalBackupImportResult> applyImportSnapshot({
|
||
required LocalDataExportSnapshot snapshot,
|
||
required LocalBackupImportMode mode,
|
||
required DateTime importedAt,
|
||
}) async {
|
||
if (throwOnApply) {
|
||
throw Exception('transaction failed');
|
||
}
|
||
appliedModes.add(mode);
|
||
return const LocalBackupImportResult(
|
||
insertedCount: 1,
|
||
updatedCount: 0,
|
||
ignoredOlderCount: 0,
|
||
deletedByReplaceCount: 0,
|
||
missingMediaCount: 0,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<bool> hasAnyUserData() async => hasData;
|
||
|
||
@override
|
||
Future<bool> hasOpenActiveWorkoutSession() async => hasOpenSession;
|
||
|
||
@override
|
||
Future<LocalDataExportSnapshot> readExportSnapshot(
|
||
DateTime exportedAt,
|
||
) async {
|
||
return LocalDataExportSnapshot(
|
||
exportedAt: exportedAt,
|
||
appSchemaVersion: 19,
|
||
originDeviceId: 'device-1',
|
||
mediaAssets: const [],
|
||
exercises: const [],
|
||
programs: const [],
|
||
workoutTemplates: const [],
|
||
workoutHistories: const [],
|
||
mediaFiles: const [],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _FakeLocalBackupMediaStore implements LocalBackupMediaStore {
|
||
@override
|
||
Future<List<EmbeddedBackupMediaFile>> readEmbeddableFiles(
|
||
List<LocalBackupResource> mediaAssets,
|
||
) async => const [];
|
||
|
||
@override
|
||
Future<Map<String, RestoredMediaFile>> restoreEmbeddedFiles(
|
||
List<EmbeddedBackupMediaFile> files,
|
||
) async => const {};
|
||
}
|
||
|
||
final class _FakeBackupFileExporter implements LocalBackupFileExporter {
|
||
_FakeBackupFileExporter({this.status = ShareResultStatus.success});
|
||
|
||
ShareResultStatus status;
|
||
String? exportedFileName;
|
||
Uint8List? exportedBytes;
|
||
var throwOnExport = false;
|
||
|
||
@override
|
||
Future<ShareResultStatus> exportFile({
|
||
required String fileName,
|
||
required Uint8List bytes,
|
||
}) async {
|
||
if (throwOnExport) {
|
||
throw Exception('share failed');
|
||
}
|
||
exportedFileName = fileName;
|
||
exportedBytes = bytes;
|
||
return status;
|
||
}
|
||
}
|
||
|
||
final class _FakeBackupFilePicker implements LocalBackupFilePicker {
|
||
_FakeBackupFilePicker({this.file});
|
||
|
||
LocalBackupPickedFile? file;
|
||
|
||
@override
|
||
Future<LocalBackupPickedFile?> pickFile() async => file;
|
||
}
|