Files
GameTime/test/presentation/share_inbox_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

412 lines
11 KiB
Dart

import 'dart:convert';
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/share_inbox_screen.dart';
void main() {
testWidgets('la boîte de réception affiche les partages et les actions', (
tester,
) async {
final remoteShareApi = _FakeRemoteShareApi()
..inboxItems = [
_shareItem(
shareId: 'share-program',
resourceType: ShareResourceType.program,
name: 'Programme tirs',
status: ShareInboxStatus.pending,
),
_shareItem(
shareId: 'share-template',
resourceType: ShareResourceType.workoutTemplate,
name: 'Séance match',
status: ShareInboxStatus.pending,
),
];
final inboxRepository = _FakeShareInboxRepository();
await tester.pumpWidget(
MaterialApp(
home: ShareInboxScreen(
shareUseCases: _shareUseCases(
remoteShareApi: remoteShareApi,
inboxRepository: inboxRepository,
),
),
),
);
await tester.pump();
await tester.pump();
expect(find.text('Programme tirs'), findsOneWidget);
expect(find.text('Séance match'), findsOneWidget);
expect(find.text('Accepter'), findsNWidgets(2));
expect(find.text('Refuser'), findsNWidgets(2));
await tester.tap(find.widgetWithText(FilledButton, 'Accepter').first);
await tester.pump();
await tester.pump();
expect(remoteShareApi.acceptCalls, 1);
expect(
inboxRepository.items
.firstWhere((item) => item.shareId == 'share-program')
.status,
ShareInboxStatus.accepted,
);
expect(find.text('Programme ajouté.'), findsOneWidget);
await tester.tap(find.widgetWithText(OutlinedButton, 'Refuser').first);
await tester.pump();
await tester.pump();
expect(remoteShareApi.declineCalls, 1);
expect(
inboxRepository.items
.firstWhere((item) => item.shareId == 'share-template')
.status,
ShareInboxStatus.declined,
);
});
testWidgets(
'accepter un partage en cache hors connexion importe une copie locale',
(tester) async {
final remoteShareApi = _FakeRemoteShareApi();
final inboxRepository = _FakeShareInboxRepository()
..items.add(
_shareItem(
shareId: 'share-program',
resourceType: ShareResourceType.program,
name: 'Programme tirs',
status: ShareInboxStatus.pending,
),
);
final programRepository = _FakeProgramRepository();
final pendingRepository = _FakePendingShareActionRepository();
await tester.pumpWidget(
MaterialApp(
home: ShareInboxScreen(
shareUseCases: _shareUseCases(
tokenStore: _FakeAuthTokenStore(null),
remoteShareApi: remoteShareApi,
inboxRepository: inboxRepository,
pendingActionRepository: pendingRepository,
programRepository: programRepository,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Accepter'));
await tester.pump();
await tester.pump();
expect(remoteShareApi.acceptCalls, 0);
expect(programRepository.saved.single.name, 'Programme tirs');
expect(
programRepository.saved.single.metadata.id,
isNot('share-program'),
);
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
expect(
pendingRepository.actions.single.actionType,
PendingShareActionType.accept,
);
expect(find.text('Programme ajouté.'), findsOneWidget);
},
);
}
ShareUseCases _shareUseCases({
_FakeAuthTokenStore? tokenStore,
required _FakeRemoteShareApi remoteShareApi,
required _FakeShareInboxRepository inboxRepository,
_FakePendingShareActionRepository? pendingActionRepository,
_FakeProgramRepository? programRepository,
}) {
return ShareUseCases(
tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'),
remoteShareApi: remoteShareApi,
inboxRepository: inboxRepository,
pendingActionRepository:
pendingActionRepository ?? _FakePendingShareActionRepository(),
localChanges: _FakeLocalSyncChangeRepository(),
programRepository: programRepository ?? _FakeProgramRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
ShareInboxItem _shareItem({
required String shareId,
required ShareResourceType resourceType,
required String name,
required ShareInboxStatus status,
}) {
final payload = switch (resourceType) {
ShareResourceType.program => {
'name': name,
'exercises': const [
{
'exerciseNameSnapshot': 'Sprint',
'setsCount': 3,
'availableTimeSnapshot': false,
'availableRepsSnapshot': true,
'availableScoreSnapshot': false,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': 10,
},
],
},
ShareResourceType.workoutTemplate => {
'name': name,
'programs': [
{
'programSnapshotJson': jsonEncode({
'exercises': const [
{'id': 'exercise-1'},
],
}),
},
],
},
};
return ShareInboxItem(
shareId: shareId,
senderUserId: 'Alex',
resourceType: resourceType,
payloadJson: jsonEncode(payload),
status: status,
createdAt: DateTime.utc(2026, 7, 17),
);
}
final class _FakeClock implements Clock {
const _FakeClock(this.value);
final DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_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 _FakeRemoteShareApi implements RemoteShareApi {
List<ShareInboxItem> inboxItems = const [];
var acceptCalls = 0;
var declineCalls = 0;
@override
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
acceptCalls += 1;
_mark(shareId, ShareInboxStatus.accepted);
return RemoteSyncedItem(
resourceType: SyncResourceType.program,
clientId: 'shared-program-1',
serverId: 'server-program-1',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17),
serverUpdatedAt: DateTime.utc(2026, 7, 17),
deletedAt: null,
payload: const {'name': 'Programme tirs'},
);
}
@override
Future<void> declineShare(String shareId, String token) async {
declineCalls += 1;
_mark(shareId, ShareInboxStatus.declined);
}
@override
Future<List<ShareInboxItem>> fetchInbox(String token) async => inboxItems;
@override
Future<void> revokeShare(String shareId, String token) async {
_mark(shareId, ShareInboxStatus.revoked);
}
@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: [],
);
}
void _mark(String shareId, ShareInboxStatus status) {
inboxItems = [
for (final item in inboxItems)
if (item.shareId == shareId)
item.copyWith(status: status, respondedAt: DateTime.utc(2026, 7, 17))
else
item,
];
}
}
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 => const [];
@override
Future<void> markFailed(String id, DateTime attemptedAt) async {}
@override
Future<void> markSucceeded(String id, DateTime attemptedAt) async {}
}
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 _FakeProgramRepository implements ProgramRepository {
final saved = <Program>[];
@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 {
saved.add(program);
}
@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 {}
}