merge(main): partage ciblé de programmes et séances entre comptes (ticket #51)
Fusionne feature/server-51-sharing — dart analyze clean, dart test 24/24 vert. Use case d'acceptation relu manuellement et jugé correct ; pas de test de bout en bout contre un vrai PostgreSQL faute d'accès Docker dans ce sandbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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
|
||||||
|
---
|
||||||
@ -9,6 +9,15 @@
|
|||||||
"status": "planned",
|
"status": "planned",
|
||||||
"updatedAt": 1784329015766,
|
"updatedAt": 1784329015766,
|
||||||
"version": 1
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
issueRef: "#50"
|
issueRef: "#50"
|
||||||
version: 1
|
version: 3
|
||||||
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
|
||||||
updatedAt: 1784412001974
|
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.
|
||||||
@ -2,15 +2,15 @@
|
|||||||
id: "983aa02b-7577-4fde-b77a-12355df0efdb"
|
id: "983aa02b-7577-4fde-b77a-12355df0efdb"
|
||||||
number: 50
|
number: 50
|
||||||
title: "[Server] API de synchronisation incrémentale LWW"
|
title: "[Server] API de synchronisation incrémentale LWW"
|
||||||
status: "open"
|
status: "qa"
|
||||||
priority: "low"
|
priority: "low"
|
||||||
sprint: null
|
sprint: null
|
||||||
links: [{"target":"#46","kind":"relatesTo"},{"target":"#48","kind":"dependsOn"},{"target":"#49","kind":"dependsOn"}]
|
links: [{"target":"#46","kind":"relatesTo"},{"target":"#48","kind":"dependsOn"},{"target":"#49","kind":"dependsOn"}]
|
||||||
agentRefs: []
|
agentRefs: []
|
||||||
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
|
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
|
createdAt: 1784412001974
|
||||||
updatedAt: 1784412001974
|
updatedAt: 1784449447058
|
||||||
version: 1
|
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.
|
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.
|
||||||
6
.ideai/tickets/54/carnet.md
Normal file
6
.ideai/tickets/54/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#54"
|
||||||
|
version: 2
|
||||||
|
updatedBy: {"kind":"user"}
|
||||||
|
updatedAt: 1784449895057
|
||||||
|
---
|
||||||
15
.ideai/tickets/54/issue.md
Normal file
15
.ideai/tickets/54/issue.md
Normal file
@ -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
|
||||||
|
---
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 54
|
"nextNumber": 55
|
||||||
}
|
}
|
||||||
@ -567,11 +567,11 @@
|
|||||||
"issueRef": "#50",
|
"issueRef": "#50",
|
||||||
"path": "50",
|
"path": "50",
|
||||||
"title": "[Server] API de synchronisation incrémentale LWW",
|
"title": "[Server] API de synchronisation incrémentale LWW",
|
||||||
"status": "open",
|
"status": "qa",
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784412001974
|
"updatedAt": 1784449447058
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"issueRef": "#51",
|
"issueRef": "#51",
|
||||||
@ -602,6 +602,16 @@
|
|||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
"updatedAt": 1784412015509
|
"updatedAt": 1784412015509
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"issueRef": "#54",
|
||||||
|
"path": "54",
|
||||||
|
"title": "Exercice à plusieurs étapes",
|
||||||
|
"status": "open",
|
||||||
|
"priority": "medium",
|
||||||
|
"sprint": "1bb8bdf2-9c35-4f53-9a31-1390a47bec63",
|
||||||
|
"assignedAgentIds": [],
|
||||||
|
"updatedAt": 1784449895057
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -104,18 +104,34 @@ server clock if no resource is returned.
|
|||||||
`pushResults` included so per-item validation errors remain visible to the
|
`pushResults` included so per-item validation errors remain visible to the
|
||||||
client.
|
client.
|
||||||
|
|
||||||
|
## Sharing
|
||||||
|
|
||||||
|
Ticket #51 adds authenticated targeted sharing for programs and workout
|
||||||
|
templates.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- `POST /shares` with `Authorization: Bearer <token>`.
|
||||||
|
- `GET /shares/inbox` with `Authorization: Bearer <token>`.
|
||||||
|
- `POST /shares/{id}/accept` with `Authorization: Bearer <token>`.
|
||||||
|
- `POST /shares/{id}/decline` with `Authorization: Bearer <token>`.
|
||||||
|
- `POST /shares/{id}/revoke` with `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
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
|
## Scope
|
||||||
|
|
||||||
Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and
|
Ticket #47 only scaffolds the Dart server, the hexagonal directory layout and
|
||||||
the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema. Ticket #48
|
the `/health` endpoint. Ticket #49 adds the first PostgreSQL schema. Ticket #48
|
||||||
adds authentication. Ticket #50 adds sync only; sharing behavior is still
|
adds authentication. Ticket #50 adds sync. Ticket #51 adds targeted sharing.
|
||||||
implemented in a later ticket.
|
|
||||||
|
|
||||||
Upcoming tickets will fill the empty adapters and use cases:
|
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.
|
- #52: Docker and registry packaging.
|
||||||
- #53: API, contract and integration tests.
|
- #53: API, contract and integration tests.
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:gametime_server/api/auth_api.dart';
|
import 'package:gametime_server/api/auth_api.dart';
|
||||||
import 'package:gametime_server/api/router.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/api/sync_api.dart';
|
||||||
import 'package:gametime_server/application/application.dart';
|
import 'package:gametime_server/application/application.dart';
|
||||||
import 'package:gametime_server/infrastructure/postgres/postgres.dart';
|
import 'package:gametime_server/infrastructure/postgres/postgres.dart';
|
||||||
@ -17,6 +18,7 @@ Future<void> main(List<String> arguments) async {
|
|||||||
final users = PostgresUserRepository(connection);
|
final users = PostgresUserRepository(connection);
|
||||||
final sessions = PostgresAuthSessionRepository(connection);
|
final sessions = PostgresAuthSessionRepository(connection);
|
||||||
final resources = PostgresSyncedResourceRepository(connection);
|
final resources = PostgresSyncedResourceRepository(connection);
|
||||||
|
final shares = PostgresShareRepository(connection);
|
||||||
final passwordHasher = Pbkdf2PasswordHasher();
|
final passwordHasher = Pbkdf2PasswordHasher();
|
||||||
final tokens = SecureOpaqueTokenService();
|
final tokens = SecureOpaqueTokenService();
|
||||||
const clock = SystemClock();
|
const clock = SystemClock();
|
||||||
@ -57,9 +59,27 @@ Future<void> main(List<String> arguments) async {
|
|||||||
exchangeSync: ExchangeSyncUseCase(push: pushSync, pull: pullSync),
|
exchangeSync: ExchangeSyncUseCase(push: pushSync, pull: pullSync),
|
||||||
authenticateRequest: authenticateRequest,
|
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(
|
final server = await shelf_io.serve(
|
||||||
buildApiHandler(authApi: authApi, syncApi: syncApi),
|
buildApiHandler(authApi: authApi, syncApi: syncApi, shareApi: shareApi),
|
||||||
InternetAddress.anyIPv4,
|
InternetAddress.anyIPv4,
|
||||||
port,
|
port,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,9 +4,14 @@ import 'package:shelf/shelf.dart';
|
|||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
import 'auth_api.dart';
|
import 'auth_api.dart';
|
||||||
|
import 'share_api.dart';
|
||||||
import 'sync_api.dart';
|
import 'sync_api.dart';
|
||||||
|
|
||||||
Handler buildApiHandler({AuthApi? authApi, SyncApi? syncApi}) {
|
Handler buildApiHandler({
|
||||||
|
AuthApi? authApi,
|
||||||
|
SyncApi? syncApi,
|
||||||
|
ShareApi? shareApi,
|
||||||
|
}) {
|
||||||
final router = Router()
|
final router = Router()
|
||||||
..get('/health', (Request request) {
|
..get('/health', (Request request) {
|
||||||
return Response.ok(
|
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/<id>/accept',
|
||||||
|
authenticationMiddleware(shareApi.authenticateRequest.execute)(
|
||||||
|
(request) => shareApi.accept(request, request.params['id']!),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..post(
|
||||||
|
'/shares/<id>/decline',
|
||||||
|
authenticationMiddleware(shareApi.authenticateRequest.execute)(
|
||||||
|
(request) => shareApi.decline(request, request.params['id']!),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..post(
|
||||||
|
'/shares/<id>/revoke',
|
||||||
|
authenticationMiddleware(shareApi.authenticateRequest.execute)(
|
||||||
|
(request) => shareApi.revoke(request, request.params['id']!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
|
return const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
|
||||||
}
|
}
|
||||||
|
|||||||
195
server/lib/api/share_api.dart
Normal file
195
server/lib/api/share_api.dart
Normal file
@ -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<Response> 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<Response> 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<Response> 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<Response> 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<Response> 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<String, Object?> _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<Map<String, Object?>> _readJsonObject(Request request) async {
|
||||||
|
final raw = await request.readAsString();
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map<String, Object?>) {
|
||||||
|
throw const FormatException('Request body must be a JSON object.');
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _requiredString(Map<String, Object?> 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<String, Object?> _requiredObject(Map<String, Object?> body, String key) {
|
||||||
|
final value = body[key];
|
||||||
|
if (value is Map<String, Object?>) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is Map) {
|
||||||
|
return Map<String, Object?>.from(value);
|
||||||
|
}
|
||||||
|
throw FormatException('$key must be a JSON object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _requiredStringList(Map<String, Object?> 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<String, Object?> body) {
|
||||||
|
return Response(
|
||||||
|
statusCode,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response _errorResponse(int statusCode, String message) {
|
||||||
|
return _jsonResponse(statusCode, {'error': message});
|
||||||
|
}
|
||||||
@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
Server use cases, ports and API DTOs independent from Shelf and PostgreSQL.
|
Server use cases, ports and API DTOs independent from Shelf and PostgreSQL.
|
||||||
|
|
||||||
Authentication and sync use cases live here and depend only on repository /
|
Authentication, sync and targeted sharing use cases live here and depend only on
|
||||||
security ports. Concrete adapters are wired from `bin/server.dart`.
|
repository / security ports. Concrete adapters are wired from `bin/server.dart`.
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export 'ports.dart';
|
export 'ports.dart';
|
||||||
|
export 'share_use_cases.dart';
|
||||||
export 'sync_use_cases.dart';
|
export 'sync_use_cases.dart';
|
||||||
export 'use_cases.dart';
|
export 'use_cases.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<void> insertShare({
|
||||||
|
required Share share,
|
||||||
|
required List<ShareRecipient> recipients,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<ShareInboxItem>> listInbox(String recipientUserId);
|
||||||
|
|
||||||
|
Future<Share?> findShareById(String shareId);
|
||||||
|
|
||||||
|
Future<ShareRecipient?> findRecipient({
|
||||||
|
required String shareId,
|
||||||
|
required String recipientUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> updateRecipientStatus({
|
||||||
|
required String recipientId,
|
||||||
|
required ShareRecipientStatus status,
|
||||||
|
required DateTime respondedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> revokeShare({
|
||||||
|
required String shareId,
|
||||||
|
required DateTime revokedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
abstract interface class PasswordHasher {
|
abstract interface class PasswordHasher {
|
||||||
Future<String> hash(String password);
|
Future<String> hash(String password);
|
||||||
|
|
||||||
|
|||||||
219
server/lib/application/share_use_cases.dart
Normal file
219
server/lib/application/share_use_cases.dart
Normal file
@ -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<String> recipientUserIds;
|
||||||
|
final List<String> 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<CreateShareResult> execute({
|
||||||
|
required String senderUserId,
|
||||||
|
required String resourceType,
|
||||||
|
required Map<String, Object?> payloadJson,
|
||||||
|
required List<String> recipientEmails,
|
||||||
|
}) async {
|
||||||
|
final parsedType = _shareResourceType(resourceType);
|
||||||
|
final emails = _normalizedEmails(recipientEmails);
|
||||||
|
if (emails.isEmpty) {
|
||||||
|
throw const ValidationException('recipientEmails must not be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final recipientUserIds = <String>[];
|
||||||
|
final unresolvedEmails = <String>[];
|
||||||
|
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<List<ShareInboxItem>> 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<AcceptShareResult> 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<void> 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<void> 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<String> _normalizedEmails(List<String> emails) {
|
||||||
|
return emails
|
||||||
|
.map((email) => email.trim().toLowerCase())
|
||||||
|
.where((email) => email.isNotEmpty)
|
||||||
|
.toSet()
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _schemaVersionFromPayload(Map<String, Object?> payloadJson) {
|
||||||
|
final schemaVersion = payloadJson['schemaVersion'];
|
||||||
|
return schemaVersion is int && schemaVersion > 0 ? schemaVersion : 1;
|
||||||
|
}
|
||||||
@ -5,4 +5,5 @@ Pure server domain entities and invariants.
|
|||||||
This layer must not import Shelf, PostgreSQL adapters, Docker configuration or
|
This layer must not import Shelf, PostgreSQL adapters, Docker configuration or
|
||||||
other infrastructure concerns.
|
other infrastructure concerns.
|
||||||
|
|
||||||
Current entities: `UserAccount`, `AuthSession` and `SyncedResource`.
|
Current entities: `UserAccount`, `AuthSession`, `SyncedResource`, `Share` and
|
||||||
|
`ShareRecipient`.
|
||||||
|
|||||||
@ -23,6 +23,14 @@ final class UnauthorizedException extends DomainException {
|
|||||||
const UnauthorizedException() : super('Unauthorized.');
|
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 {
|
final class UserAccount {
|
||||||
UserAccount({
|
UserAccount({
|
||||||
required String id,
|
required String id,
|
||||||
@ -145,6 +153,76 @@ final class SyncedResource {
|
|||||||
final String? originDeviceId;
|
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<String, Object?> 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<String, Object?> 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) {
|
String _nonBlank(String value, String label) {
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
if (trimmed.isEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
|
|||||||
@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
Connection and migration utilities for server-side PostgreSQL.
|
Connection and migration utilities for server-side PostgreSQL.
|
||||||
|
|
||||||
Current adapters cover auth users/sessions and the generic `synced_resources`
|
Current adapters cover auth users/sessions, the generic `synced_resources` sync
|
||||||
sync table.
|
table, and targeted sharing through `shares` / `share_recipients`.
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export 'auth_repositories.dart';
|
export 'auth_repositories.dart';
|
||||||
export 'postgres_database.dart';
|
export 'postgres_database.dart';
|
||||||
|
export 'share_repository.dart';
|
||||||
export 'synced_resource_repository.dart';
|
export 'synced_resource_repository.dart';
|
||||||
|
|||||||
237
server/lib/infrastructure/postgres/share_repository.dart
Normal file
237
server/lib/infrastructure/postgres/share_repository.dart
Normal file
@ -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<void> insertShare({
|
||||||
|
required Share share,
|
||||||
|
required List<ShareRecipient> 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<List<ShareInboxItem>> 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<String, Object?>),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Share?> 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<String, Object?>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ShareRecipient?> 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<String, Object?>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<void> 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<String, Object?> values) {
|
||||||
|
return ShareInboxItem(
|
||||||
|
share: _shareFromValues(values),
|
||||||
|
recipient: _recipientFromValues(values),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Share _shareFromValues(Map<String, Object?> 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<String, Object?> 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<String, Object?> _payloadValue(Object? value) {
|
||||||
|
if (value is Map<String, Object?>) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is Map) {
|
||||||
|
return Map<String, Object?>.from(value);
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
final decoded = jsonDecode(value);
|
||||||
|
if (decoded is Map<String, Object?>) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, Object?>.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();
|
||||||
|
}
|
||||||
205
server/test/share_api_test.dart
Normal file
205
server/test/share_api_test.dart
Normal file
@ -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<UserAccount?> findByEmail(String email) async {
|
||||||
|
return user.email == email.toLowerCase() ? user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UserAccount?> findById(String id) async {
|
||||||
|
return id == user.id ? user : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> insert(UserAccount user) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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<AuthSession?> findByTokenHash(String tokenHash) async {
|
||||||
|
return tokenHash == session.tokenHash ? session : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> insert(AuthSession session) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> revoke({
|
||||||
|
required String sessionId,
|
||||||
|
required DateTime revokedAt,
|
||||||
|
}) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeShareRepository implements ShareRepository {
|
||||||
|
@override
|
||||||
|
Future<void> insertShare({
|
||||||
|
required Share share,
|
||||||
|
required List<ShareRecipient> recipients,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Share?> findShareById(String shareId) async {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ShareRecipient?> findRecipient({
|
||||||
|
required String shareId,
|
||||||
|
required String recipientUserId,
|
||||||
|
}) async {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateRecipientStatus({
|
||||||
|
required String recipientId,
|
||||||
|
required ShareRecipientStatus status,
|
||||||
|
required DateTime respondedAt,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> revokeShare({
|
||||||
|
required String shareId,
|
||||||
|
required DateTime revokedAt,
|
||||||
|
}) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
||||||
|
@override
|
||||||
|
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||||
|
return SyncWriteResult(
|
||||||
|
status: SyncWriteStatus.accepted,
|
||||||
|
resource: resource,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SyncedResource>> 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
472
server/test/share_use_cases_test.dart
Normal file
472
server/test/share_use_cases_test.dart
Normal file
@ -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<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,
|
||||||
|
resources: resources,
|
||||||
|
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',
|
||||||
|
SyncedResourceType resourceType = SyncedResourceType.program,
|
||||||
|
Map<String, Object?> 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<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<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,
|
||||||
|
resourceType: share.resourceType,
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user