From 89558808af7c78ae8a0dd706d65750b40d24d572 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 31 Jul 2026 16:58:07 +0200 Subject: [PATCH] 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 --- .gitignore | 2 + server/README.md | 15 + server/docs/integration-checklist.md | 7 + server/test/fixtures/README.md | 22 + .../fixtures/exercises/v1/full_combo.json | 51 ++ .../test/fixtures/exercises/v1/minimal.json | 8 + .../exercises/v1/with_score_chrono.json | 17 + .../fixtures/exercises/v1/with_steps.json | 21 + .../fixtures/exercises/v1/with_timers.json | 18 + .../test/fixtures/exercises/v2/minimal.json | 16 + server/test/fixtures/programs/v1/minimal.json | 7 + server/test/fixtures/sync_fixtures.dart | 101 ++++ .../workout_histories/v1/minimal.json | 19 + .../workout_templates/v1/minimal.json | 13 + server/test/sync_api_test.dart | 457 +++++++++++++++++- server/test/sync_use_cases_test.dart | 227 +++++++++ 16 files changed, 989 insertions(+), 12 deletions(-) create mode 100644 server/test/fixtures/README.md create mode 100644 server/test/fixtures/exercises/v1/full_combo.json create mode 100644 server/test/fixtures/exercises/v1/minimal.json create mode 100644 server/test/fixtures/exercises/v1/with_score_chrono.json create mode 100644 server/test/fixtures/exercises/v1/with_steps.json create mode 100644 server/test/fixtures/exercises/v1/with_timers.json create mode 100644 server/test/fixtures/exercises/v2/minimal.json create mode 100644 server/test/fixtures/programs/v1/minimal.json create mode 100644 server/test/fixtures/sync_fixtures.dart create mode 100644 server/test/fixtures/workout_histories/v1/minimal.json create mode 100644 server/test/fixtures/workout_templates/v1/minimal.json diff --git a/.gitignore b/.gitignore index 1b81a9a..2a8d220 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,8 @@ coverage/ .xdg-oldflow/ .xdg/ .tmp/ +.build-home/ +.pub-cache-local/ # Fichiers de heap dump parasites (JVM/Android crash) *.hprof diff --git a/server/README.md b/server/README.md index af8253e..a2f27e9 100644 --- a/server/README.md +++ b/server/README.md @@ -69,6 +69,21 @@ Expected response: {"status":"ok"} ``` +## Flutter Client URL + +The Android emulator default client URL is `http://10.0.2.2:8090`, which maps to +port `8090` on the host machine. This matches the common Docker Compose setup +where `server/.env` exposes `API_PORT=8090` while the API container still +listens internally on `8080`. + +Override the client URL at build or run time when the server uses another host +address or port: + +```bash +flutter run --dart-define=GAMETIME_API_BASE_URL=http://192.168.1.75:8090 +flutter build apk --debug --dart-define=GAMETIME_API_BASE_URL=http://192.168.1.75:8090 +``` + ## Authentication Ticket #48 adds account registration, login, logout and bearer-token request diff --git a/server/docs/integration-checklist.md b/server/docs/integration-checklist.md index 7c1b8fd..3f3194c 100644 --- a/server/docs/integration-checklist.md +++ b/server/docs/integration-checklist.md @@ -53,6 +53,13 @@ should be HTTPS through the reverse proxy. curl http://localhost:8080/health ``` +- For an Android emulator client, confirm the APK was built with the default + `http://10.0.2.2:8090` URL or with an explicit server URL: + +```bash +flutter build apk --debug --dart-define=GAMETIME_API_BASE_URL=http://192.168.1.75:8090 +``` + - Register a user with `POST /auth/register`. - Login with `POST /auth/login` and store the returned bearer token. - Call a protected endpoint without a token and confirm `401`. diff --git a/server/test/fixtures/README.md b/server/test/fixtures/README.md new file mode 100644 index 0000000..0ee6e7e --- /dev/null +++ b/server/test/fixtures/README.md @@ -0,0 +1,22 @@ +# Versioned sync fixtures + +These JSON files are server contract fixtures. They intentionally model client +payloads as opaque snapshots: server sync tests must push them, pull them back +and compare the payloads strictly without teaching the server the client schema. + +Structure: + +- `exercises/v1/`: first captured exercise fixture batch. +- `exercises/v2/`: second captured exercise fixture batch used to prove tests + can run multiple fixture generations side by side. +- `programs/v1/`, `workout_templates/v1/`, `workout_histories/v1/`: reusable + minimal sync batches for non-exercise resources. + +Folder names such as `v1` and `v2` identify fixture batches, not the payload +schema itself. The authoritative client schema value remains the JSON +`schemaVersion` field inside each fixture, and tests assert that the server +preserves that value exactly. + +When the client schema changes, add a new fixture batch folder instead of +rewriting older fixtures. Old fixture versions are kept to verify backward and +forward compatibility. diff --git a/server/test/fixtures/exercises/v1/full_combo.json b/server/test/fixtures/exercises/v1/full_combo.json new file mode 100644 index 0000000..ed24c21 --- /dev/null +++ b/server/test/fixtures/exercises/v1/full_combo.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 5, + "id": "exercise-full-combo", + "name": "Intervals complex", + "type": "mixed", + "category": "full_body", + "media": { + "coverAssetId": "media-cover-1", + "videoAssetId": "media-video-1" + }, + "steps": [ + { + "id": "step-row", + "order": 0, + "title": "Row", + "body": "Complete calories before moving on.", + "defaultDurationSeconds": 60 + }, + { + "id": "step-thruster", + "order": 1, + "title": "Thruster", + "body": "Break sets only if form degrades.", + "defaultReps": 15 + } + ], + "score": { + "mode": "for_time", + "unit": "seconds", + "capSeconds": 900 + }, + "chrono": { + "mode": "elapsed", + "autoStart": true + }, + "timers": { + "preparationSeconds": 20, + "workSeconds": 180, + "restSeconds": 60 + }, + "chainOverrides": { + "nextExerciseId": "exercise-cooldown", + "inheritRest": false + }, + "legacy": { + "unknownClientField": { + "nested": ["preserve", 1, true, null] + } + }, + "updatedAt": "2026-07-19T10:20:00.000Z" +} diff --git a/server/test/fixtures/exercises/v1/minimal.json b/server/test/fixtures/exercises/v1/minimal.json new file mode 100644 index 0000000..3702974 --- /dev/null +++ b/server/test/fixtures/exercises/v1/minimal.json @@ -0,0 +1,8 @@ +{ + "schemaVersion": 1, + "id": "exercise-minimal", + "name": "Air squat", + "type": "strength", + "category": "legs", + "updatedAt": "2026-07-19T10:00:00.000Z" +} diff --git a/server/test/fixtures/exercises/v1/with_score_chrono.json b/server/test/fixtures/exercises/v1/with_score_chrono.json new file mode 100644 index 0000000..32f8f77 --- /dev/null +++ b/server/test/fixtures/exercises/v1/with_score_chrono.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 3, + "id": "exercise-score-chrono", + "name": "Shuttle run", + "type": "conditioning", + "score": { + "mode": "rounds_reps", + "unit": "reps", + "target": 120 + }, + "chrono": { + "mode": "countdown", + "durationSeconds": 600, + "warningSeconds": [60, 10] + }, + "updatedAt": "2026-07-19T10:10:00.000Z" +} diff --git a/server/test/fixtures/exercises/v1/with_steps.json b/server/test/fixtures/exercises/v1/with_steps.json new file mode 100644 index 0000000..b492ebd --- /dev/null +++ b/server/test/fixtures/exercises/v1/with_steps.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 2, + "id": "exercise-with-steps", + "name": "Kettlebell swing", + "type": "strength", + "steps": [ + { + "id": "step-setup", + "order": 0, + "title": "Setup", + "body": "Hinge with the bell slightly in front of the feet." + }, + { + "id": "step-drive", + "order": 1, + "title": "Drive", + "body": "Extend the hips and let the bell float to chest height." + } + ], + "updatedAt": "2026-07-19T10:05:00.000Z" +} diff --git a/server/test/fixtures/exercises/v1/with_timers.json b/server/test/fixtures/exercises/v1/with_timers.json new file mode 100644 index 0000000..aa383b2 --- /dev/null +++ b/server/test/fixtures/exercises/v1/with_timers.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 4, + "id": "exercise-with-timers", + "name": "Tempo bench press", + "type": "strength", + "timers": { + "preparationSeconds": 15, + "workSeconds": 45, + "restSeconds": 90, + "transitionSeconds": 10 + }, + "defaultTargets": { + "sets": 5, + "reps": 5, + "weightKg": 80 + }, + "updatedAt": "2026-07-19T10:15:00.000Z" +} diff --git a/server/test/fixtures/exercises/v2/minimal.json b/server/test/fixtures/exercises/v2/minimal.json new file mode 100644 index 0000000..8af70a8 --- /dev/null +++ b/server/test/fixtures/exercises/v2/minimal.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 6, + "id": "exercise-minimal-v2", + "name": "Air squat", + "type": "strength", + "category": "legs", + "defaultTargets": { + "sets": 3, + "reps": 12 + }, + "clientFormat": { + "versionFolder": "v2", + "migratedFrom": "exercises/v1/minimal" + }, + "updatedAt": "2026-07-20T10:00:00.000Z" +} diff --git a/server/test/fixtures/programs/v1/minimal.json b/server/test/fixtures/programs/v1/minimal.json new file mode 100644 index 0000000..ed3cc80 --- /dev/null +++ b/server/test/fixtures/programs/v1/minimal.json @@ -0,0 +1,7 @@ +{ + "schemaVersion": 1, + "id": "program-minimal", + "name": "Starter strength", + "exerciseIds": ["exercise-minimal"], + "updatedAt": "2026-07-19T10:30:00.000Z" +} diff --git a/server/test/fixtures/sync_fixtures.dart b/server/test/fixtures/sync_fixtures.dart new file mode 100644 index 0000000..96697a7 --- /dev/null +++ b/server/test/fixtures/sync_fixtures.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; +import 'dart:io'; + +final class VersionedSyncFixture { + const VersionedSyncFixture({ + required this.resourceType, + required this.name, + required this.schemaVersion, + required this.payload, + }); + + final String resourceType; + final String name; + final int schemaVersion; + final Map payload; +} + +VersionedSyncFixture loadExerciseFixture(String name, {String version = 'v1'}) { + return _loadFixture( + resourceType: 'exercise', + path: 'test/fixtures/exercises/$version/$name.json', + name: name, + ); +} + +VersionedSyncFixture loadProgramFixture(String name, {String version = 'v1'}) { + return _loadFixture( + resourceType: 'program', + path: 'test/fixtures/programs/$version/$name.json', + name: name, + ); +} + +VersionedSyncFixture loadWorkoutTemplateFixture( + String name, { + String version = 'v1', +}) { + return _loadFixture( + resourceType: 'workoutTemplate', + path: 'test/fixtures/workout_templates/$version/$name.json', + name: name, + ); +} + +VersionedSyncFixture loadWorkoutHistoryFixture( + String name, { + String version = 'v1', +}) { + return _loadFixture( + resourceType: 'workoutHistory', + path: 'test/fixtures/workout_histories/$version/$name.json', + name: name, + ); +} + +List loadExerciseFixtures({ + String version = 'v1', + List names = const [ + 'minimal', + 'with_steps', + 'with_score_chrono', + 'with_timers', + 'full_combo', + ], +}) { + return [ + for (final name in names) loadExerciseFixture(name, version: version), + ]; +} + +List loadMinimalResourceFixtures({ + String version = 'v1', +}) { + return [ + loadProgramFixture('minimal', version: version), + loadWorkoutTemplateFixture('minimal', version: version), + loadWorkoutHistoryFixture('minimal', version: version), + ]; +} + +VersionedSyncFixture _loadFixture({ + required String resourceType, + required String path, + required String name, +}) { + final raw = File(path).readAsStringSync(); + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw StateError('Fixture $path must contain a JSON object.'); + } + final schemaVersion = decoded['schemaVersion']; + if (schemaVersion is! int || schemaVersion <= 0) { + throw StateError('Fixture $path must contain a positive schemaVersion.'); + } + return VersionedSyncFixture( + resourceType: resourceType, + name: name, + schemaVersion: schemaVersion, + payload: decoded, + ); +} diff --git a/server/test/fixtures/workout_histories/v1/minimal.json b/server/test/fixtures/workout_histories/v1/minimal.json new file mode 100644 index 0000000..378d46b --- /dev/null +++ b/server/test/fixtures/workout_histories/v1/minimal.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "id": "history-minimal", + "templateId": "template-minimal", + "startedAt": "2026-07-19T11:00:00.000Z", + "finishedAt": "2026-07-19T11:45:00.000Z", + "entries": [ + { + "exerciseId": "exercise-minimal", + "sets": [ + { + "reps": 8, + "weightKg": 60 + } + ] + } + ], + "updatedAt": "2026-07-19T11:45:00.000Z" +} diff --git a/server/test/fixtures/workout_templates/v1/minimal.json b/server/test/fixtures/workout_templates/v1/minimal.json new file mode 100644 index 0000000..afef52a --- /dev/null +++ b/server/test/fixtures/workout_templates/v1/minimal.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "id": "template-minimal", + "name": "Full body A", + "blocks": [ + { + "exerciseId": "exercise-minimal", + "sets": 3, + "reps": 8 + } + ], + "updatedAt": "2026-07-19T10:35:00.000Z" +} diff --git a/server/test/sync_api_test.dart b/server/test/sync_api_test.dart index 6cb00df..ffca03c 100644 --- a/server/test/sync_api_test.dart +++ b/server/test/sync_api_test.dart @@ -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().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().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().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().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().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 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; } } diff --git a/server/test/sync_use_cases_test.dart b/server/test/sync_use_cases_test.dart index 8681afe..1cf6b16 100644 --- a/server/test/sync_use_cases_test.dart +++ b/server/test/sync_use_cases_test.dart @@ -2,6 +2,8 @@ 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; @@ -89,6 +91,231 @@ void main() { }, ); + 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 {