feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé : - télémétrie fréquence cardiaque live remontée montre -> téléphone (collecteur watch, adapter Wear Data Layer, persistance Drift, propagation aux écrans historique/programme/profil/exécution) - notifications de séance en arrière-plan côté téléphone (service foreground de statut + passerelle applicative) - finitions montre : chrono d'étape, score d'étape, retrait du bouton "lancer une séance", thème, icônes et polices watch_app Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -62,6 +62,24 @@ void main() {
|
||||
expect(response.statusCode, 409);
|
||||
});
|
||||
|
||||
test('register endpoint returns 400 for malformed json payload', () async {
|
||||
final handler = buildApiHandler(authApi: _authApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/auth/register'),
|
||||
body: jsonEncode(['not-an-object']),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Request body must be a JSON object.'},
|
||||
);
|
||||
});
|
||||
|
||||
test('login endpoint returns a token and expiration', () async {
|
||||
final users = _FakeUserRepository();
|
||||
await users.insert(
|
||||
@ -121,6 +139,24 @@ void main() {
|
||||
expect(response.statusCode, 401);
|
||||
});
|
||||
|
||||
test('login endpoint returns 400 when email is blank', () async {
|
||||
final handler = buildApiHandler(authApi: _authApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/auth/login'),
|
||||
body: jsonEncode({'email': ' ', 'password': 'password123'}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'email must be a non-empty string.'},
|
||||
);
|
||||
});
|
||||
|
||||
test('logout endpoint revokes the bearer session', () async {
|
||||
final users = _FakeUserRepository();
|
||||
await users.insert(
|
||||
@ -157,6 +193,20 @@ void main() {
|
||||
expect(response.statusCode, 204);
|
||||
expect(sessions.revokedSessionIds, ['session-1']);
|
||||
});
|
||||
|
||||
test('logout endpoint returns 401 without bearer token', () async {
|
||||
final handler = buildApiHandler(authApi: _authApi());
|
||||
|
||||
final response = await handler(
|
||||
Request('POST', Uri.parse('http://localhost/auth/logout')),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 401);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Missing bearer token.'},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
AuthApi _authApi({
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
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';
|
||||
@ -22,20 +24,342 @@ void main() {
|
||||
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['recipientUserIds'], ['user-2']);
|
||||
expect(body['unresolvedEmails'], ['missing@example.com']);
|
||||
expect(shares.shareById['id-1']?.resourceType, SyncedResourceType.program);
|
||||
});
|
||||
|
||||
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)['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 resources = _FakeSyncedResourceRepository();
|
||||
final handler = buildApiHandler(
|
||||
shareApi: _shareApi(shares: shares, resources: resources),
|
||||
);
|
||||
|
||||
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(shares.recipientById['recipient-1']?.status,
|
||||
ShareRecipientStatus.accepted);
|
||||
expect(resources.items.single.ownerUserId, 'user-1');
|
||||
});
|
||||
|
||||
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() {
|
||||
ShareApi _shareApi({
|
||||
_FakeUserRepository? users,
|
||||
_FakeShareRepository? shares,
|
||||
_FakeSyncedResourceRepository? resources,
|
||||
}) {
|
||||
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 userRepository =
|
||||
users ??
|
||||
_FakeUserRepository([_user('user-1', 'user@example.com')]);
|
||||
final sessions = _FakeAuthSessionRepository(
|
||||
AuthSession(
|
||||
id: 'session-1',
|
||||
@ -47,46 +371,49 @@ ShareApi _shareApi() {
|
||||
);
|
||||
final tokens = _FakeTokenService();
|
||||
final authenticate = AuthenticateRequestUseCase(
|
||||
users: users,
|
||||
users: userRepository,
|
||||
sessions: sessions,
|
||||
tokens: tokens,
|
||||
clock: clock,
|
||||
);
|
||||
final shares = _FakeShareRepository();
|
||||
final resources = _FakeSyncedResourceRepository();
|
||||
final shareRepository = shares ?? _FakeShareRepository();
|
||||
final resourceRepository = resources ?? _FakeSyncedResourceRepository();
|
||||
return ShareApi(
|
||||
createShare: CreateShareUseCase(
|
||||
users: users,
|
||||
shares: shares,
|
||||
users: userRepository,
|
||||
shares: shareRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
listInbox: ListInboxUseCase(shares: shares),
|
||||
listInbox: ListInboxUseCase(shares: shareRepository),
|
||||
acceptShare: AcceptShareUseCase(
|
||||
shares: shares,
|
||||
resources: resources,
|
||||
shares: shareRepository,
|
||||
resources: resourceRepository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
),
|
||||
declineShare: DeclineShareUseCase(shares: shares, clock: clock),
|
||||
revokeShare: RevokeShareUseCase(shares: shares, clock: clock),
|
||||
declineShare: DeclineShareUseCase(shares: shareRepository, clock: clock),
|
||||
revokeShare: RevokeShareUseCase(shares: shareRepository, clock: clock),
|
||||
authenticateRequest: authenticate,
|
||||
);
|
||||
}
|
||||
|
||||
final class _FakeUserRepository implements UserRepository {
|
||||
const _FakeUserRepository(this.user);
|
||||
_FakeUserRepository(List<UserAccount> users)
|
||||
: _byId = {for (final user in users) user.id: user},
|
||||
_byEmail = {for (final user in users) user.email: user};
|
||||
|
||||
final UserAccount user;
|
||||
final Map<String, UserAccount> _byId;
|
||||
final Map<String, UserAccount> _byEmail;
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findByEmail(String email) async {
|
||||
return user.email == email.toLowerCase() ? user : null;
|
||||
return _byEmail[email.trim().toLowerCase()];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserAccount?> findById(String id) async {
|
||||
return id == user.id ? user : null;
|
||||
return _byId[id];
|
||||
}
|
||||
|
||||
@override
|
||||
@ -121,20 +448,49 @@ final class _FakeAuthSessionRepository implements AuthSessionRepository {
|
||||
}
|
||||
|
||||
final class _FakeShareRepository implements ShareRepository {
|
||||
final shareById = <String, Share>{};
|
||||
final recipientById = <String, ShareRecipient>{};
|
||||
final recipientByKey = <String, ShareRecipient>{};
|
||||
final inboxByRecipient = <String, List<ShareInboxItem>>{};
|
||||
|
||||
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 {}
|
||||
}) async {
|
||||
seedShare(share: share, recipients: recipients);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ShareInboxItem>> listInbox(String recipientUserId) async {
|
||||
return const [];
|
||||
return inboxByRecipient[recipientUserId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Share?> findShareById(String shareId) async {
|
||||
return null;
|
||||
return shareById[shareId];
|
||||
}
|
||||
|
||||
@override
|
||||
@ -142,7 +498,7 @@ final class _FakeShareRepository implements ShareRepository {
|
||||
required String shareId,
|
||||
required String recipientUserId,
|
||||
}) async {
|
||||
return null;
|
||||
return recipientByKey['$shareId:$recipientUserId'];
|
||||
}
|
||||
|
||||
@override
|
||||
@ -150,18 +506,60 @@ final class _FakeShareRepository implements ShareRepository {
|
||||
required String recipientId,
|
||||
required ShareRecipientStatus status,
|
||||
required DateTime respondedAt,
|
||||
}) async {}
|
||||
}) 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<void> revokeShare({
|
||||
required String shareId,
|
||||
required DateTime revokedAt,
|
||||
}) async {}
|
||||
}) async {
|
||||
final existing = shareById[shareId];
|
||||
if (existing == null) {
|
||||
return;
|
||||
}
|
||||
final updated = Share(
|
||||
id: existing.id,
|
||||
senderUserId: existing.senderUserId,
|
||||
resourceType: existing.resourceType,
|
||||
payloadJson: existing.payloadJson,
|
||||
createdAt: existing.createdAt,
|
||||
revokedAt: revokedAt,
|
||||
);
|
||||
shareById[shareId] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@ -173,7 +571,10 @@ final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
||||
required String ownerUserId,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
return const [];
|
||||
return items
|
||||
.where((item) => item.ownerUserId == ownerUserId)
|
||||
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,3 +604,13 @@ final class _FakeIds implements IdGenerator {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,6 +62,140 @@ void main() {
|
||||
]);
|
||||
expect(repository.items.single.clientId, 'exercise-1');
|
||||
});
|
||||
|
||||
test('pull returns synced items and filters them with since query', () async {
|
||||
final repository = _FakeSyncedResourceRepository()
|
||||
..items.addAll([
|
||||
SyncedResource(
|
||||
serverId: 'resource-1',
|
||||
ownerUserId: 'user-1',
|
||||
resourceType: SyncedResourceType.exercise,
|
||||
clientId: 'exercise-1',
|
||||
payloadJson: {'name': 'Squat'},
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 19, 11),
|
||||
),
|
||||
SyncedResource(
|
||||
serverId: 'resource-2',
|
||||
ownerUserId: 'user-1',
|
||||
resourceType: SyncedResourceType.program,
|
||||
clientId: 'program-1',
|
||||
payloadJson: {'name': 'Programme A'},
|
||||
schemaVersion: 2,
|
||||
clientUpdatedAt: DateTime.utc(2026, 7, 19, 11),
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 19, 12, 30),
|
||||
),
|
||||
]);
|
||||
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'GET',
|
||||
Uri.parse(
|
||||
'http://localhost/sync/pull?since=2026-07-19T12:00:00Z',
|
||||
),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
final items = body['items'] as List;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(body['serverCursor'], '2026-07-19T12:30:00.000Z');
|
||||
expect(items, hasLength(1));
|
||||
expect((items.single as Map)['clientId'], 'program-1');
|
||||
expect((items.single as Map)['resourceType'], 'program');
|
||||
});
|
||||
|
||||
test('pull returns 400 for invalid since query parameter', () async {
|
||||
final handler = buildApiHandler(syncApi: _syncApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'GET',
|
||||
Uri.parse('http://localhost/sync/pull?since=not-a-date'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'Invalid date format'},
|
||||
);
|
||||
});
|
||||
|
||||
test('exchange returns push results followed by pulled items', () async {
|
||||
final repository = _FakeSyncedResourceRepository()
|
||||
..items.add(
|
||||
SyncedResource(
|
||||
serverId: 'existing-1',
|
||||
ownerUserId: 'user-1',
|
||||
resourceType: SyncedResourceType.workoutHistory,
|
||||
clientId: 'history-1',
|
||||
payloadJson: {'score': 12},
|
||||
schemaVersion: 1,
|
||||
clientUpdatedAt: DateTime.utc(2026, 7, 19, 8),
|
||||
serverUpdatedAt: DateTime.utc(2026, 7, 19, 9),
|
||||
),
|
||||
);
|
||||
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/sync/exchange'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
body: jsonEncode({
|
||||
'deviceId': 'device-1',
|
||||
'since': '2026-07-19T08:30:00Z',
|
||||
'items': [
|
||||
{
|
||||
'resourceType': 'exercise',
|
||||
'clientId': 'exercise-2',
|
||||
'schemaVersion': 1,
|
||||
'clientUpdatedAt': '2026-07-19T10:00:00Z',
|
||||
'payload': {'name': 'Lunge'},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
final body = jsonDecode(await response.readAsString()) as Map;
|
||||
final pushResults = body['pushResults'] as List;
|
||||
final items = body['items'] as List;
|
||||
|
||||
expect(response.statusCode, 200);
|
||||
expect(pushResults, hasLength(1));
|
||||
expect((pushResults.single as Map)['status'], 'accepted');
|
||||
expect(items, hasLength(2));
|
||||
expect(
|
||||
items.map((item) => (item as Map)['clientId']),
|
||||
containsAll(['history-1', 'exercise-2']),
|
||||
);
|
||||
});
|
||||
|
||||
test('exchange returns 400 when items is not a json array', () async {
|
||||
final handler = buildApiHandler(syncApi: _syncApi());
|
||||
|
||||
final response = await handler(
|
||||
Request(
|
||||
'POST',
|
||||
Uri.parse('http://localhost/sync/exchange'),
|
||||
headers: {'authorization': 'Bearer valid-token'},
|
||||
body: jsonEncode({'deviceId': 'device-1', 'items': 'not-a-list'}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.statusCode, 400);
|
||||
expect(
|
||||
jsonDecode(await response.readAsString()),
|
||||
{'error': 'items must be a JSON array.'},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
SyncApi _syncApi({_FakeSyncedResourceRepository? resources}) {
|
||||
|
||||
Reference in New Issue
Block a user