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

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