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( '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, resources: resources, 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 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, resources: resources, clock: clock, ids: ids, ); expect( () => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'), throwsA(isA()), ); }); 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, resources: resources, clock: clock, ids: ids, ); expect( () => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'), throwsA(isA()), ); }); 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()), ); }); 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', SyncedResourceType resourceType = SyncedResourceType.program, Map payloadJson = const {'schemaVersion': 1}, DateTime? createdAt, DateTime? revokedAt, }) { return Share( id: id, senderUserId: senderUserId, resourceType: resourceType, 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 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 = {}; void put(UserAccount user) { _usersByEmail[user.email] = user; } @override Future findByEmail(String email) async { return _usersByEmail[email.toLowerCase()]; } @override Future findById(String id) async { return _usersByEmail.values.where((user) => user.id == id).firstOrNull; } @override Future insert(UserAccount user) async { put(user); } @override Future updatePasswordHash({ required String userId, required String passwordHash, required DateTime updatedAt, }) async {} } final class _FakeShareRepository implements ShareRepository { final shares = []; final recipients = []; 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 insertShare({ required Share share, required List recipients, }) async { putShare(share); for (final recipient in recipients) { putRecipient(recipient); } } @override Future> 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 findShareById(String shareId) async { return shares.where((share) => share.id == shareId).firstOrNull; } @override Future findRecipient({ required String shareId, required String recipientUserId, }) async { return recipient(shareId, recipientUserId); } @override Future 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 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, resourceType: share.resourceType, payloadJson: share.payloadJson, createdAt: share.createdAt, revokedAt: revokedAt, ); } } final class _FakeSyncedResourceRepository implements SyncedResourceRepository { final _items = {}; 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 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> 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'; } }