import 'dart:convert'; import 'package:gametime_server/api/router.dart'; import 'package:gametime_server/api/share_api.dart'; import 'package:gametime_server/application/application.dart'; import 'package:gametime_server/domain/domain.dart'; import 'package:shelf/shelf.dart'; import 'package:test/test.dart'; void main() { test('share routes require bearer authentication', () async { final handler = buildApiHandler(shareApi: _shareApi()); final requests = [ Request('POST', Uri.parse('http://localhost/shares')), Request('GET', Uri.parse('http://localhost/shares/inbox')), Request('POST', Uri.parse('http://localhost/shares/share-1/accept')), Request('POST', Uri.parse('http://localhost/shares/share-1/decline')), Request('POST', Uri.parse('http://localhost/shares/share-1/revoke')), ]; for (final request in requests) { final response = await handler(request); expect(response.statusCode, 401, reason: request.url.path); } }); test('create share returns share id, resolved recipients and unresolved emails', () async { final shares = _FakeShareRepository(); final users = _FakeUserRepository([ _user('user-1', 'user@example.com'), _user('user-2', 'friend@example.com'), ]); final handler = buildApiHandler( shareApi: _shareApi(users: users, shares: shares), ); final response = await handler( Request( 'POST', Uri.parse('http://localhost/shares'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode({ 'resourceType': 'program', 'payload': {'schemaVersion': 2, 'name': 'Programme été'}, 'recipientEmails': [ ' friend@example.com ', 'missing@example.com', 'user@example.com', ], }), ), ); final body = jsonDecode(await response.readAsString()) as Map; expect(response.statusCode, 201); expect(body['shareId'], 'id-1'); expect(body['recipientUserIds'], ['user-2']); expect(body['unresolvedEmails'], ['missing@example.com']); expect(shares.shareById['id-1']?.resourceType, SyncedResourceType.program); }); test('create share returns 400 for invalid resource type', () async { final handler = buildApiHandler(shareApi: _shareApi()); final response = await handler( Request( 'POST', Uri.parse('http://localhost/shares'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode({ 'resourceType': 'exercise', 'payload': {'name': 'Squat'}, 'recipientEmails': ['friend@example.com'], }), ), ); expect(response.statusCode, 400); expect( jsonDecode(await response.readAsString()), {'error': 'resourceType must be program or workoutTemplate.'}, ); }); test('inbox returns serialized shares for the authenticated recipient', () async { final shares = _FakeShareRepository() ..seedInbox( recipientUserId: 'user-1', items: [ ShareInboxItem( share: Share( id: 'share-1', senderUserId: 'sender-1', resourceType: SyncedResourceType.program, payloadJson: {'name': 'Programme A'}, createdAt: DateTime.utc(2026, 7, 19, 10), ), recipient: ShareRecipient( id: 'recipient-1', shareId: 'share-1', recipientUserId: 'user-1', ), ), ], ); final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); final response = await handler( Request( 'GET', Uri.parse('http://localhost/shares/inbox'), headers: {'authorization': 'Bearer valid-token'}, ), ); final body = jsonDecode(await response.readAsString()) as Map; final items = body['items'] as List; expect(response.statusCode, 200); expect(items, hasLength(1)); expect((items.single as Map)['shareId'], 'share-1'); expect((items.single as Map)['resourceType'], 'program'); expect((items.single as Map)['status'], 'pending'); }); test('accept share returns the copied synced resource', () async { final shares = _FakeShareRepository() ..seedShare( share: Share( id: 'share-1', senderUserId: 'sender-1', resourceType: SyncedResourceType.workoutTemplate, payloadJson: {'schemaVersion': 3, 'name': 'Template A'}, createdAt: DateTime.utc(2026, 7, 19, 10), ), recipients: [ ShareRecipient( id: 'recipient-1', shareId: 'share-1', recipientUserId: 'user-1', ), ], ); final resources = _FakeSyncedResourceRepository(); final handler = buildApiHandler( shareApi: _shareApi(shares: shares, resources: resources), ); final response = await handler( Request( 'POST', Uri.parse('http://localhost/shares/share-1/accept'), headers: {'authorization': 'Bearer valid-token'}, ), ); final body = jsonDecode(await response.readAsString()) as Map; final created = body['createdResource'] as Map; expect(response.statusCode, 200); expect(created['resourceType'], 'workoutTemplate'); expect(created['payload'], {'schemaVersion': 3, 'name': 'Template A'}); expect(shares.recipientById['recipient-1']?.status, ShareRecipientStatus.accepted); expect(resources.items.single.ownerUserId, 'user-1'); }); test('accept share returns 404 when share is missing', () async { final handler = buildApiHandler(shareApi: _shareApi()); final response = await handler( Request( 'POST', Uri.parse('http://localhost/shares/missing/accept'), headers: {'authorization': 'Bearer valid-token'}, ), ); expect(response.statusCode, 404); expect( jsonDecode(await response.readAsString()), {'error': 'Share not found.'}, ); }); test('accept share returns 409 when share was already answered', () async { final shares = _FakeShareRepository() ..seedShare( share: Share( id: 'share-1', senderUserId: 'sender-1', resourceType: SyncedResourceType.program, payloadJson: {'name': 'Programme A'}, createdAt: DateTime.utc(2026, 7, 19, 10), ), recipients: [ ShareRecipient( id: 'recipient-1', shareId: 'share-1', recipientUserId: 'user-1', status: ShareRecipientStatus.accepted, respondedAt: DateTime.utc(2026, 7, 19, 11), ), ], ); final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); final response = await handler( Request( 'POST', Uri.parse('http://localhost/shares/share-1/accept'), headers: {'authorization': 'Bearer valid-token'}, ), ); expect(response.statusCode, 409); expect( jsonDecode(await response.readAsString()), {'error': 'Share has already been answered.'}, ); }); test('decline share returns 204 and updates recipient status', () async { final shares = _FakeShareRepository() ..seedShare( share: Share( id: 'share-1', senderUserId: 'sender-1', resourceType: SyncedResourceType.program, payloadJson: {'name': 'Programme A'}, createdAt: DateTime.utc(2026, 7, 19, 10), ), recipients: [ ShareRecipient( id: 'recipient-1', shareId: 'share-1', recipientUserId: 'user-1', ), ], ); final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); final response = await handler( Request( 'POST', Uri.parse('http://localhost/shares/share-1/decline'), headers: {'authorization': 'Bearer valid-token'}, ), ); expect(response.statusCode, 204); expect(shares.recipientById['recipient-1']?.status, ShareRecipientStatus.declined); }); test('decline share returns 409 when share was already answered', () async { final shares = _FakeShareRepository() ..seedShare( share: Share( id: 'share-1', senderUserId: 'sender-1', resourceType: SyncedResourceType.program, payloadJson: {'name': 'Programme A'}, createdAt: DateTime.utc(2026, 7, 19, 10), ), recipients: [ ShareRecipient( id: 'recipient-1', shareId: 'share-1', recipientUserId: 'user-1', status: ShareRecipientStatus.declined, respondedAt: DateTime.utc(2026, 7, 19, 11), ), ], ); final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); final response = await handler( Request( 'POST', Uri.parse('http://localhost/shares/share-1/decline'), headers: {'authorization': 'Bearer valid-token'}, ), ); expect(response.statusCode, 409); expect( jsonDecode(await response.readAsString()), {'error': 'Share has already been answered.'}, ); }); test('revoke share returns 204 and marks share as revoked', () async { final shares = _FakeShareRepository() ..seedShare( share: Share( id: 'share-1', senderUserId: 'user-1', resourceType: SyncedResourceType.program, payloadJson: {'name': 'Programme A'}, createdAt: DateTime.utc(2026, 7, 19, 10), ), recipients: const [], ); final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); final response = await handler( Request( 'POST', Uri.parse('http://localhost/shares/share-1/revoke'), headers: {'authorization': 'Bearer valid-token'}, ), ); expect(response.statusCode, 204); expect(shares.shareById['share-1']?.isRevoked, isTrue); }); test('revoke share returns 404 when requester is not the sender', () async { final shares = _FakeShareRepository() ..seedShare( share: Share( id: 'share-1', senderUserId: 'other-user', resourceType: SyncedResourceType.program, payloadJson: {'name': 'Programme A'}, createdAt: DateTime.utc(2026, 7, 19, 10), ), recipients: const [], ); final handler = buildApiHandler(shareApi: _shareApi(shares: shares)); final response = await handler( Request( 'POST', Uri.parse('http://localhost/shares/share-1/revoke'), headers: {'authorization': 'Bearer valid-token'}, ), ); expect(response.statusCode, 404); expect( jsonDecode(await response.readAsString()), {'error': 'Share not found.'}, ); }); } ShareApi _shareApi({ _FakeUserRepository? users, _FakeShareRepository? shares, _FakeSyncedResourceRepository? resources, }) { final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12)); final ids = _FakeIds(); final userRepository = users ?? _FakeUserRepository([_user('user-1', 'user@example.com')]); final sessions = _FakeAuthSessionRepository( AuthSession( id: 'session-1', userId: 'user-1', tokenHash: 'token-hash:valid-token', issuedAt: clock.now(), expiresAt: clock.now().add(const Duration(days: 1)), ), ); final tokens = _FakeTokenService(); final authenticate = AuthenticateRequestUseCase( users: userRepository, sessions: sessions, tokens: tokens, clock: clock, ); final shareRepository = shares ?? _FakeShareRepository(); final resourceRepository = resources ?? _FakeSyncedResourceRepository(); return ShareApi( createShare: CreateShareUseCase( users: userRepository, shares: shareRepository, clock: clock, ids: ids, ), listInbox: ListInboxUseCase(shares: shareRepository), acceptShare: AcceptShareUseCase( shares: shareRepository, resources: resourceRepository, clock: clock, ids: ids, ), declineShare: DeclineShareUseCase(shares: shareRepository, clock: clock), revokeShare: RevokeShareUseCase(shares: shareRepository, clock: clock), authenticateRequest: authenticate, ); } final class _FakeUserRepository implements UserRepository { _FakeUserRepository(List users) : _byId = {for (final user in users) user.id: user}, _byEmail = {for (final user in users) user.email: user}; final Map _byId; final Map _byEmail; @override Future findByEmail(String email) async { return _byEmail[email.trim().toLowerCase()]; } @override Future findById(String id) async { return _byId[id]; } @override Future insert(UserAccount user) async {} @override Future updatePasswordHash({ required String userId, required String passwordHash, required DateTime updatedAt, }) async {} } final class _FakeAuthSessionRepository implements AuthSessionRepository { const _FakeAuthSessionRepository(this.session); final AuthSession session; @override Future findByTokenHash(String tokenHash) async { return tokenHash == session.tokenHash ? session : null; } @override Future insert(AuthSession session) async {} @override Future revoke({ required String sessionId, required DateTime revokedAt, }) async {} } final class _FakeShareRepository implements ShareRepository { final shareById = {}; final recipientById = {}; final recipientByKey = {}; final inboxByRecipient = >{}; void seedShare({ required Share share, required List recipients, }) { shareById[share.id] = share; for (final recipient in recipients) { recipientById[recipient.id] = recipient; recipientByKey['${recipient.shareId}:${recipient.recipientUserId}'] = recipient; } } void seedInbox({ required String recipientUserId, required List items, }) { inboxByRecipient[recipientUserId] = items; for (final item in items) { seedShare(share: item.share, recipients: [item.recipient]); } } @override Future insertShare({ required Share share, required List recipients, }) async { seedShare(share: share, recipients: recipients); } @override Future> listInbox(String recipientUserId) async { return inboxByRecipient[recipientUserId] ?? const []; } @override Future findShareById(String shareId) async { return shareById[shareId]; } @override Future findRecipient({ required String shareId, required String recipientUserId, }) async { return recipientByKey['$shareId:$recipientUserId']; } @override Future updateRecipientStatus({ required String recipientId, required ShareRecipientStatus status, required DateTime respondedAt, }) async { final existing = recipientById[recipientId]; if (existing == null) { return; } final updated = ShareRecipient( id: existing.id, shareId: existing.shareId, recipientUserId: existing.recipientUserId, status: status, respondedAt: respondedAt, ); recipientById[recipientId] = updated; recipientByKey['${existing.shareId}:${existing.recipientUserId}'] = updated; final inbox = inboxByRecipient[existing.recipientUserId]; if (inbox != null) { inboxByRecipient[existing.recipientUserId] = [ for (final item in inbox) if (item.recipient.id == recipientId) ShareInboxItem(share: item.share, recipient: updated) else item, ]; } } @override Future revokeShare({ required String shareId, required DateTime revokedAt, }) async { final existing = shareById[shareId]; if (existing == null) { return; } final updated = Share( id: existing.id, senderUserId: existing.senderUserId, resourceType: existing.resourceType, payloadJson: existing.payloadJson, createdAt: existing.createdAt, revokedAt: revokedAt, ); shareById[shareId] = updated; } } final class _FakeSyncedResourceRepository implements SyncedResourceRepository { final items = []; @override Future upsertWithLww(SyncedResource resource) async { items.add(resource); return SyncWriteResult( status: SyncWriteStatus.accepted, resource: resource, ); } @override Future> findAllForUserSince({ required String ownerUserId, DateTime? since, }) async { return items .where((item) => item.ownerUserId == ownerUserId) .where((item) => since == null || item.serverUpdatedAt.isAfter(since)) .toList(); } } final class _FakeTokenService implements OpaqueTokenService { @override String generateToken() => 'valid-token'; @override String hashToken(String token) => 'token-hash:$token'; } final class _FakeClock implements Clock { const _FakeClock(this.value); final DateTime value; @override DateTime now() => value; } final class _FakeIds implements IdGenerator { var _next = 0; @override String newId() { _next += 1; return 'id-$_next'; } } UserAccount _user(String id, String email) { return UserAccount( id: id, email: email, passwordHash: 'hash', createdAt: DateTime.utc(2026, 7, 19, 12), updatedAt: DateTime.utc(2026, 7, 19, 12), ); }