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:
2026-07-19 10:34:09 +02:00
parent c232bdcec1
commit db6e84be06
15 changed files with 1531 additions and 13 deletions

View File

@ -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.

View File

@ -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,
);

View File

@ -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);
}

View 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});
}

View File

@ -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`.

View File

@ -1,3 +1,4 @@
export 'ports.dart';
export 'share_use_cases.dart';
export 'sync_use_cases.dart';
export 'use_cases.dart';

View File

@ -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);

View 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;
}

View File

@ -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`.

View File

@ -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) {

View File

@ -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`.

View File

@ -1,3 +1,4 @@
export 'auth_repositories.dart';
export 'postgres_database.dart';
export 'share_repository.dart';
export 'synced_resource_repository.dart';

View 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();
}

View 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';
}
}

View 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';
}
}