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:
2026-07-28 16:48:54 +02:00
parent 58272e354a
commit 917777e18b
279 changed files with 13546 additions and 674 deletions

View File

@ -44,5 +44,17 @@ void main() {
'share_recipients',
}),
);
final shareColumns = await connection.execute('''
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'shares'
AND column_name IN ('share_kind', 'pack_name', 'resource_type')
''');
expect(
shareColumns.map((row) => row[0] as String).toSet(),
containsAll({'share_kind', 'pack_name', 'resource_type'}),
);
});
}

View File

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

View File

@ -48,6 +48,40 @@ void main() {
]);
});
test('create pack share stores a composite payload', () async {
final useCase = CreateShareUseCase(
users: users,
shares: shares,
clock: clock,
ids: ids,
);
final result = await useCase.execute(
senderUserId: 'sender',
shareKind: 'pack',
packName: 'Pack été',
resourceType: null,
payloadJson: null,
packItems: const [
SharePackItemInput(
resourceType: 'program',
payloadJson: {'schemaVersion': 2, 'name': 'Programme'},
),
SharePackItemInput(
resourceType: 'workoutTemplate',
payloadJson: {'schemaVersion': 3, 'name': 'Séance'},
),
],
recipientEmails: ['friend@example.com'],
);
expect(result.share.kind, ShareKind.pack);
expect(result.share.packName, 'Pack été');
expect(result.share.resourceType, isNull);
expect(result.share.payloadJson['items'], hasLength(2));
expect(shares.recipients.single.recipientUserId, 'recipient-1');
});
test(
'accept creates a copied synced resource without touching source',
() async {
@ -75,7 +109,6 @@ void main() {
resources.put(source);
final useCase = AcceptShareUseCase(
shares: shares,
resources: resources,
clock: clock,
ids: ids,
);
@ -97,6 +130,54 @@ void main() {
},
);
test('accept pack creates all copied resources atomically', () async {
final share = _share(
id: 'share-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'},
},
],
},
);
shares.putShare(share);
shares.putRecipient(
ShareRecipient(
id: 'recipient-row-1',
shareId: 'share-1',
recipientUserId: 'recipient-1',
),
);
final useCase = AcceptShareUseCase(shares: shares, clock: clock, ids: ids);
final result = await useCase.execute(
shareId: 'share-1',
recipientUserId: 'recipient-1',
);
expect(result.createdResources.map((item) => item.resourceType), [
SyncedResourceType.program,
SyncedResourceType.workoutTemplate,
]);
expect(result.createdResources.map((item) => item.ownerUserId).toSet(), {
'recipient-1',
});
expect(result.createdResources.map((item) => item.schemaVersion), [2, 3]);
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(
@ -108,12 +189,7 @@ void main() {
respondedAt: clock.now(),
),
);
final useCase = AcceptShareUseCase(
shares: shares,
resources: resources,
clock: clock,
ids: ids,
);
final useCase = AcceptShareUseCase(shares: shares, clock: clock, ids: ids);
expect(
() => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'),
@ -130,12 +206,7 @@ void main() {
recipientUserId: 'recipient-1',
),
);
final useCase = AcceptShareUseCase(
shares: shares,
resources: resources,
clock: clock,
ids: ids,
);
final useCase = AcceptShareUseCase(shares: shares, clock: clock, ids: ids);
expect(
() => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'),
@ -224,7 +295,9 @@ UserAccount _user(String id, String email) {
Share _share({
required String id,
String senderUserId = 'sender',
SyncedResourceType resourceType = SyncedResourceType.program,
ShareKind kind = ShareKind.single,
SyncedResourceType? resourceType = SyncedResourceType.program,
String? packName,
Map<String, Object?> payloadJson = const {'schemaVersion': 1},
DateTime? createdAt,
DateTime? revokedAt,
@ -232,7 +305,9 @@ Share _share({
return Share(
id: id,
senderUserId: senderUserId,
kind: kind,
resourceType: resourceType,
packName: packName,
payloadJson: payloadJson,
createdAt: createdAt ?? DateTime.utc(2026, 7, 19, 12),
revokedAt: revokedAt,
@ -376,6 +451,20 @@ final class _FakeShareRepository implements ShareRepository {
);
}
@override
Future<List<SyncedResource>> acceptShare({
required String recipientId,
required DateTime respondedAt,
required List<SyncedResource> resources,
}) async {
await updateRecipientStatus(
recipientId: recipientId,
status: ShareRecipientStatus.accepted,
respondedAt: respondedAt,
);
return resources;
}
@override
Future<void> revokeShare({
required String shareId,
@ -386,7 +475,9 @@ final class _FakeShareRepository implements ShareRepository {
shares[index] = Share(
id: share.id,
senderUserId: share.senderUserId,
kind: share.kind,
resourceType: share.resourceType,
packName: share.packName,
payloadJson: share.payloadJson,
createdAt: share.createdAt,
revokedAt: revokedAt,