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>
This commit is contained in:
2026-07-31 16:58:07 +02:00
parent 05d91e3e84
commit 89558808af
16 changed files with 989 additions and 12 deletions

View File

@ -7,6 +7,8 @@ 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());
@ -63,6 +65,350 @@ void main() {
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<Map>().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<Map>().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<Map>().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<Map>().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<Map>().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([
@ -92,9 +438,7 @@ void main() {
final response = await handler(
Request(
'GET',
Uri.parse(
'http://localhost/sync/pull?since=2026-07-19T12:00:00Z',
),
Uri.parse('http://localhost/sync/pull?since=2026-07-19T12:00:00Z'),
headers: {'authorization': 'Bearer valid-token'},
),
);
@ -121,12 +465,66 @@ void main() {
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'Invalid date format'},
);
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(
@ -191,10 +589,9 @@ void main() {
);
expect(response.statusCode, 400);
expect(
jsonDecode(await response.readAsString()),
{'error': 'items must be a JSON array.'},
);
expect(jsonDecode(await response.readAsString()), {
'error': 'items must be a JSON array.',
});
});
}
@ -240,6 +637,38 @@ final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
@override
Future<SyncWriteResult> 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,
@ -252,10 +681,14 @@ final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
required String ownerUserId,
DateTime? since,
}) async {
return items
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;
}
}