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>
This commit is contained in:
273
server/test/sync_use_cases_test.dart
Normal file
273
server/test/sync_use_cases_test.dart
Normal file
@ -0,0 +1,273 @@
|
||||
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<String, Object?> 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 = <String, SyncedResource>{};
|
||||
|
||||
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<SyncWriteResult> 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<List<SyncedResource>> 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user