Files
GameTime/server/test/share_api_test.dart
Blomios 917777e18b chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)
Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:48:54 +02:00

710 lines
20 KiB
Dart

import 'dart:convert';
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);
}
});
test(
'create share returns share id, resolved recipients and unresolved emails',
() async {
final shares = _FakeShareRepository();
final users = _FakeUserRepository([
_user('user-1', 'user@example.com'),
_user('user-2', 'friend@example.com'),
]);
final handler = buildApiHandler(
shareApi: _shareApi(users: users, shares: shares),
);
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({
'resourceType': 'program',
'payload': {'schemaVersion': 2, 'name': 'Programme été'},
'recipientEmails': [
' friend@example.com ',
'missing@example.com',
'user@example.com',
],
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
expect(response.statusCode, 201);
expect(body['shareId'], 'id-1');
expect(body['shareKind'], 'single');
expect(body['recipientUserIds'], ['user-2']);
expect(body['unresolvedEmails'], ['missing@example.com']);
expect(
shares.shareById['id-1']?.resourceType,
SyncedResourceType.program,
);
},
);
test('create pack share returns share id and stores pack metadata', () async {
final shares = _FakeShareRepository();
final users = _FakeUserRepository([
_user('user-1', 'user@example.com'),
_user('user-2', 'friend@example.com'),
]);
final handler = buildApiHandler(
shareApi: _shareApi(users: users, shares: shares),
);
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({
'shareKind': 'pack',
'packName': 'Pack été',
'items': [
{
'resourceType': 'program',
'payload': {'schemaVersion': 2, 'name': 'Programme'},
},
{
'resourceType': 'workoutTemplate',
'payload': {'schemaVersion': 3, 'name': 'Séance'},
},
],
'recipientEmails': ['friend@example.com'],
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final share = shares.shareById['id-1'];
expect(response.statusCode, 201);
expect(body['shareKind'], 'pack');
expect(share?.kind, ShareKind.pack);
expect(share?.packName, 'Pack été');
expect(share?.resourceType, isNull);
expect(share?.payloadJson['items'], hasLength(2));
});
test('create share returns 400 for invalid resource type', () async {
final handler = buildApiHandler(shareApi: _shareApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({
'resourceType': 'exercise',
'payload': {'name': 'Squat'},
'recipientEmails': ['friend@example.com'],
}),
),
);
expect(response.statusCode, 400);
expect(jsonDecode(await response.readAsString()), {
'error': 'resourceType must be program or workoutTemplate.',
});
});
test(
'inbox returns serialized shares for the authenticated recipient',
() async {
final shares = _FakeShareRepository()
..seedInbox(
recipientUserId: 'user-1',
items: [
ShareInboxItem(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipient: ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
),
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'GET',
Uri.parse('http://localhost/shares/inbox'),
headers: {'authorization': 'Bearer valid-token'},
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final items = body['items'] as List;
expect(response.statusCode, 200);
expect(items, hasLength(1));
expect((items.single as Map)['shareId'], 'share-1');
expect((items.single as Map)['shareKind'], 'single');
expect((items.single as Map)['resourceType'], 'program');
expect((items.single as Map)['status'], 'pending');
},
);
test('accept share returns the copied synced resource', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.workoutTemplate,
payloadJson: {'schemaVersion': 3, 'name': 'Template A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: [
ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/accept'),
headers: {'authorization': 'Bearer valid-token'},
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final created = body['createdResource'] as Map;
expect(response.statusCode, 200);
expect(created['resourceType'], 'workoutTemplate');
expect(created['payload'], {'schemaVersion': 3, 'name': 'Template A'});
expect(body['createdResources'], hasLength(1));
expect(
shares.recipientById['recipient-1']?.status,
ShareRecipientStatus.accepted,
);
expect(shares.acceptedResources.single.ownerUserId, 'user-1');
});
test('accept pack share returns all copied synced resources', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
kind: ShareKind.pack,
resourceType: null,
packName: 'Pack été',
payloadJson: {
'items': [
{
'resourceType': 'program',
'payload': {'schemaVersion': 2, 'name': 'Programme'},
},
{
'resourceType': 'workoutTemplate',
'payload': {'schemaVersion': 3, 'name': 'Séance'},
},
],
},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: [
ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/accept'),
headers: {'authorization': 'Bearer valid-token'},
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final created = body['createdResources'] as List;
expect(response.statusCode, 200);
expect(body.containsKey('createdResource'), isFalse);
expect(created.map((item) => (item as Map)['resourceType']), [
'program',
'workoutTemplate',
]);
expect(
shares.recipientById['recipient-1']?.status,
ShareRecipientStatus.accepted,
);
});
test('accept share returns 404 when share is missing', () async {
final handler = buildApiHandler(shareApi: _shareApi());
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/missing/accept'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 404);
expect(jsonDecode(await response.readAsString()), {
'error': 'Share not found.',
});
});
test('accept share returns 409 when share was already answered', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: [
ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
status: ShareRecipientStatus.accepted,
respondedAt: DateTime.utc(2026, 7, 19, 11),
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/accept'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 409);
expect(jsonDecode(await response.readAsString()), {
'error': 'Share has already been answered.',
});
});
test('decline share returns 204 and updates recipient status', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: [
ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/decline'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 204);
expect(
shares.recipientById['recipient-1']?.status,
ShareRecipientStatus.declined,
);
});
test('decline share returns 409 when share was already answered', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'sender-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: [
ShareRecipient(
id: 'recipient-1',
shareId: 'share-1',
recipientUserId: 'user-1',
status: ShareRecipientStatus.declined,
respondedAt: DateTime.utc(2026, 7, 19, 11),
),
],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/decline'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 409);
expect(jsonDecode(await response.readAsString()), {
'error': 'Share has already been answered.',
});
});
test('revoke share returns 204 and marks share as revoked', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'user-1',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: const [],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/revoke'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 204);
expect(shares.shareById['share-1']?.isRevoked, isTrue);
});
test('revoke share returns 404 when requester is not the sender', () async {
final shares = _FakeShareRepository()
..seedShare(
share: Share(
id: 'share-1',
senderUserId: 'other-user',
resourceType: SyncedResourceType.program,
payloadJson: {'name': 'Programme A'},
createdAt: DateTime.utc(2026, 7, 19, 10),
),
recipients: const [],
);
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/shares/share-1/revoke'),
headers: {'authorization': 'Bearer valid-token'},
),
);
expect(response.statusCode, 404);
expect(jsonDecode(await response.readAsString()), {
'error': 'Share not found.',
});
});
}
ShareApi _shareApi({_FakeUserRepository? users, _FakeShareRepository? shares}) {
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
final ids = _FakeIds();
final userRepository =
users ?? _FakeUserRepository([_user('user-1', 'user@example.com')]);
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: userRepository,
sessions: sessions,
tokens: tokens,
clock: clock,
);
final shareRepository = shares ?? _FakeShareRepository();
return ShareApi(
createShare: CreateShareUseCase(
users: userRepository,
shares: shareRepository,
clock: clock,
ids: ids,
),
listInbox: ListInboxUseCase(shares: shareRepository),
acceptShare: AcceptShareUseCase(
shares: shareRepository,
clock: clock,
ids: ids,
),
declineShare: DeclineShareUseCase(shares: shareRepository, clock: clock),
revokeShare: RevokeShareUseCase(shares: shareRepository, clock: clock),
authenticateRequest: authenticate,
);
}
final class _FakeUserRepository implements UserRepository {
_FakeUserRepository(List<UserAccount> users)
: _byId = {for (final user in users) user.id: user},
_byEmail = {for (final user in users) user.email: user};
final Map<String, UserAccount> _byId;
final Map<String, UserAccount> _byEmail;
@override
Future<UserAccount?> findByEmail(String email) async {
return _byEmail[email.trim().toLowerCase()];
}
@override
Future<UserAccount?> findById(String id) async {
return _byId[id];
}
@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 {
final shareById = <String, Share>{};
final recipientById = <String, ShareRecipient>{};
final recipientByKey = <String, ShareRecipient>{};
final inboxByRecipient = <String, List<ShareInboxItem>>{};
final acceptedResources = <SyncedResource>[];
void seedShare({
required Share share,
required List<ShareRecipient> recipients,
}) {
shareById[share.id] = share;
for (final recipient in recipients) {
recipientById[recipient.id] = recipient;
recipientByKey['${recipient.shareId}:${recipient.recipientUserId}'] =
recipient;
}
}
void seedInbox({
required String recipientUserId,
required List<ShareInboxItem> items,
}) {
inboxByRecipient[recipientUserId] = items;
for (final item in items) {
seedShare(share: item.share, recipients: [item.recipient]);
}
}
@override
Future<void> insertShare({
required Share share,
required List<ShareRecipient> recipients,
}) async {
seedShare(share: share, recipients: recipients);
}
@override
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
return inboxByRecipient[recipientUserId] ?? const [];
}
@override
Future<Share?> findShareById(String shareId) async {
return shareById[shareId];
}
@override
Future<ShareRecipient?> findRecipient({
required String shareId,
required String recipientUserId,
}) async {
return recipientByKey['$shareId:$recipientUserId'];
}
@override
Future<void> updateRecipientStatus({
required String recipientId,
required ShareRecipientStatus status,
required DateTime respondedAt,
}) async {
final existing = recipientById[recipientId];
if (existing == null) {
return;
}
final updated = ShareRecipient(
id: existing.id,
shareId: existing.shareId,
recipientUserId: existing.recipientUserId,
status: status,
respondedAt: respondedAt,
);
recipientById[recipientId] = updated;
recipientByKey['${existing.shareId}:${existing.recipientUserId}'] = updated;
final inbox = inboxByRecipient[existing.recipientUserId];
if (inbox != null) {
inboxByRecipient[existing.recipientUserId] = [
for (final item in inbox)
if (item.recipient.id == recipientId)
ShareInboxItem(share: item.share, recipient: updated)
else
item,
];
}
}
@override
Future<List<SyncedResource>> acceptShare({
required String recipientId,
required DateTime respondedAt,
required List<SyncedResource> resources,
}) async {
acceptedResources.addAll(resources);
await updateRecipientStatus(
recipientId: recipientId,
status: ShareRecipientStatus.accepted,
respondedAt: respondedAt,
);
return resources;
}
@override
Future<void> revokeShare({
required String shareId,
required DateTime revokedAt,
}) async {
final existing = shareById[shareId];
if (existing == null) {
return;
}
final updated = Share(
id: existing.id,
senderUserId: existing.senderUserId,
kind: existing.kind,
resourceType: existing.resourceType,
packName: existing.packName,
payloadJson: existing.payloadJson,
createdAt: existing.createdAt,
revokedAt: revokedAt,
);
shareById[shareId] = updated;
}
}
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';
}
}
UserAccount _user(String id, String email) {
return UserAccount(
id: id,
email: email,
passwordHash: 'hash',
createdAt: DateTime.utc(2026, 7, 19, 12),
updatedAt: DateTime.utc(2026, 7, 19, 12),
);
}