Files
GameTime/server/test/share_use_cases_test.dart
Blomios 917777e18b chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:48:54 +02:00

564 lines
15 KiB
Dart

import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/domain/domain.dart';
import 'package:test/test.dart';
void main() {
late _FakeUserRepository users;
late _FakeShareRepository shares;
late _FakeSyncedResourceRepository resources;
late _FakeClock clock;
late _FakeIds ids;
setUp(() {
clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
ids = _FakeIds();
users = _FakeUserRepository()
..put(_user('sender', 'sender@example.com'))
..put(_user('recipient-1', 'friend@example.com'))
..put(_user('recipient-2', 'coach@example.com'));
shares = _FakeShareRepository();
resources = _FakeSyncedResourceRepository();
});
test('create share resolves known and unknown recipient emails', () async {
final useCase = CreateShareUseCase(
users: users,
shares: shares,
clock: clock,
ids: ids,
);
final result = await useCase.execute(
senderUserId: 'sender',
resourceType: 'program',
payloadJson: {'name': 'Push day'},
recipientEmails: [
'friend@example.com',
'missing@example.com',
'coach@example.com',
],
);
expect(result.share.senderUserId, 'sender');
expect(result.recipientUserIds, ['recipient-1', 'recipient-2']);
expect(result.unresolvedEmails, ['missing@example.com']);
expect(shares.recipients.map((recipient) => recipient.recipientUserId), [
'recipient-1',
'recipient-2',
]);
});
test('create pack share stores a composite payload', () async {
final useCase = CreateShareUseCase(
users: users,
shares: shares,
clock: clock,
ids: ids,
);
final result = await useCase.execute(
senderUserId: 'sender',
shareKind: 'pack',
packName: 'Pack été',
resourceType: null,
payloadJson: null,
packItems: const [
SharePackItemInput(
resourceType: 'program',
payloadJson: {'schemaVersion': 2, 'name': 'Programme'},
),
SharePackItemInput(
resourceType: 'workoutTemplate',
payloadJson: {'schemaVersion': 3, 'name': 'Séance'},
),
],
recipientEmails: ['friend@example.com'],
);
expect(result.share.kind, ShareKind.pack);
expect(result.share.packName, 'Pack été');
expect(result.share.resourceType, isNull);
expect(result.share.payloadJson['items'], hasLength(2));
expect(shares.recipients.single.recipientUserId, 'recipient-1');
});
test(
'accept creates a copied synced resource without touching source',
() async {
final share = _share(
id: 'share-1',
senderUserId: 'sender',
resourceType: SyncedResourceType.workoutTemplate,
payloadJson: {'schemaVersion': 3, 'name': 'Full body'},
);
shares.putShare(share);
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
),
);
final source = _resource(
serverId: 'source-server',
ownerUserId: 'sender',
resourceType: SyncedResourceType.workoutTemplate,
clientId: 'source-client',
payloadJson: {'name': 'Source'},
);
resources.put(source);
final useCase = AcceptShareUseCase(
shares: shares,
clock: clock,
ids: ids,
);
final result = await useCase.execute(
shareId: 'share-1',
recipientUserId: 'recipient-1',
);
expect(result.createdResource.ownerUserId, 'recipient-1');
expect(result.createdResource.clientId, isNot('source-client'));
expect(result.createdResource.payloadJson, share.payloadJson);
expect(result.createdResource.schemaVersion, 3);
expect(resources.getByServerId('source-server'), same(source));
expect(
shares.recipient('share-1', 'recipient-1')?.status,
ShareRecipientStatus.accepted,
);
},
);
test('accept pack creates all copied resources atomically', () async {
final share = _share(
id: 'share-1',
kind: ShareKind.pack,
resourceType: null,
packName: 'Pack été',
payloadJson: {
'items': [
{
'resourceType': 'program',
'payload': {'schemaVersion': 2, 'name': 'Programme'},
},
{
'resourceType': 'workoutTemplate',
'payload': {'schemaVersion': 3, 'name': 'Séance'},
},
],
},
);
shares.putShare(share);
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
),
);
final useCase = AcceptShareUseCase(shares: shares, clock: clock, ids: ids);
final result = await useCase.execute(
shareId: 'share-1',
recipientUserId: 'recipient-1',
);
expect(result.createdResources.map((item) => item.resourceType), [
SyncedResourceType.program,
SyncedResourceType.workoutTemplate,
]);
expect(result.createdResources.map((item) => item.ownerUserId).toSet(), {
'recipient-1',
});
expect(result.createdResources.map((item) => item.schemaVersion), [2, 3]);
expect(
shares.recipient('share-1', 'recipient-1')?.status,
ShareRecipientStatus.accepted,
);
});
test('accept on already answered share fails', () async {
shares.putShare(_share(id: 'share-1'));
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
status: ShareRecipientStatus.declined,
respondedAt: clock.now(),
),
);
final useCase = AcceptShareUseCase(shares: shares, clock: clock, ids: ids);
expect(
() => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'),
throwsA(isA<ShareConflictException>()),
);
});
test('accept on revoked share fails', () async {
shares.putShare(_share(id: 'share-1', revokedAt: clock.now()));
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
),
);
final useCase = AcceptShareUseCase(shares: shares, clock: clock, ids: ids);
expect(
() => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'),
throwsA(isA<ShareConflictException>()),
);
});
test('decline marks a pending recipient as declined', () async {
shares.putShare(_share(id: 'share-1'));
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
),
);
final useCase = DeclineShareUseCase(shares: shares, clock: clock);
await useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1');
final recipient = shares.recipient('share-1', 'recipient-1');
expect(recipient?.status, ShareRecipientStatus.declined);
expect(recipient?.respondedAt, clock.now());
});
test('revoke by sender marks the share revoked', () async {
shares.putShare(_share(id: 'share-1', senderUserId: 'sender'));
final useCase = RevokeShareUseCase(shares: shares, clock: clock);
await useCase.execute(shareId: 'share-1', senderUserId: 'sender');
expect(shares.findShareById('share-1'), completion(isNotNull));
expect(shares.shares.single.revokedAt, clock.now());
});
test('revoke by a non-sender is refused', () async {
shares.putShare(_share(id: 'share-1', senderUserId: 'sender'));
final useCase = RevokeShareUseCase(shares: shares, clock: clock);
expect(
() => useCase.execute(shareId: 'share-1', senderUserId: 'recipient-1'),
throwsA(isA<ShareNotFoundException>()),
);
});
test('inbox is sorted from newest to oldest', () async {
shares.putShare(
_share(id: 'older', createdAt: DateTime.utc(2026, 7, 19, 10)),
);
shares.putShare(
_share(id: 'newer', createdAt: DateTime.utc(2026, 7, 19, 11)),
);
shares
..putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'older',
recipientUserId: 'recipient-1',
),
)
..putRecipient(
ShareRecipient(
id: 'recipient-row-2',
shareId: 'newer',
recipientUserId: 'recipient-1',
),
);
final useCase = ListInboxUseCase(shares: shares);
final result = await useCase.execute(recipientUserId: 'recipient-1');
expect(result.map((item) => item.share.id), ['newer', 'older']);
});
}
UserAccount _user(String id, String email) {
return UserAccount(
id: id,
email: email,
passwordHash: 'hash',
createdAt: DateTime.utc(2026, 7, 19),
updatedAt: DateTime.utc(2026, 7, 19),
);
}
Share _share({
required String id,
String senderUserId = 'sender',
ShareKind kind = ShareKind.single,
SyncedResourceType? resourceType = SyncedResourceType.program,
String? packName,
Map<String, Object?> payloadJson = const {'schemaVersion': 1},
DateTime? createdAt,
DateTime? revokedAt,
}) {
return Share(
id: id,
senderUserId: senderUserId,
kind: kind,
resourceType: resourceType,
packName: packName,
payloadJson: payloadJson,
createdAt: createdAt ?? DateTime.utc(2026, 7, 19, 12),
revokedAt: revokedAt,
);
}
SyncedResource _resource({
required String serverId,
required String ownerUserId,
required SyncedResourceType resourceType,
required String clientId,
required Map<String, Object?> payloadJson,
}) {
return SyncedResource(
serverId: serverId,
ownerUserId: ownerUserId,
resourceType: resourceType,
clientId: clientId,
payloadJson: payloadJson,
schemaVersion: 1,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
serverUpdatedAt: DateTime.utc(2026, 7, 19, 10),
);
}
final class _FakeUserRepository implements UserRepository {
final _usersByEmail = <String, UserAccount>{};
void put(UserAccount user) {
_usersByEmail[user.email] = user;
}
@override
Future<UserAccount?> findByEmail(String email) async {
return _usersByEmail[email.toLowerCase()];
}
@override
Future<UserAccount?> findById(String id) async {
return _usersByEmail.values.where((user) => user.id == id).firstOrNull;
}
@override
Future<void> insert(UserAccount user) async {
put(user);
}
@override
Future<void> updatePasswordHash({
required String userId,
required String passwordHash,
required DateTime updatedAt,
}) async {}
}
final class _FakeShareRepository implements ShareRepository {
final shares = <Share>[];
final recipients = <ShareRecipient>[];
void putShare(Share share) {
shares.removeWhere((item) => item.id == share.id);
shares.add(share);
}
void putRecipient(ShareRecipient recipient) {
recipients.removeWhere(
(item) =>
item.shareId == recipient.shareId &&
item.recipientUserId == recipient.recipientUserId,
);
recipients.add(recipient);
}
ShareRecipient? recipient(String shareId, String recipientUserId) {
return recipients
.where(
(item) =>
item.shareId == shareId &&
item.recipientUserId == recipientUserId,
)
.firstOrNull;
}
@override
Future<void> insertShare({
required Share share,
required List<ShareRecipient> recipients,
}) async {
putShare(share);
for (final recipient in recipients) {
putRecipient(recipient);
}
}
@override
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
final items =
[
for (final recipient in recipients)
if (recipient.recipientUserId == recipientUserId)
ShareInboxItem(
share: shares.singleWhere(
(share) => share.id == recipient.shareId,
),
recipient: recipient,
),
]..sort(
(left, right) =>
right.share.createdAt.compareTo(left.share.createdAt),
);
return items;
}
@override
Future<Share?> findShareById(String shareId) async {
return shares.where((share) => share.id == shareId).firstOrNull;
}
@override
Future<ShareRecipient?> findRecipient({
required String shareId,
required String recipientUserId,
}) async {
return recipient(shareId, recipientUserId);
}
@override
Future<void> updateRecipientStatus({
required String recipientId,
required ShareRecipientStatus status,
required DateTime respondedAt,
}) async {
final index = recipients.indexWhere((item) => item.id == recipientId);
final recipient = recipients[index];
recipients[index] = ShareRecipient(
id: recipient.id,
shareId: recipient.shareId,
recipientUserId: recipient.recipientUserId,
status: status,
respondedAt: respondedAt,
);
}
@override
Future<List<SyncedResource>> acceptShare({
required String recipientId,
required DateTime respondedAt,
required List<SyncedResource> resources,
}) async {
await updateRecipientStatus(
recipientId: recipientId,
status: ShareRecipientStatus.accepted,
respondedAt: respondedAt,
);
return resources;
}
@override
Future<void> revokeShare({
required String shareId,
required DateTime revokedAt,
}) async {
final index = shares.indexWhere((share) => share.id == shareId);
final share = shares[index];
shares[index] = Share(
id: share.id,
senderUserId: share.senderUserId,
kind: share.kind,
resourceType: share.resourceType,
packName: share.packName,
payloadJson: share.payloadJson,
createdAt: share.createdAt,
revokedAt: revokedAt,
);
}
}
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
final _items = <String, SyncedResource>{};
void put(SyncedResource resource) {
_items[_key(
resource.ownerUserId,
resource.resourceType,
resource.clientId,
)] =
resource;
}
SyncedResource? getByServerId(String serverId) {
return _items.values.where((item) => item.serverId == serverId).firstOrNull;
}
@override
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
final key = _key(
resource.ownerUserId,
resource.resourceType,
resource.clientId,
);
final existing = _items[key];
if (existing != null &&
!resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) {
return SyncWriteResult(
status: SyncWriteStatus.ignoredOlder,
resource: existing,
);
}
_items[key] = resource;
return SyncWriteResult(
status: SyncWriteStatus.accepted,
resource: resource,
);
}
@override
Future<List<SyncedResource>> findAllForUserSince({
required String ownerUserId,
DateTime? since,
}) async {
return _items.values
.where((item) => item.ownerUserId == ownerUserId)
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
.toList();
}
}
String _key(
String ownerUserId,
SyncedResourceType resourceType,
String clientId,
) {
return '$ownerUserId:${resourceType.wireName}:$clientId';
}
final class _FakeClock implements Clock {
_FakeClock(this.value);
DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}