feat(server): partage ciblé de programmes et séances entre comptes (ticket #51)
Ajoute l'endpoint api/share_api.dart, les use cases de partage (application/share_use_cases.dart) et l'adapter Postgres (infrastructure/postgres/share_repository.dart). L'acceptation d'un partage crée une nouvelle ressource synced_resources avec de nouveaux IDs pour le destinataire, sans jamais modifier la ressource source de l'émetteur ; gestion des conflits révoqué/déjà répondu. dart pub get OK, 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:
@ -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 <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
|
||||
|
||||
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.
|
||||
|
||||
@ -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<void> main(List<String> 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<void> main(List<String> 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,
|
||||
);
|
||||
|
||||
@ -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/<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);
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export 'ports.dart';
|
||||
export 'share_use_cases.dart';
|
||||
export 'sync_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 {
|
||||
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
|
||||
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.');
|
||||
}
|
||||
|
||||
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<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) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export 'auth_repositories.dart';
|
||||
export 'postgres_database.dart';
|
||||
export 'share_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