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:
2
.gitignore
vendored
2
.gitignore
vendored
@ -105,6 +105,8 @@ coverage/
|
|||||||
.xdg-oldflow/
|
.xdg-oldflow/
|
||||||
.xdg/
|
.xdg/
|
||||||
.tmp/
|
.tmp/
|
||||||
|
.build-home/
|
||||||
|
.pub-cache-local/
|
||||||
|
|
||||||
# Fichiers de heap dump parasites (JVM/Android crash)
|
# Fichiers de heap dump parasites (JVM/Android crash)
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|||||||
@ -69,6 +69,21 @@ Expected response:
|
|||||||
{"status":"ok"}
|
{"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
|
## Authentication
|
||||||
|
|
||||||
Ticket #48 adds account registration, login, logout and bearer-token request
|
Ticket #48 adds account registration, login, logout and bearer-token request
|
||||||
|
|||||||
@ -53,6 +53,13 @@ should be HTTPS through the reverse proxy.
|
|||||||
curl http://localhost:8080/health
|
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`.
|
- Register a user with `POST /auth/register`.
|
||||||
- Login with `POST /auth/login` and store the returned bearer token.
|
- Login with `POST /auth/login` and store the returned bearer token.
|
||||||
- Call a protected endpoint without a token and confirm `401`.
|
- Call a protected endpoint without a token and confirm `401`.
|
||||||
|
|||||||
22
server/test/fixtures/README.md
vendored
Normal file
22
server/test/fixtures/README.md
vendored
Normal file
@ -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.
|
||||||
51
server/test/fixtures/exercises/v1/full_combo.json
vendored
Normal file
51
server/test/fixtures/exercises/v1/full_combo.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
8
server/test/fixtures/exercises/v1/minimal.json
vendored
Normal file
8
server/test/fixtures/exercises/v1/minimal.json
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "exercise-minimal",
|
||||||
|
"name": "Air squat",
|
||||||
|
"type": "strength",
|
||||||
|
"category": "legs",
|
||||||
|
"updatedAt": "2026-07-19T10:00:00.000Z"
|
||||||
|
}
|
||||||
17
server/test/fixtures/exercises/v1/with_score_chrono.json
vendored
Normal file
17
server/test/fixtures/exercises/v1/with_score_chrono.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
21
server/test/fixtures/exercises/v1/with_steps.json
vendored
Normal file
21
server/test/fixtures/exercises/v1/with_steps.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
18
server/test/fixtures/exercises/v1/with_timers.json
vendored
Normal file
18
server/test/fixtures/exercises/v1/with_timers.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
16
server/test/fixtures/exercises/v2/minimal.json
vendored
Normal file
16
server/test/fixtures/exercises/v2/minimal.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
7
server/test/fixtures/programs/v1/minimal.json
vendored
Normal file
7
server/test/fixtures/programs/v1/minimal.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "program-minimal",
|
||||||
|
"name": "Starter strength",
|
||||||
|
"exerciseIds": ["exercise-minimal"],
|
||||||
|
"updatedAt": "2026-07-19T10:30:00.000Z"
|
||||||
|
}
|
||||||
101
server/test/fixtures/sync_fixtures.dart
vendored
Normal file
101
server/test/fixtures/sync_fixtures.dart
vendored
Normal file
@ -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<String, Object?> 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<VersionedSyncFixture> loadExerciseFixtures({
|
||||||
|
String version = 'v1',
|
||||||
|
List<String> names = const [
|
||||||
|
'minimal',
|
||||||
|
'with_steps',
|
||||||
|
'with_score_chrono',
|
||||||
|
'with_timers',
|
||||||
|
'full_combo',
|
||||||
|
],
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
for (final name in names) loadExerciseFixture(name, version: version),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<VersionedSyncFixture> 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<String, Object?>) {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
19
server/test/fixtures/workout_histories/v1/minimal.json
vendored
Normal file
19
server/test/fixtures/workout_histories/v1/minimal.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
13
server/test/fixtures/workout_templates/v1/minimal.json
vendored
Normal file
13
server/test/fixtures/workout_templates/v1/minimal.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
@ -7,6 +7,8 @@ import 'package:gametime_server/domain/domain.dart';
|
|||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import 'fixtures/sync_fixtures.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('sync routes require bearer authentication', () async {
|
test('sync routes require bearer authentication', () async {
|
||||||
final handler = buildApiHandler(syncApi: _syncApi());
|
final handler = buildApiHandler(syncApi: _syncApi());
|
||||||
@ -63,6 +65,350 @@ void main() {
|
|||||||
expect(repository.items.single.clientId, 'exercise-1');
|
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 {
|
test('pull returns synced items and filters them with since query', () async {
|
||||||
final repository = _FakeSyncedResourceRepository()
|
final repository = _FakeSyncedResourceRepository()
|
||||||
..items.addAll([
|
..items.addAll([
|
||||||
@ -92,9 +438,7 @@ void main() {
|
|||||||
final response = await handler(
|
final response = await handler(
|
||||||
Request(
|
Request(
|
||||||
'GET',
|
'GET',
|
||||||
Uri.parse(
|
Uri.parse('http://localhost/sync/pull?since=2026-07-19T12:00:00Z'),
|
||||||
'http://localhost/sync/pull?since=2026-07-19T12:00:00Z',
|
|
||||||
),
|
|
||||||
headers: {'authorization': 'Bearer valid-token'},
|
headers: {'authorization': 'Bearer valid-token'},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -121,12 +465,66 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(response.statusCode, 400);
|
expect(response.statusCode, 400);
|
||||||
expect(
|
expect(jsonDecode(await response.readAsString()), {
|
||||||
jsonDecode(await response.readAsString()),
|
'error': 'Invalid date format',
|
||||||
{'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 {
|
test('exchange returns push results followed by pulled items', () async {
|
||||||
final repository = _FakeSyncedResourceRepository()
|
final repository = _FakeSyncedResourceRepository()
|
||||||
..items.add(
|
..items.add(
|
||||||
@ -191,10 +589,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(response.statusCode, 400);
|
expect(response.statusCode, 400);
|
||||||
expect(
|
expect(jsonDecode(await response.readAsString()), {
|
||||||
jsonDecode(await response.readAsString()),
|
'error': 'items must be a JSON array.',
|
||||||
{'error': 'items must be a JSON array.'},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,6 +637,38 @@ final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncWriteResult> upsertWithLww(SyncedResource resource) async {
|
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);
|
items.add(resource);
|
||||||
return SyncWriteResult(
|
return SyncWriteResult(
|
||||||
status: SyncWriteStatus.accepted,
|
status: SyncWriteStatus.accepted,
|
||||||
@ -252,10 +681,14 @@ final class _FakeSyncedResourceRepository implements SyncedResourceRepository {
|
|||||||
required String ownerUserId,
|
required String ownerUserId,
|
||||||
DateTime? since,
|
DateTime? since,
|
||||||
}) async {
|
}) async {
|
||||||
return items
|
final result = items
|
||||||
.where((item) => item.ownerUserId == ownerUserId)
|
.where((item) => item.ownerUserId == ownerUserId)
|
||||||
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
.where((item) => since == null || item.serverUpdatedAt.isAfter(since))
|
||||||
.toList();
|
.toList();
|
||||||
|
result.sort(
|
||||||
|
(left, right) => left.serverUpdatedAt.compareTo(right.serverUpdatedAt),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import 'package:gametime_server/application/application.dart';
|
|||||||
import 'package:gametime_server/domain/domain.dart';
|
import 'package:gametime_server/domain/domain.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import 'fixtures/sync_fixtures.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late _FakeSyncedResourceRepository resources;
|
late _FakeSyncedResourceRepository resources;
|
||||||
late _FakeClock clock;
|
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(
|
test(
|
||||||
'pull returns resources newer than cursor including soft deletes',
|
'pull returns resources newer than cursor including soft deletes',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user