feat(online): sync client incrémentale LWW et profil/écrans auth (tickets #65, #67)

Ajoute la synchronisation client incrémentale LWW vers l'API serveur
(application/use_cases.dart: SyncUseCases, ports.dart, migration
Drift schemaVersion 10→11 pour les métadonnées de sync et mappings de
ressources, infrastructure/remote/sync_api.dart) et l'écran de profil
avec entrée sur l'accueil (presentation/profile_screen.dart,
home_screen.dart, presentation.dart). Développés dans le même
worktree partagé par DevBackend et DevFrontend ; commit combiné car
test/presentation/home_screen_test.dart mélange authentiquement les
deux tickets (le test de l'entrée Profil et la mise à jour du fake de
bootstrap requise par le nouveau getter syncUseCases sur
AppDependencies). Corrige au passage une couleur `crimson` inexistante
dans le thème (remplacée par colorScheme.error) et une signature de
paramètres positionnels/nommés incohérente sur un fake de test.
flutter pub get OK, build_runner OK, dart format appliqué, analyze
propre (mêmes infos préexistantes), 119/119 tests verts, build APK
debug validé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:41:18 +02:00
parent b643988986
commit c0dbce275b
16 changed files with 3913 additions and 3 deletions

View File

@ -1251,6 +1251,116 @@ void main() {
expect(remoteAuthApi.logoutCalls, 0);
});
test('SyncUseCases skips silently without token', () async {
final remoteSyncApi = _FakeRemoteSyncApi();
final metadataRepository = _FakeSyncMetadataRepository();
final localChanges = _FakeLocalSyncChangeRepository();
final summary = await _syncUseCase(
tokenStore: _FakeAuthTokenStore(),
remoteSyncApi: remoteSyncApi,
metadataRepository: metadataRepository,
localChanges: localChanges,
).synchronize(manual: true);
expect(summary.skipped, isTrue);
expect(remoteSyncApi.pushCalls, 0);
expect(remoteSyncApi.pullCalls, 0);
expect(metadataRepository.saved, isEmpty);
});
test('SyncUseCases successful push marks change log rows synced', () async {
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
final localChanges = _FakeLocalSyncChangeRepository()
..pendingChanges.add(_pendingExerciseChange());
final remoteSyncApi = _FakeRemoteSyncApi()
..pushResult = RemoteSyncPushResult(
serverCursor: 'cursor-1',
results: [
RemoteSyncPushItemResult(
resourceType: SyncResourceType.exercise,
clientId: 'exercise-1',
serverId: 'server-exercise-1',
status: RemoteSyncPushStatus.accepted,
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
),
],
);
final mappingRepository = _FakeRemoteResourceMappingRepository();
final summary = await _syncUseCase(
tokenStore: tokenStore,
remoteSyncApi: remoteSyncApi,
mappingRepository: mappingRepository,
localChanges: localChanges,
).synchronize(manual: true);
expect(summary.pushedChanges, 1);
expect(localChanges.syncedChangeLogIds, ['change-1']);
expect(mappingRepository.saved.single.serverId, 'server-exercise-1');
});
test('SyncUseCases pull applies a newer remote item', () async {
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
final localChanges = _FakeLocalSyncChangeRepository()
..localUpdatedAt = DateTime.utc(2026, 7, 17, 11);
final remoteSyncApi = _FakeRemoteSyncApi()
..pullResult = RemoteSyncPullResult(
serverCursor: 'cursor-2',
items: [
_remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 12)),
],
);
final summary = await _syncUseCase(
tokenStore: tokenStore,
remoteSyncApi: remoteSyncApi,
localChanges: localChanges,
).synchronize(manual: true);
expect(summary.pulledChanges, 1);
expect(localChanges.appliedItems.single.clientId, 'exercise-1');
});
test('SyncUseCases pull ignores an older remote item', () async {
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
final localChanges = _FakeLocalSyncChangeRepository()
..localUpdatedAt = DateTime.utc(2026, 7, 17, 12);
final remoteSyncApi = _FakeRemoteSyncApi()
..pullResult = RemoteSyncPullResult(
serverCursor: 'cursor-2',
items: [
_remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 11)),
],
);
final summary = await _syncUseCase(
tokenStore: tokenStore,
remoteSyncApi: remoteSyncApi,
localChanges: localChanges,
).synchronize(manual: true);
expect(summary.pulledChanges, 0);
expect(localChanges.appliedItems, isEmpty);
});
test('SyncUseCases stores failure status without throwing', () async {
final tokenStore = _FakeAuthTokenStore()..token = 'token-1';
final metadataRepository = _FakeSyncMetadataRepository();
final remoteSyncApi = _FakeRemoteSyncApi()
..exception = const RemoteAuthException(RemoteAuthFailure.network);
final summary = await _syncUseCase(
tokenStore: tokenStore,
remoteSyncApi: remoteSyncApi,
metadataRepository: metadataRepository,
).synchronize(manual: true);
expect(summary.failed, isTrue);
expect(metadataRepository.metadata.status, OnlineSyncStatus.failure);
expect(metadataRepository.metadata.lastFailureAt, isNotNull);
});
test('score result enforces manual xor stopwatch values', () {
expect(
() => ActiveSetResult(
@ -1461,6 +1571,169 @@ final class _FakeRemoteAuthApi implements RemoteAuthApi {
}
}
SyncUseCases _syncUseCase({
_FakeAuthTokenStore? tokenStore,
_FakeRemoteSyncApi? remoteSyncApi,
_FakeSyncMetadataRepository? metadataRepository,
_FakeRemoteResourceMappingRepository? mappingRepository,
_FakeLocalSyncChangeRepository? localChanges,
_FakeClock? clock,
}) {
return SyncUseCases(
tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
remoteSyncApi: remoteSyncApi ?? _FakeRemoteSyncApi(),
metadataRepository: metadataRepository ?? _FakeSyncMetadataRepository(),
mappingRepository:
mappingRepository ?? _FakeRemoteResourceMappingRepository(),
localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)),
deviceId: 'device-1',
);
}
final class _FakeRemoteSyncApi implements RemoteSyncApi {
RemoteSyncPushResult pushResult = const RemoteSyncPushResult(
serverCursor: null,
results: [],
);
RemoteSyncPullResult pullResult = const RemoteSyncPullResult(
serverCursor: null,
items: [],
);
Exception? exception;
var pushCalls = 0;
var pullCalls = 0;
@override
Future<RemoteSyncPushResult> push({
required String deviceId,
required List<RemoteSyncPushItem> items,
required String token,
}) async {
pushCalls += 1;
final error = exception;
if (error != null) {
throw error;
}
return pushResult;
}
@override
Future<RemoteSyncPullResult> pull({
required String? since,
required String token,
}) async {
pullCalls += 1;
final error = exception;
if (error != null) {
throw error;
}
return pullResult;
}
}
final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
SyncMetadataSnapshot metadata = const SyncMetadataSnapshot();
final saved = <SyncMetadataSnapshot>[];
@override
Future<SyncMetadataSnapshot> read() async => metadata;
@override
Future<void> save(SyncMetadataSnapshot metadata) async {
this.metadata = metadata;
saved.add(metadata);
}
}
final class _FakeRemoteResourceMappingRepository
implements RemoteResourceMappingRepository {
final saved = <RemoteResourceMapping>[];
@override
Future<RemoteResourceMapping?> find({
required SyncResourceType resourceType,
required String clientId,
}) async {
return saved
.where(
(mapping) =>
mapping.resourceType == resourceType &&
mapping.clientId == clientId,
)
.firstOrNull;
}
@override
Future<void> save(RemoteResourceMapping mapping) async {
saved.removeWhere(
(existing) =>
existing.resourceType == mapping.resourceType &&
existing.clientId == mapping.clientId,
);
saved.add(mapping);
}
}
final class _FakeLocalSyncChangeRepository
implements LocalSyncChangeRepository {
final pendingChanges = <PendingSyncChange>[];
final syncedChangeLogIds = <String>[];
final appliedItems = <RemoteSyncedItem>[];
DateTime? localUpdatedAt;
@override
Future<List<PendingSyncChange>> listPendingChanges() async {
return pendingChanges;
}
@override
Future<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {
syncedChangeLogIds.addAll(changeLogIds);
}
@override
Future<bool> applyRemoteItem(RemoteSyncedItem item) async {
final updatedAt = localUpdatedAt;
if (updatedAt != null && !item.clientUpdatedAt.isAfter(updatedAt)) {
return false;
}
appliedItems.add(item);
localUpdatedAt = item.clientUpdatedAt;
return true;
}
}
PendingSyncChange _pendingExerciseChange() {
return PendingSyncChange(
changeLogIds: const ['change-1'],
item: RemoteSyncPushItem(
resourceType: SyncResourceType.exercise,
clientId: 'exercise-1',
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12),
deletedAt: null,
payload: const {'id': 'exercise-1', 'name': 'Squat'},
),
);
}
RemoteSyncedItem _remoteExerciseItem({required DateTime clientUpdatedAt}) {
return RemoteSyncedItem(
resourceType: SyncResourceType.exercise,
clientId: 'exercise-1',
serverId: 'server-exercise-1',
schemaVersion: 1,
clientUpdatedAt: clientUpdatedAt,
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
deletedAt: null,
payload: const {'id': 'exercise-1', 'name': 'Squat'},
);
}
ExerciseUseCases _exerciseUseCase(
_FakeExerciseRepository repository, {
_FakeProgramRepository? programRepository,