import 'package:gametime_server/application/application.dart'; import 'package:gametime_server/domain/domain.dart'; import 'package:test/test.dart'; void main() { late _FakeSyncedResourceRepository resources; late _FakeClock clock; late _FakeIds ids; setUp(() { clock = _FakeClock(DateTime.utc(2026, 7, 19, 12)); ids = _FakeIds(); resources = _FakeSyncedResourceRepository(); }); test( 'push inserts, updates newer items and ignores older or equal items', () async { resources.put( _resource( serverId: 'existing-server-id', resourceType: SyncedResourceType.exercise, clientId: 'exercise-1', clientUpdatedAt: DateTime.utc(2026, 7, 19, 10), serverUpdatedAt: DateTime.utc(2026, 7, 19, 11), payloadJson: {'name': 'old'}, ), ); final useCase = PushSyncUseCase( resources: resources, clock: clock, ids: ids, ); final result = await useCase.execute( ownerUserId: 'user-1', deviceId: 'device-1', items: [ PushSyncItemInput( resourceType: 'program', clientId: 'program-1', schemaVersion: 1, clientUpdatedAt: DateTime.utc(2026, 7, 19, 9), deletedAt: null, payloadJson: {'name': 'program'}, ), PushSyncItemInput( resourceType: 'exercise', clientId: 'exercise-1', schemaVersion: 1, clientUpdatedAt: DateTime.utc(2026, 7, 19, 10), deletedAt: null, payloadJson: {'name': 'same'}, ), PushSyncItemInput( resourceType: 'exercise', clientId: 'exercise-1', schemaVersion: 2, clientUpdatedAt: DateTime.utc(2026, 7, 19, 10, 1), deletedAt: null, payloadJson: {'name': 'new'}, ), const PushSyncItemInput( resourceType: 'unknown', clientId: 'bad-1', schemaVersion: 1, clientUpdatedAt: null, deletedAt: null, payloadJson: {}, ), ], ); expect(result.results.map((item) => item.status), [ 'accepted', 'ignoredOlder', 'accepted', 'error', ]); final updated = resources.get( 'user-1', SyncedResourceType.exercise, 'exercise-1', ); expect(updated?.serverId, 'existing-server-id'); expect(updated?.payloadJson, {'name': 'new'}); expect(updated?.schemaVersion, 2); expect(updated?.originDeviceId, 'device-1'); }, ); test( 'pull returns resources newer than cursor including soft deletes', () async { resources ..put( _resource( serverId: 'resource-1', resourceType: SyncedResourceType.exercise, clientId: 'exercise-1', serverUpdatedAt: DateTime.utc(2026, 7, 19, 10), ), ) ..put( _resource( serverId: 'resource-2', resourceType: SyncedResourceType.program, clientId: 'program-1', serverUpdatedAt: DateTime.utc(2026, 7, 19, 11), deletedAt: DateTime.utc(2026, 7, 19, 10, 30), ), ); final useCase = PullSyncUseCase(resources: resources, clock: clock); final result = await useCase.execute( ownerUserId: 'user-1', since: DateTime.utc(2026, 7, 19, 10, 30), ); expect(result.items.map((item) => item.clientId), ['program-1']); expect(result.items.single.deletedAt, DateTime.utc(2026, 7, 19, 10, 30)); expect(result.serverCursor, DateTime.utc(2026, 7, 19, 11)); }, ); test('exchange pushes first then pulls the updated state', () async { final push = PushSyncUseCase(resources: resources, clock: clock, ids: ids); final pull = PullSyncUseCase(resources: resources, clock: clock); final useCase = ExchangeSyncUseCase(push: push, pull: pull); final result = await useCase.execute( ownerUserId: 'user-1', deviceId: 'device-1', items: [ PushSyncItemInput( resourceType: 'mediaAsset', clientId: 'media-1', schemaVersion: 1, clientUpdatedAt: DateTime.utc(2026, 7, 19, 10), deletedAt: null, payloadJson: {'localUri': 'file://image.jpg'}, ), ], ); expect(result.push.results.single.status, 'accepted'); expect(result.pull.items.single.clientId, 'media-1'); }); } SyncedResource _resource({ required String serverId, required SyncedResourceType resourceType, required String clientId, DateTime? clientUpdatedAt, DateTime? serverUpdatedAt, DateTime? deletedAt, Map payloadJson = const {}, }) { return SyncedResource( serverId: serverId, ownerUserId: 'user-1', resourceType: resourceType, clientId: clientId, payloadJson: payloadJson, schemaVersion: 1, clientUpdatedAt: clientUpdatedAt ?? DateTime.utc(2026, 7, 19, 9), serverUpdatedAt: serverUpdatedAt ?? DateTime.utc(2026, 7, 19, 10), deletedAt: deletedAt, originDeviceId: 'device-1', ); } final class _FakeSyncedResourceRepository implements SyncedResourceRepository { final _items = {}; void put(SyncedResource resource) { _items[_key( resource.ownerUserId, resource.resourceType, resource.clientId, )] = resource; } SyncedResource? get( String ownerUserId, SyncedResourceType resourceType, String clientId, ) { return _items[_key(ownerUserId, resourceType, clientId)]; } @override Future upsertWithLww(SyncedResource resource) async { final key = _key( resource.ownerUserId, resource.resourceType, resource.clientId, ); final existing = _items[key]; if (existing != null && !resource.clientUpdatedAt.isAfter(existing.clientUpdatedAt)) { return SyncWriteResult( status: SyncWriteStatus.ignoredOlder, resource: existing, ); } final written = existing == null ? resource : 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[key] = written; return SyncWriteResult(status: SyncWriteStatus.accepted, resource: written); } @override Future> findAllForUserSince({ required String ownerUserId, DateTime? since, }) async { final result = _items.values .where((item) => item.ownerUserId == ownerUserId) .where( (item) => since == null || item.serverUpdatedAt.isAfter(since.toUtc()), ) .toList() ..sort( (left, right) => left.serverUpdatedAt.compareTo(right.serverUpdatedAt), ); return result; } } String _key( String ownerUserId, SyncedResourceType resourceType, String clientId, ) { return '$ownerUserId:${resourceType.wireName}:$clientId'; } 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 'id-$_next'; } }