feat(server): partage ciblé de programmes et séances entre comptes (ticket #51)
Ajoute l'endpoint api/share_api.dart, les use cases de partage (application/share_use_cases.dart) et l'adapter Postgres (infrastructure/postgres/share_repository.dart). L'acceptation d'un partage crée une nouvelle ressource synced_resources avec de nouveaux IDs pour le destinataire, sans jamais modifier la ressource source de l'émetteur ; gestion des conflits révoqué/déjà répondu. dart pub get OK, dart analyze clean, dart test 24/24 vert. Use case d'acceptation relu manuellement et jugé correct ; pas de test de bout en bout contre un vrai PostgreSQL faute d'accès Docker dans ce sandbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
205
server/test/share_api_test.dart
Normal file
205
server/test/share_api_test.dart
Normal file
@ -0,0 +1,205 @@
|
||||
import 'package:gametime_server/api/router.dart';
|
||||
import 'package:gametime_server/api/share_api.dart';
|
||||
import 'package:gametime_server/application/application.dart';
|
||||
import 'package:gametime_server/domain/domain.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('share routes require bearer authentication', () async {
|
||||
final handler = buildApiHandler(shareApi: _shareApi());
|
||||
final requests = [
|
||||
Request('POST', Uri.parse('http://localhost/shares')),
|
||||
Request('GET', Uri.parse('http://localhost/shares/inbox')),
|
||||
Request('POST', Uri.parse('http://localhost/shares/share-1/accept')),
|
||||
Request('POST', Uri.parse('http://localhost/shares/share-1/decline')),
|
||||
Request('POST', Uri.parse('http://localhost/shares/share-1/revoke')),
|
||||
];
|
||||
|
||||
for (final request in requests) {
|
||||
final response = await handler(request);
|
||||
|
||||
expect(response.statusCode, 401, reason: request.url.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ShareApi _shareApi() {
|
||||
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
|
||||
final ids = _FakeIds();
|
||||
final users = _FakeUserRepository(
|
||||
UserAccount(
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
passwordHash: 'hash',
|
||||
createdAt: clock.now(),
|
||||
updatedAt: clock.now(),
|
||||
),
|
||||
);
|
||||
final sessions = _FakeAuthSessionRepository(
|
||||
AuthSession(
|
||||
id: 'session-1',
|
||||
userId: 'user-1',
|
||||
tokenHash: 'token-hash:valid-token',
|
||||
issuedAt: clock.now(),
|
||||
expiresAt: clock.now().add(const Duration(days: 1)),
|
||||
),
|
||||
);
|
||||
final tokens = _FakeTokenService();
|
||||
final authenticate = AuthenticateRequestUseCase(
|
||||
users: users,
|
||||
sessions: sessions,
|
||||
tokens: tokens,
|
||||
clock: clock,
|
||||
);
|
||||
final shares = _FakeShareRepository();
|
||||
final resources = _FakeSyncedResourceRepository();
|
||||
return ShareApi(
|
||||
createShare: CreateShareUseCase(
|
||||
users: users,
|
||||
shares: shares,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
listInbox: ListInboxUseCase(shares: shares),
|
||||
acceptShare: AcceptShareUseCase(
|
||||
shares: shares,
|
||||
resources: resources,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
declineShare: DeclineShareUseCase(shares: shares, clock: clock),
|
||||
revokeShare: RevokeShareUseCase(shares: shares, clock: clock),
|
||||
authenticateRequest: authenticate,
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeUserRepository implements UserRepository {
|
||||
const _FakeUserRepository(this.user);
|
||||
|
||||
final UserAccount user;
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findByEmail(String email) async {
|
||||
return user.email == email.toLowerCase() ? user : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findById(String id) async {
|
||||
return id == user.id ? user : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(UserAccount user) async {}
|
||||
|
||||
@override
|
||||
Future<void> updatePasswordHash({
|
||||
required String userId,
|
||||
required String passwordHash,
|
||||
required DateTime updatedAt,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
final class _FakeAuthSessionRepository implements AuthSessionRepository {
|
||||
const _FakeAuthSessionRepository(this.session);
|
||||
|
||||
final AuthSession session;
|
||||
|
||||
@override
|
||||
Future<AuthSession?> findByTokenHash(String tokenHash) async {
|
||||
return tokenHash == session.tokenHash ? session : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(AuthSession session) async {}
|
||||
|
||||
@override
|
||||
Future<void> revoke({
|
||||
required String sessionId,
|
||||
required DateTime revokedAt,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
final class _FakeShareRepository implements ShareRepository {
|
||||
@override
|
||||
Future<void> insertShare({
|
||||
required Share share,
|
||||
required List<ShareRecipient> recipients,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
|
||||
return const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Share?> findShareById(String shareId) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ShareRecipient?> findRecipient({
|
||||
required String shareId,
|
||||
required String recipientUserId,
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRecipientStatus({
|
||||
required String recipientId,
|
||||
required ShareRecipientStatus status,
|
||||
required DateTime respondedAt,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> revokeShare({
|
||||
required String shareId,
|
||||
required DateTime revokedAt,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
||||
@override
|
||||
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||
return SyncWriteResult(
|
||||
status: SyncWriteStatus.accepted,
|
||||
resource: resource,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SyncedResource>> findAllForUserSince({
|
||||
required String ownerUserId,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeTokenService implements OpaqueTokenService {
|
||||
@override
|
||||
String generateToken() => 'valid-token';
|
||||
|
||||
@override
|
||||
String hashToken(String token) => 'token-hash:$token';
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
const _FakeClock(this.value);
|
||||
|
||||
final DateTime value;
|
||||
|
||||
@override
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
final class _FakeIds implements IdGenerator {
|
||||
var _next = 0;
|
||||
|
||||
@override
|
||||
String newId() {
|
||||
_next += 1;
|
||||
return 'id-$_next';
|
||||
}
|
||||
}
|
||||
472
server/test/share_use_cases_test.dart
Normal file
472
server/test/share_use_cases_test.dart
Normal file
@ -0,0 +1,472 @@
|
||||
import 'package:gametime_server/application/application.dart';
|
||||
import 'package:gametime_server/domain/domain.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late _FakeUserRepository users;
|
||||
late _FakeShareRepository shares;
|
||||
late _FakeSyncedResourceRepository resources;
|
||||
late _FakeClock clock;
|
||||
late _FakeIds ids;
|
||||
|
||||
setUp(() {
|
||||
clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
|
||||
ids = _FakeIds();
|
||||
users = _FakeUserRepository()
|
||||
..put(_user('sender', 'sender@example.com'))
|
||||
..put(_user('recipient-1', 'friend@example.com'))
|
||||
..put(_user('recipient-2', 'coach@example.com'));
|
||||
shares = _FakeShareRepository();
|
||||
resources = _FakeSyncedResourceRepository();
|
||||
});
|
||||
|
||||
test('create share resolves known and unknown recipient emails', () async {
|
||||
final useCase = CreateShareUseCase(
|
||||
users: users,
|
||||
shares: shares,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
);
|
||||
|
||||
final result = await useCase.execute(
|
||||
senderUserId: 'sender',
|
||||
resourceType: 'program',
|
||||
payloadJson: {'name': 'Push day'},
|
||||
recipientEmails: [
|
||||
'friend@example.com',
|
||||
'missing@example.com',
|
||||
'coach@example.com',
|
||||
],
|
||||
);
|
||||
|
||||
expect(result.share.senderUserId, 'sender');
|
||||
expect(result.recipientUserIds, ['recipient-1', 'recipient-2']);
|
||||
expect(result.unresolvedEmails, ['missing@example.com']);
|
||||
expect(shares.recipients.map((recipient) => recipient.recipientUserId), [
|
||||
'recipient-1',
|
||||
'recipient-2',
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'accept creates a copied synced resource without touching source',
|
||||
() async {
|
||||
final share = _share(
|
||||
id: 'share-1',
|
||||
senderUserId: 'sender',
|
||||
resourceType: SyncedResourceType.workoutTemplate,
|
||||
payloadJson: {'schemaVersion': 3, 'name': 'Full body'},
|
||||
);
|
||||
shares.putShare(share);
|
||||
shares.putRecipient(
|
||||
ShareRecipient(
|
||||
id: 'recipient-row-1',
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'recipient-1',
|
||||
),
|
||||
);
|
||||
final source = _resource(
|
||||
serverId: 'source-server',
|
||||
ownerUserId: 'sender',
|
||||
resourceType: SyncedResourceType.workoutTemplate,
|
||||
clientId: 'source-client',
|
||||
payloadJson: {'name': 'Source'},
|
||||
);
|
||||
resources.put(source);
|
||||
final useCase = AcceptShareUseCase(
|
||||
shares: shares,
|
||||
resources: resources,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
);
|
||||
|
||||
final result = await useCase.execute(
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'recipient-1',
|
||||
);
|
||||
|
||||
expect(result.createdResource.ownerUserId, 'recipient-1');
|
||||
expect(result.createdResource.clientId, isNot('source-client'));
|
||||
expect(result.createdResource.payloadJson, share.payloadJson);
|
||||
expect(result.createdResource.schemaVersion, 3);
|
||||
expect(resources.getByServerId('source-server'), same(source));
|
||||
expect(
|
||||
shares.recipient('share-1', 'recipient-1')?.status,
|
||||
ShareRecipientStatus.accepted,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('accept on already answered share fails', () async {
|
||||
shares.putShare(_share(id: 'share-1'));
|
||||
shares.putRecipient(
|
||||
ShareRecipient(
|
||||
id: 'recipient-row-1',
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'recipient-1',
|
||||
status: ShareRecipientStatus.declined,
|
||||
respondedAt: clock.now(),
|
||||
),
|
||||
);
|
||||
final useCase = AcceptShareUseCase(
|
||||
shares: shares,
|
||||
resources: resources,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'),
|
||||
throwsA(isA<ShareConflictException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('accept on revoked share fails', () async {
|
||||
shares.putShare(_share(id: 'share-1', revokedAt: clock.now()));
|
||||
shares.putRecipient(
|
||||
ShareRecipient(
|
||||
id: 'recipient-row-1',
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'recipient-1',
|
||||
),
|
||||
);
|
||||
final useCase = AcceptShareUseCase(
|
||||
shares: shares,
|
||||
resources: resources,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1'),
|
||||
throwsA(isA<ShareConflictException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('decline marks a pending recipient as declined', () async {
|
||||
shares.putShare(_share(id: 'share-1'));
|
||||
shares.putRecipient(
|
||||
ShareRecipient(
|
||||
id: 'recipient-row-1',
|
||||
shareId: 'share-1',
|
||||
recipientUserId: 'recipient-1',
|
||||
),
|
||||
);
|
||||
final useCase = DeclineShareUseCase(shares: shares, clock: clock);
|
||||
|
||||
await useCase.execute(shareId: 'share-1', recipientUserId: 'recipient-1');
|
||||
|
||||
final recipient = shares.recipient('share-1', 'recipient-1');
|
||||
expect(recipient?.status, ShareRecipientStatus.declined);
|
||||
expect(recipient?.respondedAt, clock.now());
|
||||
});
|
||||
|
||||
test('revoke by sender marks the share revoked', () async {
|
||||
shares.putShare(_share(id: 'share-1', senderUserId: 'sender'));
|
||||
final useCase = RevokeShareUseCase(shares: shares, clock: clock);
|
||||
|
||||
await useCase.execute(shareId: 'share-1', senderUserId: 'sender');
|
||||
|
||||
expect(shares.findShareById('share-1'), completion(isNotNull));
|
||||
expect(shares.shares.single.revokedAt, clock.now());
|
||||
});
|
||||
|
||||
test('revoke by a non-sender is refused', () async {
|
||||
shares.putShare(_share(id: 'share-1', senderUserId: 'sender'));
|
||||
final useCase = RevokeShareUseCase(shares: shares, clock: clock);
|
||||
|
||||
expect(
|
||||
() => useCase.execute(shareId: 'share-1', senderUserId: 'recipient-1'),
|
||||
throwsA(isA<ShareNotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('inbox is sorted from newest to oldest', () async {
|
||||
shares.putShare(
|
||||
_share(id: 'older', createdAt: DateTime.utc(2026, 7, 19, 10)),
|
||||
);
|
||||
shares.putShare(
|
||||
_share(id: 'newer', createdAt: DateTime.utc(2026, 7, 19, 11)),
|
||||
);
|
||||
shares
|
||||
..putRecipient(
|
||||
ShareRecipient(
|
||||
id: 'recipient-row-1',
|
||||
shareId: 'older',
|
||||
recipientUserId: 'recipient-1',
|
||||
),
|
||||
)
|
||||
..putRecipient(
|
||||
ShareRecipient(
|
||||
id: 'recipient-row-2',
|
||||
shareId: 'newer',
|
||||
recipientUserId: 'recipient-1',
|
||||
),
|
||||
);
|
||||
final useCase = ListInboxUseCase(shares: shares);
|
||||
|
||||
final result = await useCase.execute(recipientUserId: 'recipient-1');
|
||||
|
||||
expect(result.map((item) => item.share.id), ['newer', 'older']);
|
||||
});
|
||||
}
|
||||
|
||||
UserAccount _user(String id, String email) {
|
||||
return UserAccount(
|
||||
id: id,
|
||||
email: email,
|
||||
passwordHash: 'hash',
|
||||
createdAt: DateTime.utc(2026, 7, 19),
|
||||
updatedAt: DateTime.utc(2026, 7, 19),
|
||||
);
|
||||
}
|
||||
|
||||
Share _share({
|
||||
required String id,
|
||||
String senderUserId = 'sender',
|
||||
SyncedResourceType resourceType = SyncedResourceType.program,
|
||||
Map<String, Object?> payloadJson = const {'schemaVersion': 1},
|
||||
DateTime? createdAt,
|
||||
DateTime? revokedAt,
|
||||
}) {
|
||||
return Share(
|
||||
id: id,
|
||||
senderUserId: senderUserId,
|
||||
resourceType: resourceType,
|
||||
payloadJson: payloadJson,
|
||||
createdAt: createdAt ?? DateTime.utc(2026, 7, 19, 12),
|
||||
revokedAt: revokedAt,
|
||||
);
|
||||
}
|
||||
|
||||
SyncedResource _resource({
|
||||
required String serverId,
|
||||
required String ownerUserId,
|
||||
required SyncedResourceType resourceType,
|
||||
required String clientId,
|
||||
required Map<String, Object?> payloadJson,
|
||||
}) {
|
||||
return SyncedResource(
|
||||
serverId: serverId,
|
||||
ownerUserId: ownerUserId,
|
||||
resourceType: resourceType,
|
||||
clientId: clientId,
|
||||
payloadJson: payloadJson,
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeUserRepository implements UserRepository {
|
||||
final _usersByEmail = <String, UserAccount>{};
|
||||
|
||||
void put(UserAccount user) {
|
||||
_usersByEmail[user.email] = user;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findByEmail(String email) async {
|
||||
return _usersByEmail[email.toLowerCase()];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findById(String id) async {
|
||||
return _usersByEmail.values.where((user) => user.id == id).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(UserAccount user) async {
|
||||
put(user);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePasswordHash({
|
||||
required String userId,
|
||||
required String passwordHash,
|
||||
required DateTime updatedAt,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
final class _FakeShareRepository implements ShareRepository {
|
||||
final shares = <Share>[];
|
||||
final recipients = <ShareRecipient>[];
|
||||
|
||||
void putShare(Share share) {
|
||||
shares.removeWhere((item) => item.id == share.id);
|
||||
shares.add(share);
|
||||
}
|
||||
|
||||
void putRecipient(ShareRecipient recipient) {
|
||||
recipients.removeWhere(
|
||||
(item) =>
|
||||
item.shareId == recipient.shareId &&
|
||||
item.recipientUserId == recipient.recipientUserId,
|
||||
);
|
||||
recipients.add(recipient);
|
||||
}
|
||||
|
||||
ShareRecipient? recipient(String shareId, String recipientUserId) {
|
||||
return recipients
|
||||
.where(
|
||||
(item) =>
|
||||
item.shareId == shareId &&
|
||||
item.recipientUserId == recipientUserId,
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertShare({
|
||||
required Share share,
|
||||
required List<ShareRecipient> recipients,
|
||||
}) async {
|
||||
putShare(share);
|
||||
for (final recipient in recipients) {
|
||||
putRecipient(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
|
||||
final items =
|
||||
[
|
||||
for (final recipient in recipients)
|
||||
if (recipient.recipientUserId == recipientUserId)
|
||||
ShareInboxItem(
|
||||
share: shares.singleWhere(
|
||||
(share) => share.id == recipient.shareId,
|
||||
),
|
||||
recipient: recipient,
|
||||
),
|
||||
]..sort(
|
||||
(left, right) =>
|
||||
right.share.createdAt.compareTo(left.share.createdAt),
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Share?> findShareById(String shareId) async {
|
||||
return shares.where((share) => share.id == shareId).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ShareRecipient?> findRecipient({
|
||||
required String shareId,
|
||||
required String recipientUserId,
|
||||
}) async {
|
||||
return recipient(shareId, recipientUserId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateRecipientStatus({
|
||||
required String recipientId,
|
||||
required ShareRecipientStatus status,
|
||||
required DateTime respondedAt,
|
||||
}) async {
|
||||
final index = recipients.indexWhere((item) => item.id == recipientId);
|
||||
final recipient = recipients[index];
|
||||
recipients[index] = ShareRecipient(
|
||||
id: recipient.id,
|
||||
shareId: recipient.shareId,
|
||||
recipientUserId: recipient.recipientUserId,
|
||||
status: status,
|
||||
respondedAt: respondedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> revokeShare({
|
||||
required String shareId,
|
||||
required DateTime revokedAt,
|
||||
}) async {
|
||||
final index = shares.indexWhere((share) => share.id == shareId);
|
||||
final share = shares[index];
|
||||
shares[index] = Share(
|
||||
id: share.id,
|
||||
senderUserId: share.senderUserId,
|
||||
resourceType: share.resourceType,
|
||||
payloadJson: share.payloadJson,
|
||||
createdAt: share.createdAt,
|
||||
revokedAt: revokedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
||||
final _items = <String, SyncedResource>{};
|
||||
|
||||
void put(SyncedResource resource) {
|
||||
_items[_key(
|
||||
resource.ownerUserId,
|
||||
resource.resourceType,
|
||||
resource.clientId,
|
||||
)] =
|
||||
resource;
|
||||
}
|
||||
|
||||
SyncedResource? getByServerId(String serverId) {
|
||||
return _items.values.where((item) => item.serverId == serverId).firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
||||
final key = _key(
|
||||
resource.ownerUserId,
|
||||
resource.resourceType,
|
||||
resource.clientId,
|
||||
);
|
||||
final existing = _items[key];
|
||||
if (existing != null &&
|
||||
!resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) {
|
||||
return SyncWriteResult(
|
||||
status: SyncWriteStatus.ignoredOlder,
|
||||
resource: existing,
|
||||
);
|
||||
}
|
||||
_items[key] = resource;
|
||||
return SyncWriteResult(
|
||||
status: SyncWriteStatus.accepted,
|
||||
resource: resource,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SyncedResource>> findAllForUserSince({
|
||||
required String ownerUserId,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
return _items.values
|
||||
.where((item) => item.ownerUserId == ownerUserId)
|
||||
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
String _key(
|
||||
String ownerUserId,
|
||||
SyncedResourceType resourceType,
|
||||
String clientId,
|
||||
) {
|
||||
return '$ownerUserId:${resourceType.wireName}:$clientId';
|
||||
}
|
||||
|
||||
final class _FakeClock implements Clock {
|
||||
_FakeClock(this.value);
|
||||
|
||||
DateTime value;
|
||||
|
||||
@override
|
||||
DateTime now() => value;
|
||||
}
|
||||
|
||||
final class _FakeIds implements IdGenerator {
|
||||
var _next = 0;
|
||||
|
||||
@override
|
||||
String newId() {
|
||||
_next += 1;
|
||||
return 'id-$_next';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user