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

22
server/test/fixtures/README.md vendored Normal file
View 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.

View 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"
}

View File

@ -0,0 +1,8 @@
{
"schemaVersion": 1,
"id": "exercise-minimal",
"name": "Air squat",
"type": "strength",
"category": "legs",
"updatedAt": "2026-07-19T10:00:00.000Z"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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
View 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,
);
}

View 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"
}

View 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"
}