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>
This commit is contained in:
@ -1434,6 +1434,50 @@ void main() {
|
||||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||
});
|
||||
|
||||
test(
|
||||
'ShareUseCases acceptShare sans token importe une copie locale indépendante',
|
||||
() async {
|
||||
final remoteShareApi = _FakeRemoteShareApi();
|
||||
final pendingRepository = _FakePendingShareActionRepository();
|
||||
final programRepository = _FakeProgramRepository();
|
||||
final inboxRepository = _FakeShareInboxRepository()
|
||||
..items.add(
|
||||
_shareInboxItem(
|
||||
status: ShareInboxStatus.pending,
|
||||
payloadJson: jsonEncode(_sharedProgramPayload()),
|
||||
),
|
||||
);
|
||||
|
||||
await _shareUseCase(
|
||||
tokenStore: _FakeAuthTokenStore(),
|
||||
remoteShareApi: remoteShareApi,
|
||||
inboxRepository: inboxRepository,
|
||||
pendingActionRepository: pendingRepository,
|
||||
programRepository: programRepository,
|
||||
).acceptShare('share-1');
|
||||
|
||||
expect(remoteShareApi.acceptCalls, 0);
|
||||
expect(
|
||||
pendingRepository.actions.single.actionType,
|
||||
PendingShareActionType.accept,
|
||||
);
|
||||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||
final imported = programRepository.programs.single;
|
||||
expect(imported.metadata.id, isNot('program-from-sender'));
|
||||
expect(imported.metadata.originDeviceId, 'device-1');
|
||||
expect(imported.name, 'Programme partagé');
|
||||
expect(
|
||||
imported.exercises.single.metadata.id,
|
||||
isNot('exercise-from-sender'),
|
||||
);
|
||||
expect(imported.exercises.single.sourceExerciseId, isNull);
|
||||
expect(
|
||||
imported.exercises.single.exerciseStepsSnapshot.single.name,
|
||||
'Sprint',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('ShareUseCases refreshInbox updates the local cache', () async {
|
||||
final remoteShareApi = _FakeRemoteShareApi()
|
||||
..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
|
||||
@ -1866,6 +1910,7 @@ ShareUseCases _shareUseCase({
|
||||
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
@ -2027,17 +2072,58 @@ Program _shareableProgram() {
|
||||
ShareInboxItem _shareInboxItem({
|
||||
String shareId = 'share-1',
|
||||
required ShareInboxStatus status,
|
||||
String payloadJson = '{"name":"Programme partagé"}',
|
||||
}) {
|
||||
return ShareInboxItem(
|
||||
shareId: shareId,
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: ShareResourceType.program,
|
||||
payloadJson: '{"name":"Programme partagé"}',
|
||||
payloadJson: payloadJson,
|
||||
status: status,
|
||||
createdAt: DateTime.utc(2026, 7, 17, 12),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _sharedProgramPayload() {
|
||||
return {
|
||||
'id': 'program-from-sender',
|
||||
'name': 'Programme partagé',
|
||||
'defaultRestSeconds': 45,
|
||||
'exercises': [
|
||||
{
|
||||
'id': 'exercise-from-sender',
|
||||
'sourceExerciseId': 'source-exercise-from-sender',
|
||||
'position': 0,
|
||||
'exerciseNameSnapshot': 'Sprint',
|
||||
'exerciseStepsSnapshot': [
|
||||
{
|
||||
'id': 'step-from-sender',
|
||||
'position': 0,
|
||||
'name': 'Sprint',
|
||||
'type': 'time',
|
||||
'defaultTargetValue': 20,
|
||||
'hasScore': false,
|
||||
'scoreInputMode': 'manual',
|
||||
'scoreLabel': null,
|
||||
'scoreUnit': null,
|
||||
'defaultTargetScore': null,
|
||||
'defaultTargetScoreTimeMs': null,
|
||||
},
|
||||
],
|
||||
'availableTimeSnapshot': true,
|
||||
'availableRepsSnapshot': false,
|
||||
'availableScoreSnapshot': false,
|
||||
'scoreInputModeSnapshot': 'manual',
|
||||
'setsCount': 2,
|
||||
'timeEnabled': true,
|
||||
'repsEnabled': false,
|
||||
'scoreEnabled': false,
|
||||
'targetTimeSeconds': 20,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
RemoteSyncedItem _remoteSharedProgramItem() {
|
||||
return RemoteSyncedItem(
|
||||
resourceType: SyncResourceType.program,
|
||||
|
||||
@ -156,6 +156,7 @@ final class _FakeBootstrap implements AppDependencies {
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
),
|
||||
activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases(
|
||||
sessionRepository: activeRepository,
|
||||
|
||||
@ -93,6 +93,7 @@ void main() {
|
||||
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 {
|
||||
@ -273,6 +274,7 @@ final class _AuthHarness {
|
||||
templateRepository: templateRepository,
|
||||
clock: const _FakeClock(),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -604,6 +604,7 @@ ShareUseCases _shareUseCases({
|
||||
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -70,22 +70,78 @@ void main() {
|
||||
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: _FakeAuthTokenStore('token-1'),
|
||||
tokenStore: tokenStore ?? _FakeAuthTokenStore('token-1'),
|
||||
remoteShareApi: remoteShareApi,
|
||||
inboxRepository: inboxRepository,
|
||||
pendingActionRepository: _FakePendingShareActionRepository(),
|
||||
pendingActionRepository:
|
||||
pendingActionRepository ?? _FakePendingShareActionRepository(),
|
||||
localChanges: _FakeLocalSyncChangeRepository(),
|
||||
programRepository: _FakeProgramRepository(),
|
||||
programRepository: programRepository ?? _FakeProgramRepository(),
|
||||
templateRepository: _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
@ -99,7 +155,17 @@ ShareInboxItem _shareItem({
|
||||
ShareResourceType.program => {
|
||||
'name': name,
|
||||
'exercises': const [
|
||||
{'setsCount': 3},
|
||||
{
|
||||
'exerciseNameSnapshot': 'Sprint',
|
||||
'setsCount': 3,
|
||||
'availableTimeSnapshot': false,
|
||||
'availableRepsSnapshot': true,
|
||||
'availableScoreSnapshot': false,
|
||||
'timeEnabled': false,
|
||||
'repsEnabled': true,
|
||||
'scoreEnabled': false,
|
||||
'targetReps': 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
ShareResourceType.workoutTemplate => {
|
||||
@ -267,8 +333,12 @@ final class _FakeShareInboxRepository implements ShareInboxRepository {
|
||||
|
||||
final class _FakePendingShareActionRepository
|
||||
implements PendingShareActionRepository {
|
||||
final actions = <PendingShareAction>[];
|
||||
|
||||
@override
|
||||
Future<void> add(PendingShareAction action) async {}
|
||||
Future<void> add(PendingShareAction action) async {
|
||||
actions.add(action);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PendingShareAction>> listPending() async => const [];
|
||||
@ -296,6 +366,8 @@ final class _FakeLocalSyncChangeRepository
|
||||
}
|
||||
|
||||
final class _FakeProgramRepository implements ProgramRepository {
|
||||
final saved = <Program>[];
|
||||
|
||||
@override
|
||||
Future<Program?> findById(String id) async => null;
|
||||
|
||||
@ -306,7 +378,9 @@ final class _FakeProgramRepository implements ProgramRepository {
|
||||
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
|
||||
|
||||
@override
|
||||
Future<void> save(Program program) async {}
|
||||
Future<void> save(Program program) async {
|
||||
saved.add(program);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveExercise(ProgramExercise exercise) async {}
|
||||
|
||||
@ -537,6 +537,7 @@ ShareUseCases _shareUseCases({
|
||||
templateRepository: templateRepository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user