diff --git a/.ideai/sprints/1bb8bdf2-9c35-4f53-9a31-1390a47bec63/sprint.md b/.ideai/sprints/1bb8bdf2-9c35-4f53-9a31-1390a47bec63/sprint.md new file mode 100644 index 0000000..85b50ce --- /dev/null +++ b/.ideai/sprints/1bb8bdf2-9c35-4f53-9a31-1390a47bec63/sprint.md @@ -0,0 +1,11 @@ +--- +id: "1bb8bdf2-9c35-4f53-9a31-1390a47bec63" +order: 2 +name: "Exercice editor enhancement" +status: "planned" +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784449859008 +updatedAt: 1784449859008 +version: 1 +--- diff --git a/.ideai/sprints/index.json b/.ideai/sprints/index.json index c12a843..f6734fb 100644 --- a/.ideai/sprints/index.json +++ b/.ideai/sprints/index.json @@ -9,6 +9,15 @@ "status": "planned", "updatedAt": 1784329015766, "version": 1 + }, + { + "id": "1bb8bdf2-9c35-4f53-9a31-1390a47bec63", + "path": "1bb8bdf2-9c35-4f53-9a31-1390a47bec63", + "order": 2, + "name": "Exercice editor enhancement", + "status": "planned", + "updatedAt": 1784449859008, + "version": 1 } ] } \ No newline at end of file diff --git a/.ideai/tickets/50/carnet.md b/.ideai/tickets/50/carnet.md index 3e46a85..b684b25 100644 --- a/.ideai/tickets/50/carnet.md +++ b/.ideai/tickets/50/carnet.md @@ -1,6 +1,7 @@ --- issueRef: "#50" -version: 1 -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedAt: 1784412001974 +version: 3 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784449447058 --- +Endpoints `POST /sync/push`, `GET /sync/pull`, `POST /sync/exchange` implémentés, protégés par le middleware Bearer. LWW rendu atomique côté SQL (CTE `INSERT ... ON CONFLICT ... WHERE client_updated_at < EXCLUDED.client_updated_at` + fallback pour `ignoredOlder`), relu manuellement et jugé correct. Tests unitaires avec fakes en mémoire verts (15/15), `dart analyze` clean. Non vérifié : aucun test de bout en bout contre un vrai PostgreSQL (pas d'accès Docker dans le sandbox Main). À faire avant mise en prod : une fois #52 livré, tester `push`/`pull`/`exchange` via curl contre le serveur réel + vérifier la concurrence de l'upsert LWW sous charge. \ No newline at end of file diff --git a/.ideai/tickets/50/issue.md b/.ideai/tickets/50/issue.md index ba2c8c9..af63bcb 100644 --- a/.ideai/tickets/50/issue.md +++ b/.ideai/tickets/50/issue.md @@ -2,15 +2,15 @@ id: "983aa02b-7577-4fde-b77a-12355df0efdb" number: 50 title: "[Server] API de synchronisation incrémentale LWW" -status: "open" +status: "qa" priority: "low" sprint: null links: [{"target":"#46","kind":"relatesTo"},{"target":"#48","kind":"dependsOn"},{"target":"#49","kind":"dependsOn"}] agentRefs: [] createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784412001974 -updatedAt: 1784412001974 -version: 1 +updatedAt: 1784449447058 +version: 3 --- Implémenter les ports/use cases et endpoints de sync incrémentale authentifiée. Endpoints : `POST /sync/push`, `GET /sync/pull?since=...`, optionnel `POST /sync/exchange`. Stratégie v1 last-write-wins basée sur `clientUpdatedAt` puis tie-breaker `serverUpdatedAt`/`serverId`. Supporter upsert, soft delete, réponses par item (`accepted`, `ignoredOlder`, `conflictLwwApplied`, `error`) et cursor serveur monotone. Ne pas intégrer le client Flutter dans ce ticket. \ No newline at end of file diff --git a/.ideai/tickets/54/carnet.md b/.ideai/tickets/54/carnet.md new file mode 100644 index 0000000..e385c35 --- /dev/null +++ b/.ideai/tickets/54/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#54" +version: 2 +updatedBy: {"kind":"user"} +updatedAt: 1784449895057 +--- diff --git a/.ideai/tickets/54/issue.md b/.ideai/tickets/54/issue.md new file mode 100644 index 0000000..dd1ad52 --- /dev/null +++ b/.ideai/tickets/54/issue.md @@ -0,0 +1,15 @@ +--- +id: "7e6894d5-36ad-413b-9fda-be3ba26db6cb" +number: 54 +title: "Exercice à plusieurs étapes" +status: "open" +priority: "medium" +sprint: "1bb8bdf2-9c35-4f53-9a31-1390a47bec63" +links: [] +agentRefs: [] +createdBy: {"kind":"user"} +updatedBy: {"kind":"user"} +createdAt: 1784449895047 +updatedAt: 1784449895057 +version: 2 +--- diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index fadf2d9..5db1edc 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 54 + "nextNumber": 55 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 3f650fb..e4fec3a 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -567,11 +567,11 @@ "issueRef": "#50", "path": "50", "title": "[Server] API de synchronisation incrémentale LWW", - "status": "open", + "status": "qa", "priority": "low", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784412001974 + "updatedAt": 1784449447058 }, { "issueRef": "#51", @@ -602,6 +602,16 @@ "sprint": null, "assignedAgentIds": [], "updatedAt": 1784412015509 + }, + { + "issueRef": "#54", + "path": "54", + "title": "Exercice à plusieurs étapes", + "status": "open", + "priority": "medium", + "sprint": "1bb8bdf2-9c35-4f53-9a31-1390a47bec63", + "assignedAgentIds": [], + "updatedAt": 1784449895057 } ] } \ No newline at end of file diff --git a/server/README.md b/server/README.md index b9d5747..42865e5 100644 --- a/server/README.md +++ b/server/README.md @@ -104,18 +104,34 @@ server clock if no resource is returned. `pushResults` included so per-item validation errors remain visible to the client. +## Sharing + +Ticket #51 adds authenticated targeted sharing for programs and workout +templates. + +Endpoints: + +- `POST /shares` with `Authorization: Bearer `. +- `GET /shares/inbox` with `Authorization: Bearer `. +- `POST /shares/{id}/accept` with `Authorization: Bearer `. +- `POST /shares/{id}/decline` with `Authorization: Bearer `. +- `POST /shares/{id}/revoke` with `Authorization: Bearer `. + +Creating a share stores a snapshot payload and creates pending recipient rows +for known recipient emails. Unknown emails are reported as `unresolvedEmails` +without failing the whole request. Accepting a share creates a new +`synced_resources` copy owned by the recipient with a server-generated +`clientId`; the sender's original resource is never modified. Accept returns +`409` for revoked or already answered shares, while missing shares or recipients +return `404`. + ## Scope Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema. Ticket #48 -adds authentication. Ticket #50 adds sync only; sharing behavior is still -implemented in a later ticket. +adds authentication. Ticket #50 adds sync. Ticket #51 adds targeted sharing. Upcoming tickets will fill the empty adapters and use cases: -- #48: user authentication and API tokens. -- #49: PostgreSQL schema and repositories. -- #50: incremental sync API. -- #51: targeted sharing. - #52: Docker and registry packaging. - #53: API, contract and integration tests. diff --git a/server/bin/server.dart b/server/bin/server.dart index 2701234..2c910c1 100644 --- a/server/bin/server.dart +++ b/server/bin/server.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:gametime_server/api/auth_api.dart'; import 'package:gametime_server/api/router.dart'; +import 'package:gametime_server/api/share_api.dart'; import 'package:gametime_server/api/sync_api.dart'; import 'package:gametime_server/application/application.dart'; import 'package:gametime_server/infrastructure/postgres/postgres.dart'; @@ -17,6 +18,7 @@ Future main(List arguments) async { final users = PostgresUserRepository(connection); final sessions = PostgresAuthSessionRepository(connection); final resources = PostgresSyncedResourceRepository(connection); + final shares = PostgresShareRepository(connection); final passwordHasher = Pbkdf2PasswordHasher(); final tokens = SecureOpaqueTokenService(); const clock = SystemClock(); @@ -57,9 +59,27 @@ Future main(List arguments) async { exchangeSync: ExchangeSyncUseCase(push: pushSync, pull: pullSync), authenticateRequest: authenticateRequest, ); + final shareApi = ShareApi( + createShare: CreateShareUseCase( + users: users, + shares: shares, + clock: clock, + ids: ids, + ), + listInbox: ListInboxUseCase(shares: shares), + acceptShare: AcceptShareUseCase( + shares: shares, + resources: resources, + clock: clock, + ids: ids, + ), + declineShare: DeclineShareUseCase(shares: shares, clock: clock), + revokeShare: RevokeShareUseCase(shares: shares, clock: clock), + authenticateRequest: authenticateRequest, + ); final server = await shelf_io.serve( - buildApiHandler(authApi: authApi, syncApi: syncApi), + buildApiHandler(authApi: authApi, syncApi: syncApi, shareApi: shareApi), InternetAddress.anyIPv4, port, ); diff --git a/server/lib/api/router.dart b/server/lib/api/router.dart index 3311870..f25c83f 100644 --- a/server/lib/api/router.dart +++ b/server/lib/api/router.dart @@ -4,9 +4,14 @@ import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; import 'auth_api.dart'; +import 'share_api.dart'; import 'sync_api.dart'; -Handler buildApiHandler({AuthApi? authApi, SyncApi? syncApi}) { +Handler buildApiHandler({ + AuthApi? authApi, + SyncApi? syncApi, + ShareApi? shareApi, +}) { final router = Router() ..get('/health', (Request request) { return Response.ok( @@ -49,5 +54,39 @@ Handler buildApiHandler({AuthApi? authApi, SyncApi? syncApi}) { ); } + if (shareApi != null) { + router + ..post( + '/shares', + authenticationMiddleware(shareApi.authenticateRequest.execute)( + shareApi.create, + ), + ) + ..get( + '/shares/inbox', + authenticationMiddleware(shareApi.authenticateRequest.execute)( + shareApi.inbox, + ), + ) + ..post( + '/shares//accept', + authenticationMiddleware(shareApi.authenticateRequest.execute)( + (request) => shareApi.accept(request, request.params['id']!), + ), + ) + ..post( + '/shares//decline', + authenticationMiddleware(shareApi.authenticateRequest.execute)( + (request) => shareApi.decline(request, request.params['id']!), + ), + ) + ..post( + '/shares//revoke', + authenticationMiddleware(shareApi.authenticateRequest.execute)( + (request) => shareApi.revoke(request, request.params['id']!), + ), + ); + } + return const Pipeline().addMiddleware(logRequests()).addHandler(router.call); } diff --git a/server/lib/api/share_api.dart b/server/lib/api/share_api.dart new file mode 100644 index 0000000..861be7c --- /dev/null +++ b/server/lib/api/share_api.dart @@ -0,0 +1,195 @@ +import 'dart:convert'; + +import 'package:shelf/shelf.dart'; + +import '../application/application.dart'; +import '../domain/domain.dart'; +import 'auth_api.dart'; + +final class ShareApi { + const ShareApi({ + required this.createShare, + required this.listInbox, + required this.acceptShare, + required this.declineShare, + required this.revokeShare, + required this.authenticateRequest, + }); + + final CreateShareUseCase createShare; + final ListInboxUseCase listInbox; + final AcceptShareUseCase acceptShare; + final DeclineShareUseCase declineShare; + final RevokeShareUseCase revokeShare; + final AuthenticateRequestUseCase authenticateRequest; + + Future create(Request request) async { + final authenticated = authenticatedRequestFrom(request); + if (authenticated == null) { + return _errorResponse(401, 'Unauthorized.'); + } + try { + final body = await _readJsonObject(request); + final result = await createShare.execute( + senderUserId: authenticated.user.id, + resourceType: _requiredString(body, 'resourceType'), + payloadJson: _requiredObject(body, 'payload'), + recipientEmails: _requiredStringList(body, 'recipientEmails'), + ); + return _jsonResponse(201, { + 'shareId': result.share.id, + 'recipientUserIds': result.recipientUserIds, + 'unresolvedEmails': result.unresolvedEmails, + }); + } on ValidationException catch (error) { + return _errorResponse(400, error.message); + } on FormatException catch (error) { + return _errorResponse(400, error.message); + } + } + + Future inbox(Request request) async { + final authenticated = authenticatedRequestFrom(request); + if (authenticated == null) { + return _errorResponse(401, 'Unauthorized.'); + } + final items = await listInbox.execute( + recipientUserId: authenticated.user.id, + ); + return _jsonResponse(200, { + 'items': [ + for (final item in items) + { + 'shareId': item.share.id, + 'senderUserId': item.share.senderUserId, + 'resourceType': item.share.resourceType.wireName, + 'payload': item.share.payloadJson, + 'status': item.recipient.status.wireName, + 'createdAt': item.share.createdAt.toIso8601String(), + 'respondedAt': item.recipient.respondedAt?.toIso8601String(), + }, + ], + }); + } + + Future accept(Request request, String shareId) async { + final authenticated = authenticatedRequestFrom(request); + if (authenticated == null) { + return _errorResponse(401, 'Unauthorized.'); + } + try { + final result = await acceptShare.execute( + shareId: shareId, + recipientUserId: authenticated.user.id, + ); + return _jsonResponse(200, { + 'createdResource': _resourceJson(result.createdResource), + }); + } on ShareNotFoundException catch (error) { + return _errorResponse(404, error.message); + } on ShareConflictException catch (error) { + return _errorResponse(409, error.message); + } + } + + Future decline(Request request, String shareId) async { + final authenticated = authenticatedRequestFrom(request); + if (authenticated == null) { + return _errorResponse(401, 'Unauthorized.'); + } + try { + await declineShare.execute( + shareId: shareId, + recipientUserId: authenticated.user.id, + ); + return Response(204); + } on ShareNotFoundException catch (error) { + return _errorResponse(404, error.message); + } on ShareConflictException catch (error) { + return _errorResponse(409, error.message); + } + } + + Future revoke(Request request, String shareId) async { + final authenticated = authenticatedRequestFrom(request); + if (authenticated == null) { + return _errorResponse(401, 'Unauthorized.'); + } + try { + await revokeShare.execute( + shareId: shareId, + senderUserId: authenticated.user.id, + ); + return Response(204); + } on ShareNotFoundException catch (error) { + return _errorResponse(404, error.message); + } + } +} + +Map _resourceJson(SyncedResource resource) { + return { + 'serverId': resource.serverId, + 'clientId': resource.clientId, + 'resourceType': resource.resourceType.wireName, + 'schemaVersion': resource.schemaVersion, + 'clientUpdatedAt': resource.clientUpdatedAt.toIso8601String(), + 'serverUpdatedAt': resource.serverUpdatedAt.toIso8601String(), + 'deletedAt': resource.deletedAt?.toIso8601String(), + 'payload': resource.payloadJson, + }; +} + +Future> _readJsonObject(Request request) async { + final raw = await request.readAsString(); + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw const FormatException('Request body must be a JSON object.'); + } + return decoded; +} + +String _requiredString(Map body, String key) { + final value = body[key]; + if (value is! String || value.trim().isEmpty) { + throw FormatException('$key must be a non-empty string.'); + } + return value; +} + +Map _requiredObject(Map body, String key) { + final value = body[key]; + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + throw FormatException('$key must be a JSON object.'); +} + +List _requiredStringList(Map body, String key) { + final value = body[key]; + if (value is! List) { + throw FormatException('$key must be an array.'); + } + return [ + for (final item in value) + if (item is String) + item + else + throw FormatException('$key must contain only strings.'), + ]; +} + +Response _jsonResponse(int statusCode, Map body) { + return Response( + statusCode, + body: jsonEncode(body), + headers: {'content-type': 'application/json'}, + ); +} + +Response _errorResponse(int statusCode, String message) { + return _jsonResponse(statusCode, {'error': message}); +} diff --git a/server/lib/application/README.md b/server/lib/application/README.md index bee15e4..ebbf6d4 100644 --- a/server/lib/application/README.md +++ b/server/lib/application/README.md @@ -2,5 +2,5 @@ Server use cases, ports and API DTOs independent from Shelf and PostgreSQL. -Authentication and sync use cases live here and depend only on repository / -security ports. Concrete adapters are wired from `bin/server.dart`. +Authentication, sync and targeted sharing use cases live here and depend only on +repository / security ports. Concrete adapters are wired from `bin/server.dart`. diff --git a/server/lib/application/application.dart b/server/lib/application/application.dart index c99ebd8..c2f6162 100644 --- a/server/lib/application/application.dart +++ b/server/lib/application/application.dart @@ -1,3 +1,4 @@ export 'ports.dart'; +export 'share_use_cases.dart'; export 'sync_use_cases.dart'; export 'use_cases.dart'; diff --git a/server/lib/application/ports.dart b/server/lib/application/ports.dart index 65825c7..6b1d62b 100644 --- a/server/lib/application/ports.dart +++ b/server/lib/application/ports.dart @@ -40,6 +40,40 @@ abstract interface class SyncedResourceRepository { }); } +final class ShareInboxItem { + const ShareInboxItem({required this.share, required this.recipient}); + + final Share share; + final ShareRecipient recipient; +} + +abstract interface class ShareRepository { + Future insertShare({ + required Share share, + required List recipients, + }); + + Future> listInbox(String recipientUserId); + + Future findShareById(String shareId); + + Future findRecipient({ + required String shareId, + required String recipientUserId, + }); + + Future updateRecipientStatus({ + required String recipientId, + required ShareRecipientStatus status, + required DateTime respondedAt, + }); + + Future revokeShare({ + required String shareId, + required DateTime revokedAt, + }); +} + abstract interface class PasswordHasher { Future hash(String password); diff --git a/server/lib/application/share_use_cases.dart b/server/lib/application/share_use_cases.dart new file mode 100644 index 0000000..b652e5e --- /dev/null +++ b/server/lib/application/share_use_cases.dart @@ -0,0 +1,219 @@ +import '../domain/domain.dart'; +import 'ports.dart'; + +final class CreateShareResult { + const CreateShareResult({ + required this.share, + required this.recipientUserIds, + required this.unresolvedEmails, + }); + + final Share share; + final List recipientUserIds; + final List unresolvedEmails; +} + +final class AcceptShareResult { + const AcceptShareResult({required this.createdResource}); + + final SyncedResource createdResource; +} + +final class CreateShareUseCase { + const CreateShareUseCase({ + required this.users, + required this.shares, + required this.clock, + required this.ids, + }); + + final UserRepository users; + final ShareRepository shares; + final Clock clock; + final IdGenerator ids; + + Future execute({ + required String senderUserId, + required String resourceType, + required Map payloadJson, + required List recipientEmails, + }) async { + final parsedType = _shareResourceType(resourceType); + final emails = _normalizedEmails(recipientEmails); + if (emails.isEmpty) { + throw const ValidationException('recipientEmails must not be empty.'); + } + + final recipientUserIds = []; + final unresolvedEmails = []; + for (final email in emails) { + final user = await users.findByEmail(email); + if (user == null) { + unresolvedEmails.add(email); + } else if (user.id != senderUserId && + !recipientUserIds.contains(user.id)) { + recipientUserIds.add(user.id); + } + } + + final now = clock.now().toUtc(); + final share = Share( + id: ids.newId(), + senderUserId: senderUserId, + resourceType: parsedType, + payloadJson: payloadJson, + createdAt: now, + ); + final recipients = [ + for (final recipientUserId in recipientUserIds) + ShareRecipient( + id: ids.newId(), + shareId: share.id, + recipientUserId: recipientUserId, + ), + ]; + await shares.insertShare(share: share, recipients: recipients); + return CreateShareResult( + share: share, + recipientUserIds: recipientUserIds, + unresolvedEmails: unresolvedEmails, + ); + } +} + +final class ListInboxUseCase { + const ListInboxUseCase({required this.shares}); + + final ShareRepository shares; + + Future> execute({required String recipientUserId}) { + return shares.listInbox(recipientUserId); + } +} + +final class AcceptShareUseCase { + const AcceptShareUseCase({ + required this.shares, + required this.resources, + required this.clock, + required this.ids, + }); + + final ShareRepository shares; + final SyncedResourceRepository resources; + final Clock clock; + final IdGenerator ids; + + Future execute({ + required String shareId, + required String recipientUserId, + }) async { + final share = await shares.findShareById(shareId); + final recipient = await shares.findRecipient( + shareId: shareId, + recipientUserId: recipientUserId, + ); + if (share == null || recipient == null) { + throw const ShareNotFoundException(); + } + if (share.isRevoked) { + throw const ShareConflictException('Share has been revoked.'); + } + if (recipient.status != ShareRecipientStatus.pending) { + throw const ShareConflictException('Share has already been answered.'); + } + + final now = clock.now().toUtc(); + final resource = SyncedResource( + serverId: ids.newId(), + ownerUserId: recipientUserId, + resourceType: share.resourceType, + clientId: ids.newId(), + payloadJson: share.payloadJson, + schemaVersion: _schemaVersionFromPayload(share.payloadJson), + clientUpdatedAt: now, + serverUpdatedAt: now, + ); + final written = await resources.upsertWithLww(resource); + await shares.updateRecipientStatus( + recipientId: recipient.id, + status: ShareRecipientStatus.accepted, + respondedAt: now, + ); + return AcceptShareResult(createdResource: written.resource); + } +} + +final class DeclineShareUseCase { + const DeclineShareUseCase({required this.shares, required this.clock}); + + final ShareRepository shares; + final Clock clock; + + Future execute({ + required String shareId, + required String recipientUserId, + }) async { + final share = await shares.findShareById(shareId); + final recipient = await shares.findRecipient( + shareId: shareId, + recipientUserId: recipientUserId, + ); + if (share == null || recipient == null) { + throw const ShareNotFoundException(); + } + if (recipient.status != ShareRecipientStatus.pending) { + throw const ShareConflictException('Share has already been answered.'); + } + await shares.updateRecipientStatus( + recipientId: recipient.id, + status: ShareRecipientStatus.declined, + respondedAt: clock.now().toUtc(), + ); + } +} + +final class RevokeShareUseCase { + const RevokeShareUseCase({required this.shares, required this.clock}); + + final ShareRepository shares; + final Clock clock; + + Future execute({ + required String shareId, + required String senderUserId, + }) async { + final share = await shares.findShareById(shareId); + if (share == null || share.senderUserId != senderUserId) { + throw const ShareNotFoundException(); + } + if (share.isRevoked) { + return; + } + await shares.revokeShare(shareId: shareId, revokedAt: clock.now().toUtc()); + } +} + +SyncedResourceType _shareResourceType(String value) { + final type = SyncedResourceType.parse(value); + if (type != SyncedResourceType.program && + type != SyncedResourceType.workoutTemplate) { + throw const ValidationException( + 'resourceType must be program or workoutTemplate.', + ); + } + return type; +} + +List _normalizedEmails(List emails) { + return emails + .map((email) => email.trim().toLowerCase()) + .where((email) => email.isNotEmpty) + .toSet() + .toList(growable: false); +} + +int _schemaVersionFromPayload(Map payloadJson) { + final schemaVersion = payloadJson['schemaVersion']; + return schemaVersion is int && schemaVersion > 0 ? schemaVersion : 1; +} diff --git a/server/lib/domain/README.md b/server/lib/domain/README.md index 9fc4ad4..930c5da 100644 --- a/server/lib/domain/README.md +++ b/server/lib/domain/README.md @@ -5,4 +5,5 @@ Pure server domain entities and invariants. This layer must not import Shelf, PostgreSQL adapters, Docker configuration or other infrastructure concerns. -Current entities: `UserAccount`, `AuthSession` and `SyncedResource`. +Current entities: `UserAccount`, `AuthSession`, `SyncedResource`, `Share` and +`ShareRecipient`. diff --git a/server/lib/domain/entities.dart b/server/lib/domain/entities.dart index f318d48..8ed3b6e 100644 --- a/server/lib/domain/entities.dart +++ b/server/lib/domain/entities.dart @@ -23,6 +23,14 @@ final class UnauthorizedException extends DomainException { const UnauthorizedException() : super('Unauthorized.'); } +final class ShareNotFoundException extends DomainException { + const ShareNotFoundException() : super('Share not found.'); +} + +final class ShareConflictException extends DomainException { + const ShareConflictException(super.message); +} + final class UserAccount { UserAccount({ required String id, @@ -145,6 +153,76 @@ final class SyncedResource { final String? originDeviceId; } +enum ShareRecipientStatus { + pending('pending'), + accepted('accepted'), + declined('declined'), + revoked('revoked'); + + const ShareRecipientStatus(this.wireName); + + final String wireName; + + static ShareRecipientStatus parse(String value) { + for (final status in values) { + if (status.wireName == value) { + return status; + } + } + throw ValidationException('Unsupported share recipient status: $value.'); + } +} + +final class Share { + Share({ + required String id, + required String senderUserId, + required this.resourceType, + required Map payloadJson, + required DateTime createdAt, + DateTime? revokedAt, + }) : id = _nonBlank(id, 'Share id'), + senderUserId = _nonBlank(senderUserId, 'Sender user id'), + payloadJson = Map.unmodifiable(payloadJson), + createdAt = createdAt.toUtc(), + revokedAt = revokedAt?.toUtc() { + if (resourceType != SyncedResourceType.program && + resourceType != SyncedResourceType.workoutTemplate) { + throw const ValidationException( + 'Shares only support program and workoutTemplate resources.', + ); + } + } + + final String id; + final String senderUserId; + final SyncedResourceType resourceType; + final Map payloadJson; + final DateTime createdAt; + final DateTime? revokedAt; + + bool get isRevoked => revokedAt != null; +} + +final class ShareRecipient { + ShareRecipient({ + required String id, + required String shareId, + required String recipientUserId, + this.status = ShareRecipientStatus.pending, + DateTime? respondedAt, + }) : id = _nonBlank(id, 'Share recipient id'), + shareId = _nonBlank(shareId, 'Share id'), + recipientUserId = _nonBlank(recipientUserId, 'Recipient user id'), + respondedAt = respondedAt?.toUtc(); + + final String id; + final String shareId; + final String recipientUserId; + final ShareRecipientStatus status; + final DateTime? respondedAt; +} + String _nonBlank(String value, String label) { final trimmed = value.trim(); if (trimmed.isEmpty) { diff --git a/server/lib/infrastructure/postgres/README.md b/server/lib/infrastructure/postgres/README.md index 95e8116..1f88519 100644 --- a/server/lib/infrastructure/postgres/README.md +++ b/server/lib/infrastructure/postgres/README.md @@ -2,5 +2,5 @@ Connection and migration utilities for server-side PostgreSQL. -Current adapters cover auth users/sessions and the generic `synced_resources` -sync table. +Current adapters cover auth users/sessions, the generic `synced_resources` sync +table, and targeted sharing through `shares` / `share_recipients`. diff --git a/server/lib/infrastructure/postgres/postgres.dart b/server/lib/infrastructure/postgres/postgres.dart index 3c9347b..0beb47e 100644 --- a/server/lib/infrastructure/postgres/postgres.dart +++ b/server/lib/infrastructure/postgres/postgres.dart @@ -1,3 +1,4 @@ export 'auth_repositories.dart'; export 'postgres_database.dart'; +export 'share_repository.dart'; export 'synced_resource_repository.dart'; diff --git a/server/lib/infrastructure/postgres/share_repository.dart b/server/lib/infrastructure/postgres/share_repository.dart new file mode 100644 index 0000000..ee0586a --- /dev/null +++ b/server/lib/infrastructure/postgres/share_repository.dart @@ -0,0 +1,237 @@ +import 'dart:convert'; + +import 'package:postgres/postgres.dart'; + +import '../../application/application.dart'; +import '../../domain/domain.dart'; + +final class PostgresShareRepository implements ShareRepository { + const PostgresShareRepository(this.connection); + + final Connection connection; + + @override + Future insertShare({ + required Share share, + required List recipients, + }) async { + await connection.execute( + Sql.named(''' + INSERT INTO shares ( + id, sender_user_id, resource_type, payload_json, created_at, + revoked_at + ) + VALUES ( + @id::uuid, @sender_user_id::uuid, @resource_type, + @payload_json::jsonb, @created_at, @revoked_at + ) + '''), + parameters: { + 'id': share.id, + 'sender_user_id': share.senderUserId, + 'resource_type': share.resourceType.wireName, + 'payload_json': jsonEncode(share.payloadJson), + 'created_at': share.createdAt, + 'revoked_at': share.revokedAt, + }, + ); + + for (final recipient in recipients) { + await connection.execute( + Sql.named(''' + INSERT INTO share_recipients ( + id, share_id, recipient_user_id, status, responded_at + ) + VALUES ( + @id::uuid, @share_id::uuid, @recipient_user_id::uuid, @status, + @responded_at + ) + ON CONFLICT (share_id, recipient_user_id) DO NOTHING + '''), + parameters: { + 'id': recipient.id, + 'share_id': recipient.shareId, + 'recipient_user_id': recipient.recipientUserId, + 'status': recipient.status.wireName, + 'responded_at': recipient.respondedAt, + }, + ); + } + } + + @override + Future> listInbox(String recipientUserId) async { + final result = await connection.execute( + Sql.named(''' + SELECT + s.id AS share_id, + s.sender_user_id, + s.resource_type, + s.payload_json, + s.created_at, + s.revoked_at, + sr.id AS recipient_id, + sr.recipient_user_id, + sr.status, + sr.responded_at + FROM share_recipients sr + INNER JOIN shares s ON s.id = sr.share_id + WHERE sr.recipient_user_id = @recipient_user_id::uuid + ORDER BY s.created_at DESC, s.id DESC + '''), + parameters: {'recipient_user_id': recipientUserId}, + ); + return [ + for (final row in result) + _inboxItemFromValues(row.toColumnMap() as Map), + ]; + } + + @override + Future findShareById(String shareId) async { + final result = await connection.execute( + Sql.named(''' + SELECT id, sender_user_id, resource_type, payload_json, created_at, + revoked_at + FROM shares + WHERE id = @id::uuid + LIMIT 1 + '''), + parameters: {'id': shareId}, + ); + return result.isEmpty + ? null + : _shareFromValues(result.single.toColumnMap() as Map); + } + + @override + Future findRecipient({ + required String shareId, + required String recipientUserId, + }) async { + final result = await connection.execute( + Sql.named(''' + SELECT id, share_id, recipient_user_id, status, responded_at + FROM share_recipients + WHERE share_id = @share_id::uuid + AND recipient_user_id = @recipient_user_id::uuid + LIMIT 1 + '''), + parameters: {'share_id': shareId, 'recipient_user_id': recipientUserId}, + ); + return result.isEmpty + ? null + : _recipientFromValues( + result.single.toColumnMap() as Map, + ); + } + + @override + Future updateRecipientStatus({ + required String recipientId, + required ShareRecipientStatus status, + required DateTime respondedAt, + }) async { + await connection.execute( + Sql.named(''' + UPDATE share_recipients + SET status = @status, responded_at = @responded_at + WHERE id = @id::uuid + '''), + parameters: { + 'id': recipientId, + 'status': status.wireName, + 'responded_at': respondedAt.toUtc(), + }, + ); + } + + @override + Future revokeShare({ + required String shareId, + required DateTime revokedAt, + }) async { + await connection.execute( + Sql.named(''' + UPDATE shares + SET revoked_at = @revoked_at + WHERE id = @id::uuid AND revoked_at IS NULL + '''), + parameters: {'id': shareId, 'revoked_at': revokedAt.toUtc()}, + ); + } +} + +ShareInboxItem _inboxItemFromValues(Map values) { + return ShareInboxItem( + share: _shareFromValues(values), + recipient: _recipientFromValues(values), + ); +} + +Share _shareFromValues(Map values) { + return Share( + id: _stringValue(values['share_id'] ?? values['id']), + senderUserId: _stringValue(values['sender_user_id']), + resourceType: SyncedResourceType.parse( + _stringValue(values['resource_type']), + ), + payloadJson: _payloadValue(values['payload_json']), + createdAt: _dateTimeValue(values['created_at']), + revokedAt: _nullableDateTimeValue(values['revoked_at']), + ); +} + +ShareRecipient _recipientFromValues(Map values) { + return ShareRecipient( + id: _stringValue(values['recipient_id'] ?? values['id']), + shareId: _stringValue(values['share_id']), + recipientUserId: _stringValue(values['recipient_user_id']), + status: ShareRecipientStatus.parse(_stringValue(values['status'])), + respondedAt: _nullableDateTimeValue(values['responded_at']), + ); +} + +Map _payloadValue(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + if (value is String) { + final decoded = jsonDecode(value); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return Map.from(decoded); + } + } + throw const FormatException('Expected JSON object payload.'); +} + +String _stringValue(Object? value) { + if (value == null) { + throw const FormatException('Expected non-null string value.'); + } + return value.toString(); +} + +DateTime _dateTimeValue(Object? value) { + final result = _nullableDateTimeValue(value); + if (result == null) { + throw const FormatException('Expected non-null DateTime value.'); + } + return result; +} + +DateTime? _nullableDateTimeValue(Object? value) { + if (value == null) { + return null; + } + if (value is DateTime) { + return value.toUtc(); + } + return DateTime.parse(value.toString()).toUtc(); +} diff --git a/server/test/share_api_test.dart b/server/test/share_api_test.dart new file mode 100644 index 0000000..b400758 --- /dev/null +++ b/server/test/share_api_test.dart @@ -0,0 +1,205 @@ +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); + } + }); +} + +ShareApi _shareApi() { + final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12)); + final ids = _FakeIds(); + final users = _FakeUserRepository( + UserAccount( + id: 'user-1', + email: 'user@example.com', + passwordHash: 'hash', + createdAt: clock.now(), + updatedAt: clock.now(), + ), + ); + 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: users, + sessions: sessions, + tokens: tokens, + clock: clock, + ); + final shares = _FakeShareRepository(); + final resources = _FakeSyncedResourceRepository(); + return ShareApi( + createShare: CreateShareUseCase( + users: users, + shares: shares, + clock: clock, + ids: ids, + ), + listInbox: ListInboxUseCase(shares: shares), + acceptShare: AcceptShareUseCase( + shares: shares, + resources: resources, + clock: clock, + ids: ids, + ), + declineShare: DeclineShareUseCase(shares: shares, clock: clock), + revokeShare: RevokeShareUseCase(shares: shares, clock: clock), + authenticateRequest: authenticate, + ); +} + +final class _FakeUserRepository implements UserRepository { + const _FakeUserRepository(this.user); + + final UserAccount user; + + @override + Future findByEmail(String email) async { + return user.email == email.toLowerCase() ? user : null; + } + + @override + Future findById(String id) async { + return id == user.id ? user : null; + } + + @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 { + @override + Future insertShare({ + required Share share, + required List recipients, + }) async {} + + @override + Future> listInbox(String recipientUserId) async { + return const []; + } + + @override + Future findShareById(String shareId) async { + return null; + } + + @override + Future findRecipient({ + required String shareId, + required String recipientUserId, + }) async { + return null; + } + + @override + Future updateRecipientStatus({ + required String recipientId, + required ShareRecipientStatus status, + required DateTime respondedAt, + }) async {} + + @override + Future revokeShare({ + required String shareId, + required DateTime revokedAt, + }) async {} +} + +final class _FakeSyncedResourceRepository implements SyncedResourceRepository { + @override + Future upsertWithLww(SyncedResource resource) async { + return SyncWriteResult( + status: SyncWriteStatus.accepted, + resource: resource, + ); + } + + @override + Future> findAllForUserSince({ + required String ownerUserId, + DateTime? since, + }) async { + return const []; + } +} + +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'; + } +} diff --git a/server/test/share_use_cases_test.dart b/server/test/share_use_cases_test.dart new file mode 100644 index 0000000..bdbaf1c --- /dev/null +++ b/server/test/share_use_cases_test.dart @@ -0,0 +1,472 @@ +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'; + } +}