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'); }); 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}) { 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 = []; @override Future upsertWithLww(SyncedResource resource) async { items.add(resource); return SyncWriteResult( status: SyncWriteStatus.accepted, resource: resource, ); } @override Future> 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 findByEmail(String email) async => user; @override Future findById(String id) async => id == user.id ? user : null; @override Future insert(UserAccount user) async {} @override Future 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 findByTokenHash(String tokenHash) async { return tokenHash == session.tokenHash ? session : null; } @override Future insert(AuthSession session) async {} @override Future 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'; } }