Files
GameTime/server/test/sync_api_test.dart
Blomios 60912ebde4 feat(server): API de synchronisation incrémentale LWW (ticket #50)
Ajoute l'endpoint api/sync_api.dart, les use cases de synchronisation
(application/sync_use_cases.dart) et l'adapter Postgres
(infrastructure/postgres/synced_resource_repository.dart) implémentant
un upsert LWW atomique via CTE (INSERT ... ON CONFLICT ... WHERE
client_updated_at < EXCLUDED.client_updated_at, avec fallback UNION
ALL pour le cas ignoré). dart pub get OK, dart analyze clean, dart
test 15/15 vert. SQL d'upsert 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>
2026-07-19 10:23:36 +02:00

196 lines
5.1 KiB
Dart

import 'dart:convert';
import 'package:gametime_server/api/router.dart';
import 'package:gametime_server/api/sync_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('sync routes require bearer authentication', () async {
final handler = buildApiHandler(syncApi: _syncApi());
final response = await handler(
Request('GET', Uri.parse('http://localhost/sync/pull')),
);
expect(response.statusCode, 401);
});
test('push parses item errors without blocking valid items', () async {
final repository = _FakeSyncedResourceRepository();
final handler = buildApiHandler(syncApi: _syncApi(resources: repository));
final response = await handler(
Request(
'POST',
Uri.parse('http://localhost/sync/push'),
headers: {'authorization': 'Bearer valid-token'},
body: jsonEncode({
'deviceId': 'device-1',
'items': [
{
'resourceType': 'exercise',
'clientId': 'exercise-1',
'schemaVersion': 1,
'clientUpdatedAt': '2026-07-19T10:00:00Z',
'deletedAt': null,
'payload': {'name': 'Squat'},
},
{
'resourceType': 'bad',
'clientId': 'bad-1',
'schemaVersion': 1,
'clientUpdatedAt': '2026-07-19T10:00:00Z',
'payload': {},
},
'malformed',
],
}),
),
);
final body = jsonDecode(await response.readAsString()) as Map;
final results = body['results'] as List;
expect(response.statusCode, 200);
expect(results.map((item) => (item as Map)['status']), [
'accepted',
'error',
'error',
]);
expect(repository.items.single.clientId, 'exercise-1');
});
}
SyncApi _syncApi({_FakeSyncedResourceRepository? resources}) {
final repository = resources ?? _FakeSyncedResourceRepository();
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
final ids = _FakeIds();
final user = UserAccount(
id: 'user-1',
email: 'user@example.com',
passwordHash: 'hash',
createdAt: clock.now(),
updatedAt: clock.now(),
);
final session = AuthSession(
id: 'session-1',
userId: user.id,
tokenHash: 'token-hash:valid-token',
issuedAt: clock.now(),
expiresAt: clock.now().add(const Duration(days: 1)),
);
final users = _FakeUserRepository(user);
final sessions = _FakeAuthSessionRepository(session);
final tokens = _FakeTokenService();
final authenticate = AuthenticateRequestUseCase(
users: users,
sessions: sessions,
tokens: tokens,
clock: clock,
);
final push = PushSyncUseCase(resources: repository, clock: clock, ids: ids);
final pull = PullSyncUseCase(resources: repository, clock: clock);
return SyncApi(
pushSync: push,
pullSync: pull,
exchangeSync: ExchangeSyncUseCase(push: push, pull: pull),
authenticateRequest: authenticate,
);
}
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 _FakeUserRepository implements UserRepository {
const _FakeUserRepository(this.user);
final UserAccount user;
@override
Future<UserAccount?> findByEmail(String email) async => user;
@override
Future<UserAccount?> findById(String id) async => 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 _FakeTokenService implements OpaqueTokenService {
@override
String generateToken() => 'valid-token';
@override
String hashToken(String token) => 'token-hash:$token';
}
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 'server-id-$_next';
}
}