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>
564 lines
15 KiB
Dart
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';
|
|
}
|
|
}
|