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>
This commit is contained in:
@ -25,8 +25,50 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('create share returns share id, resolved recipients and unresolved emails',
|
||||
() async {
|
||||
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'),
|
||||
@ -42,24 +84,32 @@ void main() {
|
||||
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',
|
||||
'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['shareId'], 'id-1');
|
||||
expect(body['recipientUserIds'], ['user-2']);
|
||||
expect(body['unresolvedEmails'], ['missing@example.com']);
|
||||
expect(shares.shareById['id-1']?.resourceType, SyncedResourceType.program);
|
||||
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 {
|
||||
@ -79,53 +129,55 @@ void main() {
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'resourceType must be program or workoutTemplate.'},
|
||||
);
|
||||
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),
|
||||
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',
|
||||
),
|
||||
),
|
||||
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 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;
|
||||
|
||||
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)['resourceType'], 'program');
|
||||
expect((items.single as Map)['status'], 'pending');
|
||||
});
|
||||
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()
|
||||
@ -145,10 +197,7 @@ void main() {
|
||||
),
|
||||
],
|
||||
);
|
||||
final resources = _FakeSyncedResourceRepository();
|
||||
final handler = buildApiHandler(
|
||||
shareApi: _shareApi(shares: shares, resources: resources),
|
||||
);
|
||||
final handler = buildApiHandler(shareApi: _shareApi(shares: shares));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
@ -164,9 +213,68 @@ void main() {
|
||||
expect(response.statusCode, 200);
|
||||
expect(created['resourceType'], 'workoutTemplate');
|
||||
expect(created['payload'], {'schemaVersion': 3, 'name': 'Template A'});
|
||||
expect(shares.recipientById['recipient-1']?.status,
|
||||
ShareRecipientStatus.accepted);
|
||||
expect(resources.items.single.ownerUserId, 'user-1');
|
||||
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 {
|
||||
@ -181,10 +289,9 @@ void main() {
|
||||
);
|
||||
|
||||
expect(response.statusCode, 404);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Share not found.'},
|
||||
);
|
||||
expect(jsonDecode(await response.readAsString()), {
|
||||
'error': 'Share not found.',
|
||||
});
|
||||
});
|
||||
|
||||
test('accept share returns 409 when share was already answered', () async {
|
||||
@ -218,10 +325,9 @@ void main() {
|
||||
);
|
||||
|
||||
expect(response.statusCode, 409);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Share has already been answered.'},
|
||||
);
|
||||
expect(jsonDecode(await response.readAsString()), {
|
||||
'error': 'Share has already been answered.',
|
||||
});
|
||||
});
|
||||
|
||||
test('decline share returns 204 and updates recipient status', () async {
|
||||
@ -253,8 +359,10 @@ void main() {
|
||||
);
|
||||
|
||||
expect(response.statusCode, 204);
|
||||
expect(shares.recipientById['recipient-1']?.status,
|
||||
ShareRecipientStatus.declined);
|
||||
expect(
|
||||
shares.recipientById['recipient-1']?.status,
|
||||
ShareRecipientStatus.declined,
|
||||
);
|
||||
});
|
||||
|
||||
test('decline share returns 409 when share was already answered', () async {
|
||||
@ -288,10 +396,9 @@ void main() {
|
||||
);
|
||||
|
||||
expect(response.statusCode, 409);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Share has already been answered.'},
|
||||
);
|
||||
expect(jsonDecode(await response.readAsString()), {
|
||||
'error': 'Share has already been answered.',
|
||||
});
|
||||
});
|
||||
|
||||
test('revoke share returns 204 and marks share as revoked', () async {
|
||||
@ -343,23 +450,17 @@ void main() {
|
||||
);
|
||||
|
||||
expect(response.statusCode, 404);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Share not found.'},
|
||||
);
|
||||
expect(jsonDecode(await response.readAsString()), {
|
||||
'error': 'Share not found.',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ShareApi _shareApi({
|
||||
_FakeUserRepository? users,
|
||||
_FakeShareRepository? shares,
|
||||
_FakeSyncedResourceRepository? resources,
|
||||
}) {
|
||||
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')]);
|
||||
users ?? _FakeUserRepository([_user('user-1', 'user@example.com')]);
|
||||
final sessions = _FakeAuthSessionRepository(
|
||||
AuthSession(
|
||||
id: 'session-1',
|
||||
@ -377,7 +478,6 @@ ShareApi _shareApi({
|
||||
clock: clock,
|
||||
);
|
||||
final shareRepository = shares ?? _FakeShareRepository();
|
||||
final resourceRepository = resources ?? _FakeSyncedResourceRepository();
|
||||
return ShareApi(
|
||||
createShare: CreateShareUseCase(
|
||||
users: userRepository,
|
||||
@ -388,7 +488,6 @@ ShareApi _shareApi({
|
||||
listInbox: ListInboxUseCase(shares: shareRepository),
|
||||
acceptShare: AcceptShareUseCase(
|
||||
shares: shareRepository,
|
||||
resources: resourceRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
@ -452,6 +551,7 @@ final class _FakeShareRepository implements ShareRepository {
|
||||
final recipientById = <String, ShareRecipient>{};
|
||||
final recipientByKey = <String, ShareRecipient>{};
|
||||
final inboxByRecipient = <String, List<ShareInboxItem>>{};
|
||||
final acceptedResources = <SyncedResource>[];
|
||||
|
||||
void seedShare({
|
||||
required Share share,
|
||||
@ -533,6 +633,21 @@ final class _FakeShareRepository implements ShareRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
@ -545,7 +660,9 @@ final class _FakeShareRepository implements ShareRepository {
|
||||
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,
|
||||
@ -554,30 +671,6 @@ final class _FakeShareRepository implements ShareRepository {
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
||||
final items = <SyncedResource>[];
|
||||
|
||||
@override
|
||||
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||
items.add(resource);
|
||||
return SyncWriteResult(
|
||||
status: SyncWriteStatus.accepted,
|
||||
resource: resource,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SyncedResource>> findAllForUserSince({
|
||||
required String ownerUserId,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
return items
|
||||
.where((item) => item.ownerUserId == ownerUserId)
|
||||
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeTokenService implements OpaqueTokenService {
|
||||
@override
|
||||
String generateToken() => 'valid-token';
|
||||
|
||||
Reference in New Issue
Block a user