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'; import 'fixtures/sync_fixtures.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( 'push and pull round-trip versioned exercise fixtures strictly', () async { final repository = _FakeSyncedResourceRepository(); final handler = buildApiHandler(syncApi: _syncApi(resources: repository)); final fixtures = loadExerciseFixtures(); final pushResponse = await handler( Request( 'POST', Uri.parse('http://localhost/sync/push'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode({ 'deviceId': 'device-1', 'items': [ for (var index = 0; index < fixtures.length; index += 1) { 'resourceType': fixtures[index].resourceType, 'clientId': 'exercise-${fixtures[index].name}', 'schemaVersion': fixtures[index].schemaVersion, 'clientUpdatedAt': '2026-07-19T10:${index.toString().padLeft(2, '0')}:00Z', 'deletedAt': null, 'payload': fixtures[index].payload, }, ], }), ), ); final pushBody = jsonDecode(await pushResponse.readAsString()) as Map; final pullResponse = await handler( Request( 'GET', Uri.parse('http://localhost/sync/pull'), headers: {'authorization': 'Bearer valid-token'}, ), ); final pullBody = jsonDecode(await pullResponse.readAsString()) as Map; final pulledItems = pullBody['items'] as List; expect(pushResponse.statusCode, 200); expect( (pushBody['results'] as List).map((item) => (item as Map)['status']), everyElement('accepted'), ); expect(pullResponse.statusCode, 200); expect(pulledItems, hasLength(fixtures.length)); for (final fixture in fixtures) { final pulled = pulledItems.cast().singleWhere( (item) => item['clientId'] == 'exercise-${fixture.name}', ); expect(pulled['resourceType'], 'exercise'); expect(pulled['schemaVersion'], fixture.schemaVersion); expect(pulled['payload'], fixture.payload); } }, ); test('push and pull round-trip workoutTemplate fixture strictly', () async { final repository = _FakeSyncedResourceRepository(); final handler = buildApiHandler(syncApi: _syncApi(resources: repository)); final fixture = loadWorkoutTemplateFixture('minimal'); final pushResponse = await handler( Request( 'POST', Uri.parse('http://localhost/sync/push'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode({ 'deviceId': 'device-1', 'items': [ { 'resourceType': fixture.resourceType, 'clientId': 'template-minimal', 'schemaVersion': fixture.schemaVersion, 'clientUpdatedAt': '2026-07-19T10:30:00Z', 'payload': fixture.payload, }, ], }), ), ); final pushBody = jsonDecode(await pushResponse.readAsString()) as Map; final pullResponse = await handler( Request( 'GET', Uri.parse('http://localhost/sync/pull'), headers: {'authorization': 'Bearer valid-token'}, ), ); final pullBody = jsonDecode(await pullResponse.readAsString()) as Map; final pulled = (pullBody['items'] as List).cast().single; expect(pushResponse.statusCode, 200); expect(((pushBody['results'] as List).single as Map)['status'], 'accepted'); expect(pullResponse.statusCode, 200); expect(pulled['resourceType'], 'workoutTemplate'); expect(pulled['clientId'], 'template-minimal'); expect(pulled['schemaVersion'], fixture.schemaVersion); expect(pulled['payload'], fixture.payload); }); test( 'push and pull round-trip all reusable minimal resource fixtures', () async { final repository = _FakeSyncedResourceRepository(); final handler = buildApiHandler(syncApi: _syncApi(resources: repository)); final fixtures = loadMinimalResourceFixtures(); final pushResponse = await handler( Request( 'POST', Uri.parse('http://localhost/sync/push'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode({ 'deviceId': 'device-1', 'items': [ for (var index = 0; index < fixtures.length; index += 1) { 'resourceType': fixtures[index].resourceType, 'clientId': '${fixtures[index].resourceType}-minimal', 'schemaVersion': fixtures[index].schemaVersion, 'clientUpdatedAt': '2026-07-19T11:${index.toString().padLeft(2, '0')}:00Z', 'payload': fixtures[index].payload, }, ], }), ), ); final pushBody = jsonDecode(await pushResponse.readAsString()) as Map; final pullResponse = await handler( Request( 'GET', Uri.parse('http://localhost/sync/pull'), headers: {'authorization': 'Bearer valid-token'}, ), ); final pulledItems = (jsonDecode(await pullResponse.readAsString()) as Map)['items'] as List; expect(pushResponse.statusCode, 200); expect( (pushBody['results'] as List).map((item) => (item as Map)['status']), everyElement('accepted'), ); expect(pullResponse.statusCode, 200); for (final fixture in fixtures) { final pulled = pulledItems.cast().singleWhere( (item) => item['clientId'] == '${fixture.resourceType}-minimal', ); expect(pulled['resourceType'], fixture.resourceType); expect(pulled['schemaVersion'], fixture.schemaVersion); expect(pulled['payload'], fixture.payload); } }, ); test( 'push and pull preserve exercise fixture batches across versions', () async { final repository = _FakeSyncedResourceRepository(); final handler = buildApiHandler(syncApi: _syncApi(resources: repository)); final fixtures = [ loadExerciseFixture('minimal'), loadExerciseFixture('minimal', version: 'v2'), ]; final pushResponse = await handler( Request( 'POST', Uri.parse('http://localhost/sync/push'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode({ 'deviceId': 'device-1', 'items': [ for (var index = 0; index < fixtures.length; index += 1) { 'resourceType': 'exercise', 'clientId': 'exercise-versioned-$index', 'schemaVersion': fixtures[index].schemaVersion, 'clientUpdatedAt': '2026-07-19T12:${index.toString().padLeft(2, '0')}:00Z', 'payload': fixtures[index].payload, }, ], }), ), ); final pushBody = jsonDecode(await pushResponse.readAsString()) as Map; final pullResponse = await handler( Request( 'GET', Uri.parse('http://localhost/sync/pull'), headers: {'authorization': 'Bearer valid-token'}, ), ); final pulledItems = (jsonDecode(await pullResponse.readAsString()) as Map)['items'] as List; expect(pushResponse.statusCode, 200); expect( (pushBody['results'] as List).map((item) => (item as Map)['status']), everyElement('accepted'), ); expect(pullResponse.statusCode, 200); for (var index = 0; index < fixtures.length; index += 1) { final pulled = pulledItems.cast().singleWhere( (item) => item['clientId'] == 'exercise-versioned-$index', ); expect(pulled['schemaVersion'], fixtures[index].schemaVersion); expect(pulled['payload'], fixtures[index].payload); } }, ); test('push delete stores deletedAt and pull returns the tombstone', () async { final repository = _FakeSyncedResourceRepository(); final handler = buildApiHandler(syncApi: _syncApi(resources: repository)); final fixture = loadWorkoutTemplateFixture('minimal'); final response = await handler( Request( 'POST', Uri.parse('http://localhost/sync/push'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode({ 'deviceId': 'device-1', 'items': [ { 'resourceType': 'workoutTemplate', 'clientId': 'template-delete', 'schemaVersion': fixture.schemaVersion, 'clientUpdatedAt': '2026-07-19T10:30:00Z', 'deletedAt': null, 'payload': fixture.payload, }, { 'resourceType': 'workoutTemplate', 'clientId': 'template-delete', 'schemaVersion': fixture.schemaVersion, 'clientUpdatedAt': '2026-07-19T10:31:00Z', 'deletedAt': '2026-07-19T10:31:00Z', 'payload': fixture.payload, }, ], }), ), ); final body = jsonDecode(await response.readAsString()) as Map; final results = body['results'] as List; final pullResponse = await handler( Request( 'GET', Uri.parse('http://localhost/sync/pull'), headers: {'authorization': 'Bearer valid-token'}, ), ); final pullBody = jsonDecode(await pullResponse.readAsString()) as Map; final pulled = (pullBody['items'] as List).cast().single; expect(response.statusCode, 200); expect(results.map((item) => (item as Map)['status']), [ 'accepted', 'accepted', ]); expect(pullResponse.statusCode, 200); expect(pulled['resourceType'], 'workoutTemplate'); expect(pulled['clientId'], 'template-delete'); expect(pulled['deletedAt'], '2026-07-19T10:31:00.000Z'); expect(pulled['schemaVersion'], fixture.schemaVersion); expect(pulled['payload'], fixture.payload); }); test('push applies LWW accepted, older ignored and equal ignored', () async { final repository = _FakeSyncedResourceRepository(); final handler = buildApiHandler(syncApi: _syncApi(resources: repository)); final original = loadExerciseFixture('with_score_chrono'); final stale = loadExerciseFixture('minimal'); final newer = loadExerciseFixture('full_combo'); 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-lww', 'schemaVersion': original.schemaVersion, 'clientUpdatedAt': '2026-07-19T10:00:00Z', 'payload': original.payload, }, { 'resourceType': 'exercise', 'clientId': 'exercise-lww', 'schemaVersion': stale.schemaVersion, 'clientUpdatedAt': '2026-07-19T09:59:59Z', 'payload': stale.payload, }, { 'resourceType': 'exercise', 'clientId': 'exercise-lww', 'schemaVersion': stale.schemaVersion, 'clientUpdatedAt': '2026-07-19T10:00:00Z', 'payload': stale.payload, }, { 'resourceType': 'exercise', 'clientId': 'exercise-lww', 'schemaVersion': newer.schemaVersion, 'clientUpdatedAt': '2026-07-19T10:00:01Z', 'payload': newer.payload, }, ], }), ), ); final body = jsonDecode(await response.readAsString()) as Map; final results = body['results'] as List; final stored = repository.items.single; expect(response.statusCode, 200); expect(results.map((item) => (item as Map)['status']), [ 'accepted', 'ignoredOlder', 'ignoredOlder', 'accepted', ]); expect(stored.schemaVersion, newer.schemaVersion); expect(stored.payloadJson, newer.payload); }); 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('push returns 400 when request body is not a json object', () async { final handler = buildApiHandler(syncApi: _syncApi()); final response = await handler( Request( 'POST', Uri.parse('http://localhost/sync/push'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode(['not-an-object']), ), ); expect(response.statusCode, 400); expect(jsonDecode(await response.readAsString()), { 'error': 'Request body must be a JSON object.', }); }); test( 'push rejects blank device id per item without storing payloads', () async { final repository = _FakeSyncedResourceRepository(); final handler = buildApiHandler(syncApi: _syncApi(resources: repository)); final fixture = loadExerciseFixture('minimal'); final response = await handler( Request( 'POST', Uri.parse('http://localhost/sync/push'), headers: {'authorization': 'Bearer valid-token'}, body: jsonEncode({ 'deviceId': ' ', 'items': [ { 'resourceType': 'exercise', 'clientId': 'exercise-blank-device', 'schemaVersion': fixture.schemaVersion, 'clientUpdatedAt': '2026-07-19T10:00:00Z', 'payload': fixture.payload, }, ], }), ), ); final body = jsonDecode(await response.readAsString()) as Map; final results = body['results'] as List; expect(response.statusCode, 200); expect((results.single as Map)['status'], 'error'); expect((results.single as Map)['message'], 'deviceId must not be blank.'); expect(repository.items, isEmpty); }, ); 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 { final existingIndex = items.indexWhere( (item) => item.ownerUserId == resource.ownerUserId && item.resourceType == resource.resourceType && item.clientId == resource.clientId, ); if (existingIndex != -1) { final existing = items[existingIndex]; if (!resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) { return SyncWriteResult( status: SyncWriteStatus.ignoredOlder, resource: existing, ); } final written = SyncedResource( serverId: existing.serverId, ownerUserId: existing.ownerUserId, resourceType: existing.resourceType, clientId: existing.clientId, payloadJson: resource.payloadJson, schemaVersion: resource.schemaVersion, clientUpdatedAt: resource.clientUpdatedAt, serverUpdatedAt: resource.serverUpdatedAt, deletedAt: resource.deletedAt, originDeviceId: resource.originDeviceId, ); items[existingIndex] = written; return SyncWriteResult( status: SyncWriteStatus.accepted, resource: written, ); } items.add(resource); return SyncWriteResult( status: SyncWriteStatus.accepted, resource: resource, ); } @override Future> findAllForUserSince({ required String ownerUserId, DateTime? since, }) async { final result = items .where((item) => item.ownerUserId == ownerUserId) .where((item) => since == null || item.serverUpdatedAt.isAfter(since)) .toList(); result.sort( (left, right) => left.serverUpdatedAt.compareTo(right.serverUpdatedAt), ); return result; } } 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'; } }