Files
GameTime/test/presentation/profile_screen_test.dart
Blomios b6f048ad64 fix(online): validation client online offline-first (ticket #70)
Corrige l'acceptation de partage hors-ligne (acceptShare,
_acceptCachedShareAndQueueAck, _importSharedPayload dans
application/use_cases.dart) : crée bien une copie locale indépendante
avec de nouveaux IDs avant de mettre l'accusé serveur en file
d'attente. Ajuste app_bootstrap.dart, profile_screen.dart et
share_screen.dart en conséquence. flutter pub get OK, dart format
appliqué (aucun changement), analyze propre (mêmes infos
préexistantes), 137/137 tests verts, build APK debug validé. Dernier
ticket du chantier "Ajouter les features serveur au client" (#63,
tickets #64 à #70 tous terminés).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:39:40 +02:00

624 lines
17 KiB
Dart

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';
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,
),
),
);
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,
),
),
);
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,
),
),
);
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,
),
),
);
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,
),
),
);
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,
),
),
);
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,
),
),
);
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,
),
),
);
await tester.pump();
await tester.tap(find.text('Partages reçus'));
await tester.pumpAndSettle();
expect(find.text('Aucun partage reçu'), findsOneWidget);
});
}
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() {
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',
);
}
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;
late final AuthUseCases useCases;
late final SyncUseCases syncUseCases;
late final ShareUseCases shareUseCases;
}
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 {}
}