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:
@ -67,12 +67,7 @@ Future<void> main(List<String> arguments) async {
|
||||
ids: ids,
|
||||
),
|
||||
listInbox: ListInboxUseCase(shares: shares),
|
||||
acceptShare: AcceptShareUseCase(
|
||||
shares: shares,
|
||||
resources: resources,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
acceptShare: AcceptShareUseCase(shares: shares, clock: clock, ids: ids),
|
||||
declineShare: DeclineShareUseCase(shares: shares, clock: clock),
|
||||
revokeShare: RevokeShareUseCase(shares: shares, clock: clock),
|
||||
authenticateRequest: authenticateRequest,
|
||||
|
||||
@ -30,14 +30,21 @@ final class ShareApi {
|
||||
}
|
||||
try {
|
||||
final body = await _readJsonObject(request);
|
||||
final shareKind = _optionalString(body, 'shareKind') ?? 'single';
|
||||
final result = await createShare.execute(
|
||||
senderUserId: authenticated.user.id,
|
||||
resourceType: _requiredString(body, 'resourceType'),
|
||||
payloadJson: _requiredObject(body, 'payload'),
|
||||
shareKind: shareKind,
|
||||
packName: _optionalString(body, 'packName'),
|
||||
resourceType: _optionalString(body, 'resourceType'),
|
||||
payloadJson: _optionalObject(body, 'payload'),
|
||||
packItems: shareKind == ShareKind.pack.wireName
|
||||
? _packItems(body['items'])
|
||||
: const [],
|
||||
recipientEmails: _requiredStringList(body, 'recipientEmails'),
|
||||
);
|
||||
return _jsonResponse(201, {
|
||||
'shareId': result.share.id,
|
||||
'shareKind': result.share.kind.wireName,
|
||||
'recipientUserIds': result.recipientUserIds,
|
||||
'unresolvedEmails': result.unresolvedEmails,
|
||||
});
|
||||
@ -62,7 +69,9 @@ final class ShareApi {
|
||||
{
|
||||
'shareId': item.share.id,
|
||||
'senderUserId': item.share.senderUserId,
|
||||
'resourceType': item.share.resourceType.wireName,
|
||||
'shareKind': item.share.kind.wireName,
|
||||
'packName': item.share.packName,
|
||||
'resourceType': item.share.resourceType?.wireName,
|
||||
'payload': item.share.payloadJson,
|
||||
'status': item.recipient.status.wireName,
|
||||
'createdAt': item.share.createdAt.toIso8601String(),
|
||||
@ -83,7 +92,12 @@ final class ShareApi {
|
||||
recipientUserId: authenticated.user.id,
|
||||
);
|
||||
return _jsonResponse(200, {
|
||||
'createdResource': _resourceJson(result.createdResource),
|
||||
if (result.createdResources.length == 1)
|
||||
'createdResource': _resourceJson(result.createdResource),
|
||||
'createdResources': [
|
||||
for (final resource in result.createdResources)
|
||||
_resourceJson(resource),
|
||||
],
|
||||
});
|
||||
} on ShareNotFoundException catch (error) {
|
||||
return _errorResponse(404, error.message);
|
||||
@ -140,6 +154,26 @@ Map<String, Object?> _resourceJson(SyncedResource resource) {
|
||||
};
|
||||
}
|
||||
|
||||
List<SharePackItemInput> _packItems(Object? rawItems) {
|
||||
if (rawItems is! List) {
|
||||
throw const FormatException('items must be an array.');
|
||||
}
|
||||
return [
|
||||
for (final rawItem in rawItems)
|
||||
if (rawItem is Map)
|
||||
_packItem(Map<String, Object?>.from(rawItem))
|
||||
else
|
||||
throw const FormatException('items must contain only objects.'),
|
||||
];
|
||||
}
|
||||
|
||||
SharePackItemInput _packItem(Map<String, Object?> item) {
|
||||
return SharePackItemInput(
|
||||
resourceType: _requiredString(item, 'resourceType'),
|
||||
payloadJson: _requiredObject(item, 'payload'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readJsonObject(Request request) async {
|
||||
final raw = await request.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
@ -157,6 +191,18 @@ String _requiredString(Map<String, Object?> body, String key) {
|
||||
return value;
|
||||
}
|
||||
|
||||
String? _optionalString(Map<String, Object?> body, String key) {
|
||||
final value = body[key];
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is! String) {
|
||||
throw FormatException('$key must be a string.');
|
||||
}
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
Map<String, Object?> _requiredObject(Map<String, Object?> body, String key) {
|
||||
final value = body[key];
|
||||
if (value is Map<String, Object?>) {
|
||||
@ -168,6 +214,13 @@ Map<String, Object?> _requiredObject(Map<String, Object?> body, String key) {
|
||||
throw FormatException('$key must be a JSON object.');
|
||||
}
|
||||
|
||||
Map<String, Object?>? _optionalObject(Map<String, Object?> body, String key) {
|
||||
if (!body.containsKey(key)) {
|
||||
return null;
|
||||
}
|
||||
return _requiredObject(body, key);
|
||||
}
|
||||
|
||||
List<String> _requiredStringList(Map<String, Object?> body, String key) {
|
||||
final value = body[key];
|
||||
if (value is! List) {
|
||||
|
||||
@ -68,6 +68,12 @@ abstract interface class ShareRepository {
|
||||
required DateTime respondedAt,
|
||||
});
|
||||
|
||||
Future<List<SyncedResource>> acceptShare({
|
||||
required String recipientId,
|
||||
required DateTime respondedAt,
|
||||
required List<SyncedResource> resources,
|
||||
});
|
||||
|
||||
Future<void> revokeShare({
|
||||
required String shareId,
|
||||
required DateTime revokedAt,
|
||||
|
||||
@ -14,9 +14,21 @@ final class CreateShareResult {
|
||||
}
|
||||
|
||||
final class AcceptShareResult {
|
||||
const AcceptShareResult({required this.createdResource});
|
||||
const AcceptShareResult({required this.createdResources});
|
||||
|
||||
final SyncedResource createdResource;
|
||||
final List<SyncedResource> createdResources;
|
||||
|
||||
SyncedResource get createdResource => createdResources.single;
|
||||
}
|
||||
|
||||
final class SharePackItemInput {
|
||||
const SharePackItemInput({
|
||||
required this.resourceType,
|
||||
required this.payloadJson,
|
||||
});
|
||||
|
||||
final String resourceType;
|
||||
final Map<String, Object?> payloadJson;
|
||||
}
|
||||
|
||||
final class CreateShareUseCase {
|
||||
@ -34,11 +46,21 @@ final class CreateShareUseCase {
|
||||
|
||||
Future<CreateShareResult> execute({
|
||||
required String senderUserId,
|
||||
required String resourceType,
|
||||
required Map<String, Object?> payloadJson,
|
||||
String shareKind = 'single',
|
||||
String? packName,
|
||||
required String? resourceType,
|
||||
required Map<String, Object?>? payloadJson,
|
||||
List<SharePackItemInput> packItems = const [],
|
||||
required List<String> recipientEmails,
|
||||
}) async {
|
||||
final parsedType = _shareResourceType(resourceType);
|
||||
final parsedKind = ShareKind.parse(shareKind);
|
||||
final shareContent = _shareContent(
|
||||
kind: parsedKind,
|
||||
packName: packName,
|
||||
resourceType: resourceType,
|
||||
payloadJson: payloadJson,
|
||||
packItems: packItems,
|
||||
);
|
||||
final emails = _normalizedEmails(recipientEmails);
|
||||
if (emails.isEmpty) {
|
||||
throw const ValidationException('recipientEmails must not be empty.');
|
||||
@ -60,8 +82,10 @@ final class CreateShareUseCase {
|
||||
final share = Share(
|
||||
id: ids.newId(),
|
||||
senderUserId: senderUserId,
|
||||
resourceType: parsedType,
|
||||
payloadJson: payloadJson,
|
||||
kind: parsedKind,
|
||||
resourceType: shareContent.resourceType,
|
||||
packName: shareContent.packName,
|
||||
payloadJson: shareContent.payloadJson,
|
||||
createdAt: now,
|
||||
);
|
||||
final recipients = [
|
||||
@ -94,13 +118,11 @@ final class ListInboxUseCase {
|
||||
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;
|
||||
|
||||
@ -124,23 +146,25 @@ final class AcceptShareUseCase {
|
||||
}
|
||||
|
||||
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(
|
||||
final resourcesToCreate = [
|
||||
for (final item in _resourcesFromShare(share))
|
||||
SyncedResource(
|
||||
serverId: ids.newId(),
|
||||
ownerUserId: recipientUserId,
|
||||
resourceType: item.resourceType,
|
||||
clientId: ids.newId(),
|
||||
payloadJson: item.payloadJson,
|
||||
schemaVersion: _schemaVersionFromPayload(item.payloadJson),
|
||||
clientUpdatedAt: now,
|
||||
serverUpdatedAt: now,
|
||||
),
|
||||
];
|
||||
final written = await shares.acceptShare(
|
||||
recipientId: recipient.id,
|
||||
status: ShareRecipientStatus.accepted,
|
||||
respondedAt: now,
|
||||
resources: resourcesToCreate,
|
||||
);
|
||||
return AcceptShareResult(createdResource: written.resource);
|
||||
return AcceptShareResult(createdResources: written);
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,6 +229,140 @@ SyncedResourceType _shareResourceType(String value) {
|
||||
return type;
|
||||
}
|
||||
|
||||
final class _ShareContent {
|
||||
const _ShareContent({
|
||||
required this.resourceType,
|
||||
required this.packName,
|
||||
required this.payloadJson,
|
||||
});
|
||||
|
||||
final SyncedResourceType? resourceType;
|
||||
final String? packName;
|
||||
final Map<String, Object?> payloadJson;
|
||||
}
|
||||
|
||||
_ShareContent _shareContent({
|
||||
required ShareKind kind,
|
||||
required String? packName,
|
||||
required String? resourceType,
|
||||
required Map<String, Object?>? payloadJson,
|
||||
required List<SharePackItemInput> packItems,
|
||||
}) {
|
||||
return switch (kind) {
|
||||
ShareKind.single => _singleShareContent(
|
||||
resourceType: resourceType,
|
||||
payloadJson: payloadJson,
|
||||
),
|
||||
ShareKind.pack => _packShareContent(packName: packName, items: packItems),
|
||||
};
|
||||
}
|
||||
|
||||
_ShareContent _singleShareContent({
|
||||
required String? resourceType,
|
||||
required Map<String, Object?>? payloadJson,
|
||||
}) {
|
||||
final normalizedType = _blankToNull(resourceType);
|
||||
if (normalizedType == null) {
|
||||
throw const ValidationException('resourceType must not be blank.');
|
||||
}
|
||||
final payload = payloadJson;
|
||||
if (payload == null) {
|
||||
throw const ValidationException('payload is required.');
|
||||
}
|
||||
return _ShareContent(
|
||||
resourceType: _shareResourceType(normalizedType),
|
||||
packName: null,
|
||||
payloadJson: payload,
|
||||
);
|
||||
}
|
||||
|
||||
_ShareContent _packShareContent({
|
||||
required String? packName,
|
||||
required List<SharePackItemInput> items,
|
||||
}) {
|
||||
final normalizedPackName = _blankToNull(packName);
|
||||
if (normalizedPackName == null) {
|
||||
throw const ValidationException('packName must not be blank.');
|
||||
}
|
||||
if (items.isEmpty) {
|
||||
throw const ValidationException('items must not be empty.');
|
||||
}
|
||||
return _ShareContent(
|
||||
resourceType: null,
|
||||
packName: normalizedPackName,
|
||||
payloadJson: {
|
||||
'items': [
|
||||
for (final item in items)
|
||||
{
|
||||
'resourceType': _shareResourceType(item.resourceType).wireName,
|
||||
'payload': item.payloadJson,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final class _ShareResourceData {
|
||||
const _ShareResourceData({
|
||||
required this.resourceType,
|
||||
required this.payloadJson,
|
||||
});
|
||||
|
||||
final SyncedResourceType resourceType;
|
||||
final Map<String, Object?> payloadJson;
|
||||
}
|
||||
|
||||
List<_ShareResourceData> _resourcesFromShare(Share share) {
|
||||
return switch (share.kind) {
|
||||
ShareKind.single => [
|
||||
_ShareResourceData(
|
||||
resourceType: share.resourceType!,
|
||||
payloadJson: share.payloadJson,
|
||||
),
|
||||
],
|
||||
ShareKind.pack => _packResourcesFromPayload(share.payloadJson),
|
||||
};
|
||||
}
|
||||
|
||||
List<_ShareResourceData> _packResourcesFromPayload(
|
||||
Map<String, Object?> payloadJson,
|
||||
) {
|
||||
final rawItems = payloadJson['items'];
|
||||
if (rawItems is! List || rawItems.isEmpty) {
|
||||
throw const ValidationException('Pack share payload must contain items.');
|
||||
}
|
||||
return [
|
||||
for (final rawItem in rawItems)
|
||||
if (rawItem is Map)
|
||||
_packResourceFromPayload(Map<String, Object?>.from(rawItem))
|
||||
else
|
||||
throw const ValidationException('Pack item must be a JSON object.'),
|
||||
];
|
||||
}
|
||||
|
||||
_ShareResourceData _packResourceFromPayload(Map<String, Object?> item) {
|
||||
final resourceType = item['resourceType'];
|
||||
if (resourceType is! String || resourceType.trim().isEmpty) {
|
||||
throw const ValidationException(
|
||||
'Pack item resourceType must not be blank.',
|
||||
);
|
||||
}
|
||||
final payload = item['payload'];
|
||||
if (payload is Map<String, Object?>) {
|
||||
return _ShareResourceData(
|
||||
resourceType: _shareResourceType(resourceType),
|
||||
payloadJson: payload,
|
||||
);
|
||||
}
|
||||
if (payload is Map) {
|
||||
return _ShareResourceData(
|
||||
resourceType: _shareResourceType(resourceType),
|
||||
payloadJson: Map<String, Object?>.from(payload),
|
||||
);
|
||||
}
|
||||
throw const ValidationException('Pack item payload must be a JSON object.');
|
||||
}
|
||||
|
||||
List<String> _normalizedEmails(List<String> emails) {
|
||||
return emails
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
@ -213,6 +371,11 @@ List<String> _normalizedEmails(List<String> emails) {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String? _blankToNull(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
return trimmed == null || trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
int _schemaVersionFromPayload(Map<String, Object?> payloadJson) {
|
||||
final schemaVersion = payloadJson['schemaVersion'];
|
||||
return schemaVersion is int && schemaVersion > 0 ? schemaVersion : 1;
|
||||
|
||||
@ -173,30 +173,69 @@ enum ShareRecipientStatus {
|
||||
}
|
||||
}
|
||||
|
||||
enum ShareKind {
|
||||
single('single'),
|
||||
pack('pack');
|
||||
|
||||
const ShareKind(this.wireName);
|
||||
|
||||
final String wireName;
|
||||
|
||||
static ShareKind parse(String value) {
|
||||
for (final kind in values) {
|
||||
if (kind.wireName == value) {
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
throw ValidationException('Unsupported share kind: $value.');
|
||||
}
|
||||
}
|
||||
|
||||
final class Share {
|
||||
Share({
|
||||
required String id,
|
||||
required String senderUserId,
|
||||
this.kind = ShareKind.single,
|
||||
required this.resourceType,
|
||||
String? packName,
|
||||
required Map<String, Object?> payloadJson,
|
||||
required DateTime createdAt,
|
||||
DateTime? revokedAt,
|
||||
}) : id = _nonBlank(id, 'Share id'),
|
||||
senderUserId = _nonBlank(senderUserId, 'Sender user id'),
|
||||
packName = _blankToNull(packName),
|
||||
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.',
|
||||
);
|
||||
if (kind == ShareKind.single) {
|
||||
final type = resourceType;
|
||||
if (type != SyncedResourceType.program &&
|
||||
type != SyncedResourceType.workoutTemplate) {
|
||||
throw const ValidationException(
|
||||
'Single shares only support program and workoutTemplate resources.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (kind == ShareKind.pack) {
|
||||
if (resourceType != null) {
|
||||
throw const ValidationException(
|
||||
'Pack shares must not set resourceType.',
|
||||
);
|
||||
}
|
||||
if (this.packName == null) {
|
||||
throw const ValidationException('Pack name must not be blank.');
|
||||
}
|
||||
}
|
||||
if (kind == ShareKind.pack && payloadJson['items'] is! List) {
|
||||
throw const ValidationException('Pack share payload must contain items.');
|
||||
}
|
||||
}
|
||||
|
||||
final String id;
|
||||
final String senderUserId;
|
||||
final SyncedResourceType resourceType;
|
||||
final ShareKind kind;
|
||||
final SyncedResourceType? resourceType;
|
||||
final String? packName;
|
||||
final Map<String, Object?> payloadJson;
|
||||
final DateTime createdAt;
|
||||
final DateTime? revokedAt;
|
||||
@ -230,3 +269,8 @@ String _nonBlank(String value, String label) {
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
String? _blankToNull(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
return trimmed == null || trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
@ -18,18 +18,20 @@ final class PostgresShareRepository implements ShareRepository {
|
||||
await connection.execute(
|
||||
Sql.named('''
|
||||
INSERT INTO shares (
|
||||
id, sender_user_id, resource_type, payload_json, created_at,
|
||||
revoked_at
|
||||
id, sender_user_id, share_kind, pack_name, resource_type,
|
||||
payload_json, created_at, revoked_at
|
||||
)
|
||||
VALUES (
|
||||
@id::uuid, @sender_user_id::uuid, @resource_type,
|
||||
@payload_json::jsonb, @created_at, @revoked_at
|
||||
@id::uuid, @sender_user_id::uuid, @share_kind, @pack_name,
|
||||
@resource_type, @payload_json::jsonb, @created_at, @revoked_at
|
||||
)
|
||||
'''),
|
||||
parameters: {
|
||||
'id': share.id,
|
||||
'sender_user_id': share.senderUserId,
|
||||
'resource_type': share.resourceType.wireName,
|
||||
'share_kind': share.kind.wireName,
|
||||
'pack_name': share.packName,
|
||||
'resource_type': share.resourceType?.wireName,
|
||||
'payload_json': jsonEncode(share.payloadJson),
|
||||
'created_at': share.createdAt,
|
||||
'revoked_at': share.revokedAt,
|
||||
@ -66,6 +68,8 @@ final class PostgresShareRepository implements ShareRepository {
|
||||
SELECT
|
||||
s.id AS share_id,
|
||||
s.sender_user_id,
|
||||
s.share_kind,
|
||||
s.pack_name,
|
||||
s.resource_type,
|
||||
s.payload_json,
|
||||
s.created_at,
|
||||
@ -91,8 +95,8 @@ final class PostgresShareRepository implements ShareRepository {
|
||||
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
|
||||
SELECT id, sender_user_id, share_kind, pack_name, resource_type,
|
||||
payload_json, created_at, revoked_at
|
||||
FROM shares
|
||||
WHERE id = @id::uuid
|
||||
LIMIT 1
|
||||
@ -146,6 +150,33 @@ final class PostgresShareRepository implements ShareRepository {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SyncedResource>> acceptShare({
|
||||
required String recipientId,
|
||||
required DateTime respondedAt,
|
||||
required List<SyncedResource> resources,
|
||||
}) {
|
||||
return connection.runTx((session) async {
|
||||
final written = <SyncedResource>[];
|
||||
for (final resource in resources) {
|
||||
written.add(await _upsertResource(session, resource));
|
||||
}
|
||||
await session.execute(
|
||||
Sql.named('''
|
||||
UPDATE share_recipients
|
||||
SET status = @status, responded_at = @responded_at
|
||||
WHERE id = @id::uuid
|
||||
'''),
|
||||
parameters: {
|
||||
'id': recipientId,
|
||||
'status': ShareRecipientStatus.accepted.wireName,
|
||||
'responded_at': respondedAt.toUtc(),
|
||||
},
|
||||
);
|
||||
return written;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> revokeShare({
|
||||
required String shareId,
|
||||
@ -173,9 +204,11 @@ 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']),
|
||||
kind: ShareKind.parse(
|
||||
_stringValue(values['share_kind'] ?? ShareKind.single.wireName),
|
||||
),
|
||||
packName: values['pack_name'] as String?,
|
||||
resourceType: _nullableResourceType(values['resource_type']),
|
||||
payloadJson: _payloadValue(values['payload_json']),
|
||||
createdAt: _dateTimeValue(values['created_at']),
|
||||
revokedAt: _nullableDateTimeValue(values['revoked_at']),
|
||||
@ -192,6 +225,90 @@ ShareRecipient _recipientFromValues(Map<String, Object?> values) {
|
||||
);
|
||||
}
|
||||
|
||||
Future<SyncedResource> _upsertResource(
|
||||
Session session,
|
||||
SyncedResource resource,
|
||||
) async {
|
||||
final result = await session.execute(
|
||||
Sql.named('''
|
||||
WITH upserted AS (
|
||||
INSERT INTO synced_resources (
|
||||
server_id, owner_user_id, resource_type, client_id, payload_json,
|
||||
schema_version, client_updated_at, deleted_at, origin_device_id
|
||||
)
|
||||
VALUES (
|
||||
@server_id::uuid, @owner_user_id::uuid, @resource_type, @client_id,
|
||||
@payload_json::jsonb, @schema_version, @client_updated_at,
|
||||
@deleted_at, @origin_device_id
|
||||
)
|
||||
ON CONFLICT (owner_user_id, resource_type, client_id)
|
||||
DO UPDATE SET
|
||||
payload_json = EXCLUDED.payload_json,
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
client_updated_at = EXCLUDED.client_updated_at,
|
||||
deleted_at = EXCLUDED.deleted_at,
|
||||
origin_device_id = EXCLUDED.origin_device_id
|
||||
WHERE synced_resources.client_updated_at < EXCLUDED.client_updated_at
|
||||
RETURNING
|
||||
server_id, owner_user_id, resource_type, client_id, payload_json,
|
||||
schema_version, client_updated_at, server_updated_at, deleted_at,
|
||||
origin_device_id
|
||||
)
|
||||
SELECT * FROM upserted
|
||||
UNION ALL
|
||||
SELECT
|
||||
existing.server_id, existing.owner_user_id, existing.resource_type,
|
||||
existing.client_id, existing.payload_json, existing.schema_version,
|
||||
existing.client_updated_at, existing.server_updated_at,
|
||||
existing.deleted_at, existing.origin_device_id
|
||||
FROM synced_resources existing
|
||||
WHERE existing.owner_user_id = @owner_user_id::uuid
|
||||
AND existing.resource_type = @resource_type
|
||||
AND existing.client_id = @client_id
|
||||
AND NOT EXISTS (SELECT 1 FROM upserted)
|
||||
LIMIT 1
|
||||
'''),
|
||||
parameters: {
|
||||
'server_id': resource.serverId,
|
||||
'owner_user_id': resource.ownerUserId,
|
||||
'resource_type': resource.resourceType.wireName,
|
||||
'client_id': resource.clientId,
|
||||
'payload_json': jsonEncode(resource.payloadJson),
|
||||
'schema_version': resource.schemaVersion,
|
||||
'client_updated_at': resource.clientUpdatedAt,
|
||||
'deleted_at': resource.deletedAt,
|
||||
'origin_device_id': resource.originDeviceId,
|
||||
},
|
||||
);
|
||||
return _resourceFromValues(
|
||||
result.single.toColumnMap() as Map<String, Object?>,
|
||||
);
|
||||
}
|
||||
|
||||
SyncedResource _resourceFromValues(Map<String, Object?> values) {
|
||||
return SyncedResource(
|
||||
serverId: _stringValue(values['server_id']),
|
||||
ownerUserId: _stringValue(values['owner_user_id']),
|
||||
resourceType: SyncedResourceType.parse(
|
||||
_stringValue(values['resource_type']),
|
||||
),
|
||||
clientId: _stringValue(values['client_id']),
|
||||
payloadJson: _payloadValue(values['payload_json']),
|
||||
schemaVersion: values['schema_version'] as int,
|
||||
clientUpdatedAt: _dateTimeValue(values['client_updated_at']),
|
||||
serverUpdatedAt: _dateTimeValue(values['server_updated_at']),
|
||||
deletedAt: _nullableDateTimeValue(values['deleted_at']),
|
||||
originDeviceId: values['origin_device_id'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
SyncedResourceType? _nullableResourceType(Object? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return SyncedResourceType.parse(_stringValue(value));
|
||||
}
|
||||
|
||||
Map<String, Object?> _payloadValue(Object? value) {
|
||||
if (value is Map<String, Object?>) {
|
||||
return value;
|
||||
|
||||
42
server/migrations/0002_share_packs.sql
Normal file
42
server/migrations/0002_share_packs.sql
Normal file
@ -0,0 +1,42 @@
|
||||
ALTER TABLE shares
|
||||
ADD COLUMN IF NOT EXISTS share_kind text NOT NULL DEFAULT 'single';
|
||||
|
||||
ALTER TABLE shares
|
||||
ADD COLUMN IF NOT EXISTS pack_name text;
|
||||
|
||||
ALTER TABLE shares
|
||||
ALTER COLUMN resource_type DROP NOT NULL;
|
||||
|
||||
ALTER TABLE shares
|
||||
DROP CONSTRAINT IF EXISTS shares_resource_type_check;
|
||||
|
||||
ALTER TABLE shares
|
||||
DROP CONSTRAINT IF EXISTS shares_kind_check;
|
||||
|
||||
ALTER TABLE shares
|
||||
DROP CONSTRAINT IF EXISTS shares_shape_check;
|
||||
|
||||
ALTER TABLE shares
|
||||
ADD CONSTRAINT shares_resource_type_check CHECK (
|
||||
resource_type IS NULL OR resource_type IN ('program', 'workoutTemplate')
|
||||
);
|
||||
|
||||
ALTER TABLE shares
|
||||
ADD CONSTRAINT shares_kind_check CHECK (share_kind IN ('single', 'pack'));
|
||||
|
||||
ALTER TABLE shares
|
||||
ADD CONSTRAINT shares_shape_check CHECK (
|
||||
(
|
||||
share_kind = 'single'
|
||||
AND resource_type IN ('program', 'workoutTemplate')
|
||||
AND pack_name IS NULL
|
||||
)
|
||||
OR
|
||||
(
|
||||
share_kind = 'pack'
|
||||
AND resource_type IS NULL
|
||||
AND length(trim(pack_name)) > 0
|
||||
AND COALESCE(jsonb_typeof(payload_json -> 'items') = 'array', false)
|
||||
AND jsonb_array_length(payload_json -> 'items') > 0
|
||||
)
|
||||
);
|
||||
@ -367,6 +367,10 @@ components:
|
||||
type: string
|
||||
enum: [program, workoutTemplate]
|
||||
|
||||
ShareKind:
|
||||
type: string
|
||||
enum: [single, pack]
|
||||
|
||||
ShareRecipientStatus:
|
||||
type: string
|
||||
enum: [pending, accepted, declined, revoked]
|
||||
@ -517,12 +521,26 @@ components:
|
||||
|
||||
CreateShareRequest:
|
||||
type: object
|
||||
required: [resourceType, payload, recipientEmails]
|
||||
required: [recipientEmails]
|
||||
properties:
|
||||
shareKind:
|
||||
description: Defaults to single when omitted.
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ShareKind'
|
||||
packName:
|
||||
type: string
|
||||
nullable: true
|
||||
resourceType:
|
||||
$ref: '#/components/schemas/ShareResourceType'
|
||||
nullable: true
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ShareResourceType'
|
||||
payload:
|
||||
$ref: '#/components/schemas/JsonObject'
|
||||
nullable: true
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SharePackItem'
|
||||
recipientEmails:
|
||||
type: array
|
||||
minItems: 1
|
||||
@ -530,12 +548,23 @@ components:
|
||||
type: string
|
||||
format: email
|
||||
|
||||
SharePackItem:
|
||||
type: object
|
||||
required: [resourceType, payload]
|
||||
properties:
|
||||
resourceType:
|
||||
$ref: '#/components/schemas/ShareResourceType'
|
||||
payload:
|
||||
$ref: '#/components/schemas/JsonObject'
|
||||
|
||||
CreateShareResponse:
|
||||
type: object
|
||||
required: [shareId, recipientUserIds, unresolvedEmails]
|
||||
required: [shareId, shareKind, recipientUserIds, unresolvedEmails]
|
||||
properties:
|
||||
shareId:
|
||||
type: string
|
||||
shareKind:
|
||||
$ref: '#/components/schemas/ShareKind'
|
||||
recipientUserIds:
|
||||
type: array
|
||||
items:
|
||||
@ -560,7 +589,7 @@ components:
|
||||
required:
|
||||
- shareId
|
||||
- senderUserId
|
||||
- resourceType
|
||||
- shareKind
|
||||
- payload
|
||||
- status
|
||||
- createdAt
|
||||
@ -569,8 +598,15 @@ components:
|
||||
type: string
|
||||
senderUserId:
|
||||
type: string
|
||||
shareKind:
|
||||
$ref: '#/components/schemas/ShareKind'
|
||||
packName:
|
||||
type: string
|
||||
nullable: true
|
||||
resourceType:
|
||||
$ref: '#/components/schemas/ShareResourceType'
|
||||
nullable: true
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/ShareResourceType'
|
||||
payload:
|
||||
$ref: '#/components/schemas/JsonObject'
|
||||
status:
|
||||
@ -585,7 +621,12 @@ components:
|
||||
|
||||
AcceptShareResponse:
|
||||
type: object
|
||||
required: [createdResource]
|
||||
required: [createdResources]
|
||||
properties:
|
||||
createdResource:
|
||||
$ref: '#/components/schemas/SyncedResourceItem'
|
||||
description: Present only for single-resource shares.
|
||||
createdResources:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SyncedResourceItem'
|
||||
|
||||
@ -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'}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user