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