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>
501 lines
15 KiB
Dart
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';
|
|
}
|
|
}
|