feat(online): partage client - use cases, inbox cache et import local (ticket #66)
Ajoute l'adapter API distant de partage (infrastructure/remote/share_api.dart) et les use cases associés (application/use_cases.dart), avec cache d'inbox et import local des ressources partagées acceptées. Étend le modèle Drift (migration schemaVersion 11→12) et les entités du domaine en conséquence. flutter pub get OK, build_runner OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 124/124 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1361,6 +1361,118 @@ void main() {
|
||||
expect(metadataRepository.metadata.lastFailureAt, isNotNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'ShareUseCases sendShare returns neutral result without token',
|
||||
() async {
|
||||
final remoteShareApi = _FakeRemoteShareApi();
|
||||
final pendingRepository = _FakePendingShareActionRepository();
|
||||
final programRepository = _FakeProgramRepository()
|
||||
..programs.add(_shareableProgram());
|
||||
|
||||
final result =
|
||||
await _shareUseCase(
|
||||
tokenStore: _FakeAuthTokenStore(),
|
||||
remoteShareApi: remoteShareApi,
|
||||
pendingActionRepository: pendingRepository,
|
||||
programRepository: programRepository,
|
||||
).sendShare(
|
||||
resourceType: ShareResourceType.program,
|
||||
localResourceId: 'program-1',
|
||||
recipientEmails: const ['friend@example.com'],
|
||||
);
|
||||
|
||||
expect(result.status, ShareSendStatus.notConnected);
|
||||
expect(remoteShareApi.sendCalls, 0);
|
||||
expect(pendingRepository.actions, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('ShareUseCases sendShare queues action when network fails', () async {
|
||||
final remoteShareApi = _FakeRemoteShareApi()
|
||||
..exception = const RemoteAuthException(RemoteAuthFailure.network);
|
||||
final pendingRepository = _FakePendingShareActionRepository();
|
||||
final programRepository = _FakeProgramRepository()
|
||||
..programs.add(_shareableProgram());
|
||||
|
||||
final result =
|
||||
await _shareUseCase(
|
||||
remoteShareApi: remoteShareApi,
|
||||
pendingActionRepository: pendingRepository,
|
||||
programRepository: programRepository,
|
||||
).sendShare(
|
||||
resourceType: ShareResourceType.program,
|
||||
localResourceId: 'program-1',
|
||||
recipientEmails: const ['friend@example.com'],
|
||||
);
|
||||
|
||||
expect(result.status, ShareSendStatus.queued);
|
||||
expect(
|
||||
pendingRepository.actions.single.actionType,
|
||||
PendingShareActionType.send,
|
||||
);
|
||||
expect(
|
||||
pendingRepository.actions.single.resourceType,
|
||||
ShareResourceType.program,
|
||||
);
|
||||
expect(pendingRepository.actions.single.payloadJson, contains('Programme'));
|
||||
});
|
||||
|
||||
test('ShareUseCases acceptShare imports returned resource locally', () async {
|
||||
final remoteShareApi = _FakeRemoteShareApi()
|
||||
..acceptResult = _remoteSharedProgramItem();
|
||||
final localChanges = _FakeLocalSyncChangeRepository();
|
||||
final inboxRepository = _FakeShareInboxRepository()
|
||||
..items.add(_shareInboxItem(status: ShareInboxStatus.pending));
|
||||
|
||||
await _shareUseCase(
|
||||
remoteShareApi: remoteShareApi,
|
||||
localChanges: localChanges,
|
||||
inboxRepository: inboxRepository,
|
||||
).acceptShare('share-1');
|
||||
|
||||
expect(localChanges.appliedItems.single.clientId, 'shared-program-1');
|
||||
expect(inboxRepository.items.single.status, ShareInboxStatus.accepted);
|
||||
});
|
||||
|
||||
test('ShareUseCases refreshInbox updates the local cache', () async {
|
||||
final remoteShareApi = _FakeRemoteShareApi()
|
||||
..inboxItems = [_shareInboxItem(status: ShareInboxStatus.pending)];
|
||||
final inboxRepository = _FakeShareInboxRepository();
|
||||
|
||||
final items = await _shareUseCase(
|
||||
remoteShareApi: remoteShareApi,
|
||||
inboxRepository: inboxRepository,
|
||||
).refreshInbox();
|
||||
|
||||
expect(items.single.shareId, 'share-1');
|
||||
expect(inboxRepository.items.single.senderUserId, 'sender-1');
|
||||
});
|
||||
|
||||
test('ShareUseCases decline and revoke update local statuses', () async {
|
||||
final inboxRepository = _FakeShareInboxRepository()
|
||||
..items.add(_shareInboxItem(status: ShareInboxStatus.pending))
|
||||
..items.add(
|
||||
_shareInboxItem(shareId: 'share-2', status: ShareInboxStatus.pending),
|
||||
);
|
||||
final useCase = _shareUseCase(inboxRepository: inboxRepository);
|
||||
|
||||
await useCase.declineShare('share-1');
|
||||
await useCase.revokeShare('share-2');
|
||||
|
||||
expect(
|
||||
inboxRepository.items
|
||||
.singleWhere((item) => item.shareId == 'share-1')
|
||||
.status,
|
||||
ShareInboxStatus.declined,
|
||||
);
|
||||
expect(
|
||||
inboxRepository.items
|
||||
.singleWhere((item) => item.shareId == 'share-2')
|
||||
.status,
|
||||
ShareInboxStatus.revoked,
|
||||
);
|
||||
});
|
||||
|
||||
test('score result enforces manual xor stopwatch values', () {
|
||||
expect(
|
||||
() => ActiveSetResult(
|
||||
@ -1734,6 +1846,211 @@ RemoteSyncedItem _remoteExerciseItem({required DateTime clientUpdatedAt}) {
|
||||
);
|
||||
}
|
||||
|
||||
ShareUseCases _shareUseCase({
|
||||
_FakeAuthTokenStore? tokenStore,
|
||||
_FakeRemoteShareApi? remoteShareApi,
|
||||
_FakeShareInboxRepository? inboxRepository,
|
||||
_FakePendingShareActionRepository? pendingActionRepository,
|
||||
_FakeLocalSyncChangeRepository? localChanges,
|
||||
_FakeProgramRepository? programRepository,
|
||||
_FakeWorkoutTemplateRepository? templateRepository,
|
||||
}) {
|
||||
return ShareUseCases(
|
||||
tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'),
|
||||
remoteShareApi: remoteShareApi ?? _FakeRemoteShareApi(),
|
||||
inboxRepository: inboxRepository ?? _FakeShareInboxRepository(),
|
||||
pendingActionRepository:
|
||||
pendingActionRepository ?? _FakePendingShareActionRepository(),
|
||||
localChanges: localChanges ?? _FakeLocalSyncChangeRepository(),
|
||||
programRepository: programRepository ?? _FakeProgramRepository(),
|
||||
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
|
||||
ids: _FakeIds(),
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeRemoteShareApi implements RemoteShareApi {
|
||||
Exception? exception;
|
||||
RemoteSyncedItem? acceptResult;
|
||||
List<ShareInboxItem> inboxItems = const [];
|
||||
var sendCalls = 0;
|
||||
var acceptCalls = 0;
|
||||
var declineCalls = 0;
|
||||
var revokeCalls = 0;
|
||||
|
||||
@override
|
||||
Future<RemoteSyncedItem> acceptShare(String shareId, String token) async {
|
||||
acceptCalls += 1;
|
||||
final error = exception;
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
return acceptResult ?? _remoteSharedProgramItem();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> declineShare(String shareId, String token) async {
|
||||
declineCalls += 1;
|
||||
final error = exception;
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ShareInboxItem>> fetchInbox(String token) async {
|
||||
final error = exception;
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
return inboxItems;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> revokeShare(String shareId, String token) async {
|
||||
revokeCalls += 1;
|
||||
final error = exception;
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteShareSendResult> sendShare({
|
||||
required ShareResourceType resourceType,
|
||||
required Map<String, Object?> payload,
|
||||
required List<String> recipientEmails,
|
||||
required String token,
|
||||
}) async {
|
||||
sendCalls += 1;
|
||||
final error = exception;
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
return const RemoteShareSendResult(
|
||||
shareId: 'share-1',
|
||||
recipientUserIds: ['server-user-2'],
|
||||
unresolvedEmails: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeShareInboxRepository implements ShareInboxRepository {
|
||||
final items = <ShareInboxItem>[];
|
||||
|
||||
@override
|
||||
Future<ShareInboxItem?> findByShareId(String shareId) async {
|
||||
return items.where((item) => item.shareId == shareId).firstOrNull;
|
||||
}
|
||||
|
||||
@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 {
|
||||
return actions
|
||||
.where((action) => action.status != PendingShareActionStatus.succeeded)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markFailed(String id, DateTime attemptedAt) async {
|
||||
_mark(id, attemptedAt, PendingShareActionStatus.failed);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markSucceeded(String id, DateTime attemptedAt) async {
|
||||
_mark(id, attemptedAt, PendingShareActionStatus.succeeded);
|
||||
}
|
||||
|
||||
void _mark(String id, DateTime attemptedAt, PendingShareActionStatus status) {
|
||||
final index = actions.indexWhere((action) => action.id == id);
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
final action = actions[index];
|
||||
actions[index] = action.copyWith(
|
||||
lastAttemptAt: attemptedAt,
|
||||
attemptCount: action.attemptCount + 1,
|
||||
status: status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Program _shareableProgram() {
|
||||
return Program(
|
||||
metadata: _metadata('program-1'),
|
||||
name: 'Programme',
|
||||
defaultRestSeconds: 60,
|
||||
);
|
||||
}
|
||||
|
||||
ShareInboxItem _shareInboxItem({
|
||||
String shareId = 'share-1',
|
||||
required ShareInboxStatus status,
|
||||
}) {
|
||||
return ShareInboxItem(
|
||||
shareId: shareId,
|
||||
senderUserId: 'sender-1',
|
||||
resourceType: ShareResourceType.program,
|
||||
payloadJson: '{"name":"Programme partagé"}',
|
||||
status: status,
|
||||
createdAt: DateTime.utc(2026, 7, 17, 12),
|
||||
);
|
||||
}
|
||||
|
||||
RemoteSyncedItem _remoteSharedProgramItem() {
|
||||
return RemoteSyncedItem(
|
||||
resourceType: SyncResourceType.program,
|
||||
clientId: 'shared-program-1',
|
||||
serverId: 'server-program-1',
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: DateTime.utc(2026, 7, 17, 12),
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1),
|
||||
deletedAt: null,
|
||||
payload: const {'id': 'shared-program-1', 'name': 'Programme partagé'},
|
||||
);
|
||||
}
|
||||
|
||||
ExerciseUseCases _exerciseUseCase(
|
||||
_FakeExerciseRepository repository, {
|
||||
_FakeProgramRepository? programRepository,
|
||||
|
||||
Reference in New Issue
Block a user