Files
GameTime/server/test/sync_use_cases_test.dart
Blomios 89558808af test(server): ajoute tests fonctionnels sync et fixtures versionnees (#187)
Fixe .gitignore pour exclure .build-home/ et .pub-cache-local/ des
environnements locaux de build (bruit non versionne). Documente le
checklist d'integration serveur et met a jour le README en consequence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 16:58:07 +02:00

501 lines
15 KiB
Dart

import 'package:gametime_server/application/application.dart';
import 'package:gametime_server/domain/domain.dart';
import 'package:test/test.dart';
import 'fixtures/sync_fixtures.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('push and pull preserve versioned exercise fixtures strictly', () async {
final push = PushSyncUseCase(resources: resources, clock: clock, ids: ids);
final pull = PullSyncUseCase(resources: resources, clock: clock);
final fixtures = loadExerciseFixtures();
final pushResult = await push.execute(
ownerUserId: 'user-1',
deviceId: 'device-1',
items: [
for (var index = 0; index < fixtures.length; index += 1)
PushSyncItemInput(
resourceType: fixtures[index].resourceType,
clientId: 'exercise-${fixtures[index].name}',
schemaVersion: fixtures[index].schemaVersion,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10, index),
deletedAt: null,
payloadJson: fixtures[index].payload,
),
],
);
final pullResult = await pull.execute(ownerUserId: 'user-1');
expect(
pushResult.results.map((item) => item.status),
everyElement('accepted'),
);
expect(pullResult.items, hasLength(fixtures.length));
for (final fixture in fixtures) {
final pulled = pullResult.items.singleWhere(
(item) => item.clientId == 'exercise-${fixture.name}',
);
expect(pulled.resourceType, SyncedResourceType.exercise);
expect(pulled.schemaVersion, fixture.schemaVersion);
expect(pulled.payloadJson, fixture.payload);
}
});
test(
'push and pull preserve exercise fixture batches across versions',
() async {
final push = PushSyncUseCase(
resources: resources,
clock: clock,
ids: ids,
);
final pull = PullSyncUseCase(resources: resources, clock: clock);
final fixtures = [
loadExerciseFixture('minimal'),
loadExerciseFixture('minimal', version: 'v2'),
];
final pushResult = await push.execute(
ownerUserId: 'user-1',
deviceId: 'device-1',
items: [
for (var index = 0; index < fixtures.length; index += 1)
PushSyncItemInput(
resourceType: fixtures[index].resourceType,
clientId: 'exercise-versioned-$index',
schemaVersion: fixtures[index].schemaVersion,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10, index),
deletedAt: null,
payloadJson: fixtures[index].payload,
),
],
);
final pullResult = await pull.execute(ownerUserId: 'user-1');
expect(pushResult.results.map((item) => item.status), [
'accepted',
'accepted',
]);
for (var index = 0; index < fixtures.length; index += 1) {
final pulled = pullResult.items.singleWhere(
(item) => item.clientId == 'exercise-versioned-$index',
);
expect(pulled.schemaVersion, fixtures[index].schemaVersion);
expect(pulled.payloadJson, fixtures[index].payload);
}
},
);
test(
'push and pull preserve reusable non-exercise fixtures strictly',
() async {
final push = PushSyncUseCase(
resources: resources,
clock: clock,
ids: ids,
);
final pull = PullSyncUseCase(resources: resources, clock: clock);
final fixtures = loadMinimalResourceFixtures();
final pushResult = await push.execute(
ownerUserId: 'user-1',
deviceId: 'device-1',
items: [
for (var index = 0; index < fixtures.length; index += 1)
PushSyncItemInput(
resourceType: fixtures[index].resourceType,
clientId: '${fixtures[index].resourceType}-minimal',
schemaVersion: fixtures[index].schemaVersion,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 11, index),
deletedAt: null,
payloadJson: fixtures[index].payload,
),
],
);
final pullResult = await pull.execute(ownerUserId: 'user-1');
expect(
pushResult.results.map((item) => item.status),
everyElement('accepted'),
);
for (final fixture in fixtures) {
final pulled = pullResult.items.singleWhere(
(item) => item.clientId == '${fixture.resourceType}-minimal',
);
expect(pulled.resourceType.wireName, fixture.resourceType);
expect(pulled.schemaVersion, fixture.schemaVersion);
expect(pulled.payloadJson, fixture.payload);
}
},
);
test(
'LWW ignores older and equal exercise fixture payloads strictly',
() async {
final original = loadExerciseFixture('full_combo');
final newer = loadExerciseFixture('with_timers');
final push = PushSyncUseCase(
resources: resources,
clock: clock,
ids: ids,
);
final result = await push.execute(
ownerUserId: 'user-1',
deviceId: 'device-1',
items: [
PushSyncItemInput(
resourceType: original.resourceType,
clientId: 'exercise-lww',
schemaVersion: original.schemaVersion,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
deletedAt: null,
payloadJson: original.payload,
),
PushSyncItemInput(
resourceType: newer.resourceType,
clientId: 'exercise-lww',
schemaVersion: newer.schemaVersion,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 9, 59),
deletedAt: null,
payloadJson: newer.payload,
),
PushSyncItemInput(
resourceType: newer.resourceType,
clientId: 'exercise-lww',
schemaVersion: newer.schemaVersion,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
deletedAt: null,
payloadJson: newer.payload,
),
],
);
expect(result.results.map((item) => item.status), [
'accepted',
'ignoredOlder',
'ignoredOlder',
]);
final stored = resources.get(
'user-1',
SyncedResourceType.exercise,
'exercise-lww',
);
expect(stored?.schemaVersion, original.schemaVersion);
expect(stored?.payloadJson, original.payload);
},
);
test('push reports per-item malformations without storing them', () async {
final useCase = PushSyncUseCase(
resources: resources,
clock: clock,
ids: ids,
);
final result = await useCase.execute(
ownerUserId: 'user-1',
deviceId: 'device-1',
items: [
PushSyncItemInput(
resourceType: 'exercise',
clientId: 'bad-schema',
schemaVersion: 0,
clientUpdatedAt: DateTime.utc(2026, 7, 19, 10),
deletedAt: null,
payloadJson: {},
),
const PushSyncItemInput(
resourceType: 'exercise',
clientId: 'missing-updated-at',
schemaVersion: 1,
clientUpdatedAt: null,
deletedAt: null,
payloadJson: {},
),
const PushSyncItemInput.invalid(
resourceType: 'exercise',
clientId: 'bad-payload',
message: 'payload must be a JSON object.',
),
],
);
expect(result.results.map((item) => item.status), [
'error',
'error',
'error',
]);
expect(resources._items, isEmpty);
});
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';
}
}