feat(gherkin): ajouter features API et UI pour audio-guides multi-séquences

Créer 5 nouvelles features API :
- creation-gestion.feature : création, modification, publication d'audio-guides
- declenchement-gps.feature : calculs GPS, rayons, déclenchement automatique
- progression-sync.feature : sauvegarde progression, sync cloud, multi-device
- publicites.feature : insertion pub, fréquence, métriques créateur
- metriques-analytics.feature : statistiques, heatmaps, analytics créateur

Adapter mode-voiture.feature :
- ajouter section publicités en mode voiture (auto-play, pas de pause)

Corriger .gitignore :
- remplacer "api" par "/api" pour ne pas ignorer features/api/
This commit is contained in:
jpgiannetti
2026-02-02 22:52:10 +01:00
parent ea77aa8ac7
commit a19a901ed4
6 changed files with 1980 additions and 3 deletions

6
.gitignore vendored
View File

@@ -6,9 +6,9 @@
*.dylib
/bin/
/dist/
api
worker
migrate
/api
/worker
/migrate
# Test binary, built with `go test -c`
*.test

View File

@@ -0,0 +1,402 @@
# language: fr
Fonctionnalité: API - Création et gestion d'audio-guides multi-séquences
En tant que système backend
Je veux exposer des endpoints pour créer et gérer les audio-guides
Afin de permettre aux créateurs de publier des expériences guidées
Contexte:
Étant donné que l'API RoadWave est démarrée
Et que le créateur "guide@example.com" est authentifié avec un token JWT valide
Et que son compte est vérifié (email_verified: true)
# 16.1.2 - Endpoints de création
Scénario: POST /api/v1/audio-guides - Création d'un audio-guide
Étant donné la requête suivante:
"""json
{
"title": "Safari du Paugre",
"description": "Découvrez les animaux du parc en voiture sur un circuit de 5km",
"mode": "voiture",
"vitesse_recommandee": "30-50 km/h",
"tags": ["nature", "animaux", "famille"],
"classification_age": "tout_public",
"zone_diffusion": {
"type": "polygon",
"coordinates": [[2.5678, 43.1234], [2.5690, 43.1245], [2.5700, 43.1250]]
}
}
"""
Quand je fais un POST sur "/api/v1/audio-guides"
Alors le code HTTP de réponse est 201
Et le corps de réponse contient:
| champ | valeur |
| id | UUID généré |
| status | draft |
| creator_id | ID du créateur |
| sequences_count | 0 |
| created_at | Timestamp actuel |
Scénario: Validation du titre (longueur 5-100 caractères)
Étant donné la requête avec titre "ABC"
Quand je fais un POST sur "/api/v1/audio-guides"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "title: doit contenir entre 5 et 100 caractères"
Scénario: Validation de la description (longueur 10-500 caractères)
Étant donné la requête avec description de 8 caractères
Quand je fais un POST sur "/api/v1/audio-guides"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "description: doit contenir entre 10 et 500 caractères"
Plan du Scénario: Validation du mode de déplacement
Étant donné la requête avec mode "<mode>"
Quand je fais un POST sur "/api/v1/audio-guides"
Alors le code HTTP de réponse est <code>
Exemples:
| mode | code |
| pieton | 201 |
| voiture | 201 |
| velo | 201 |
| transport | 201 |
| avion | 400 |
| invalid | 400 |
Scénario: Validation tags (1-3 tags obligatoires)
Étant donné la requête avec 0 tags
Quand je fais un POST sur "/api/v1/audio-guides"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "tags: minimum 1 tag requis, maximum 3"
Scénario: Validation classification âge
Étant donné la requête avec classification_age "invalide"
Quand je fais un POST sur "/api/v1/audio-guides"
Alors le code HTTP de réponse est 400
Et le message d'erreur contient "classification_age: valeurs autorisées [tout_public, 13+, 16+, 18+]"
# Ajout de séquences
Scénario: POST /api/v1/audio-guides/{id}/sequences - Ajout première séquence
Étant donné un audio-guide draft avec ID "ag_123"
Et la requête suivante:
"""json
{
"order": 1,
"title": "Introduction - Point d'accueil",
"audio_file": "base64_encoded_mp3_data",
"gps_point": {
"latitude": 43.1234,
"longitude": 2.5678
},
"rayon_declenchement": 30
}
"""
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/sequences"
Alors le code HTTP de réponse est 201
Et la séquence est créée avec:
| champ | valeur |
| sequence_id | UUID généré |
| order | 1 |
| duration | Calculée depuis audio |
| status | uploaded |
Scénario: Validation format audio (MP3, AAC, M4A uniquement)
Étant donné un audio-guide draft
Et un fichier audio au format WAV
Quand je fais un POST sur "/api/v1/audio-guides/{id}/sequences"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "audio_file: format non supporté. Formats acceptés: MP3, AAC, M4A"
Scénario: Validation taille audio (max 200 MB)
Étant donné un audio-guide draft
Et un fichier audio de 250 MB
Quand je fais un POST sur "/api/v1/audio-guides/{id}/sequences"
Alors le code HTTP de réponse 413
Et le message d'erreur est "audio_file: taille maximale 200 MB dépassée"
Scénario: Validation durée audio (max 15 minutes)
Étant donné un audio-guide draft
Et un fichier audio de 18 minutes
Quand je fais un POST sur "/api/v1/audio-guides/{id}/sequences"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "audio_file: durée maximale 15 minutes dépassée"
Scénario: Point GPS obligatoire sauf mode piéton
Étant donné un audio-guide en mode "voiture"
Et une séquence sans gps_point
Quand je fais un POST sur "/api/v1/audio-guides/{id}/sequences"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "gps_point: obligatoire pour mode voiture"
Scénario: Point GPS optionnel en mode piéton
Étant donné un audio-guide en mode "pieton"
Et une séquence sans gps_point
Quand je fais un POST sur "/api/v1/audio-guides/{id}/sequences"
Alors le code HTTP de réponse est 201
Et la séquence est créée sans point GPS
Plan du Scénario: Rayon de déclenchement par défaut selon mode
Étant donné un audio-guide en mode "<mode>"
Et une séquence sans rayon_declenchement spécifié
Quand je fais un POST sur "/api/v1/audio-guides/{id}/sequences"
Alors le rayon par défaut appliqué est <rayon>
Exemples:
| mode | rayon |
| voiture | 30 |
| velo | 50 |
| transport | 100 |
Scénario: Validation rayon configurable (10-200m)
Étant donné un audio-guide
Et une séquence avec rayon_declenchement 250
Quand je fais un POST sur "/api/v1/audio-guides/{id}/sequences"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "rayon_declenchement: doit être entre 10 et 200 mètres"
Scénario: Nombre maximum de séquences (50)
Étant donné un audio-guide avec 50 séquences
Quand je tente d'ajouter une 51ème séquence
Alors le code HTTP de réponse est 400
Et le message d'erreur est "Maximum 50 séquences par audio-guide atteint"
# Modification et réordonnancement
Scénario: PATCH /api/v1/audio-guides/{id}/sequences/{seq_id} - Modification séquence
Étant donné une séquence existante "seq_456"
Et la requête suivante:
"""json
{
"title": "Introduction - Point d'accueil (édité)",
"rayon_declenchement": 40
}
"""
Quand je fais un PATCH sur "/api/v1/audio-guides/ag_123/sequences/seq_456"
Alors le code HTTP de réponse est 200
Et les champs modifiés sont mis à jour
Et updated_at est mis à jour
Scénario: PUT /api/v1/audio-guides/{id}/sequences/reorder - Réordonnancement
Étant donné un audio-guide avec 5 séquences
Et la requête suivante:
"""json
{
"sequence_orders": [
{"sequence_id": "seq_1", "order": 1},
{"sequence_id": "seq_4", "order": 2},
{"sequence_id": "seq_2", "order": 3},
{"sequence_id": "seq_3", "order": 4},
{"sequence_id": "seq_5", "order": 5}
]
}
"""
Quand je fais un PUT sur "/api/v1/audio-guides/ag_123/sequences/reorder"
Alors le code HTTP de réponse est 200
Et l'ordre des séquences est mis à jour en base
Scénario: DELETE /api/v1/audio-guides/{id}/sequences/{seq_id} - Suppression séquence
Étant donné un audio-guide avec 3 séquences
Quand je fais un DELETE sur "/api/v1/audio-guides/ag_123/sequences/seq_2"
Alors le code HTTP de réponse est 204
Et la séquence est supprimée
Et l'ordre des séquences restantes est réajusté (1, 2)
# Publication et validation
Scénario: POST /api/v1/audio-guides/{id}/publish - Publication (nouveau créateur)
Étant donné un audio-guide draft avec 3 séquences complètes
Et que c'est le 2ème audio-guide du créateur
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/publish"
Alors le code HTTP de réponse est 200
Et le status passe à "pending_moderation"
Et moderation_required est true
Et le corps de réponse contient:
"""json
{
"status": "pending_moderation",
"message": "Votre audio-guide est en cours de validation. Notre équipe le vérifiera sous 24-48h.",
"estimated_validation": "2026-01-24T14:00:00Z"
}
"""
Scénario: Publication directe pour créateur expérimenté (>5 audio-guides validés)
Étant donné un audio-guide draft avec 5 séquences
Et que le créateur a publié 8 audio-guides validés
Et qu'il n'a aucun strike actif
Quand je fais un POST sur "/api/v1/audio-guides/ag_456/publish"
Alors le code HTTP de réponse est 200
Et le status passe à "published"
Et moderation_required est false
Et l'audio-guide est immédiatement visible
Scénario: Validation nombre minimum de séquences (2)
Étant donné un audio-guide draft avec 1 seule séquence
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/publish"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "Minimum 2 séquences requis pour publication"
Scénario: Alerte cohérence - Points GPS trop éloignés
Étant donné un audio-guide en mode "pieton"
Et une séquence au Louvre (Paris)
Et une séquence à Lyon (465 km de distance)
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/publish"
Alors le code HTTP de réponse est 200
Et un warning est retourné:
"""json
{
"status": "pending_moderation",
"warnings": [
{
"type": "distance_incohérence",
"message": "Distance importante entre points (465 km). Vérifiez que le mode 'pieton' est approprié.",
"severity": "warning"
}
]
}
"""
# Gestion des brouillons
Scénario: Sauvegarde automatique brouillon
Étant donné un audio-guide draft non sauvegardé depuis 5 minutes
Quand une modification est apportée via PATCH
Alors le champ updated_at est mis à jour automatiquement
Et le brouillon est sauvegardé en base
Scénario: GET /api/v1/audio-guides/drafts - Liste des brouillons
Étant donné que le créateur a 2 brouillons:
| id | title | sequences_count | updated_at |
| ag_111 | Safari du Paugre | 3 | 2026-01-20 10:00:00 |
| ag_222 | Visite du Louvre | 1 | 2026-01-15 14:30:00 |
Quand je fais un GET sur "/api/v1/audio-guides/drafts"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient les 2 brouillons
Et ils sont triés par updated_at décroissant
Scénario: DELETE /api/v1/audio-guides/{id} - Suppression brouillon
Étant donné un audio-guide draft "ag_123"
Quand je fais un DELETE sur "/api/v1/audio-guides/ag_123"
Alors le code HTTP de réponse est 204
Et l'audio-guide et toutes ses séquences sont supprimés
Et les fichiers audio associés sont marqués pour suppression S3
# Modification audio-guide publié
Scénario: PATCH /api/v1/audio-guides/{id} - Modification métadonnées (publié)
Étant donné un audio-guide publié "ag_789"
Et la requête suivante:
"""json
{
"title": "Safari du Paugre - Version 2",
"description": "Nouvelle description améliorée",
"tags": ["nature", "animaux", "enfants"]
}
"""
Quand je fais un PATCH sur "/api/v1/audio-guides/ag_789"
Alors le code HTTP de réponse est 200
Et les métadonnées sont mises à jour
Et le status reste "published" (pas de revalidation)
Scénario: Interdiction modification mode après publication
Étant donné un audio-guide publié en mode "voiture"
Et la requête avec mode "pieton"
Quand je fais un PATCH sur "/api/v1/audio-guides/{id}"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "mode: impossible de modifier le mode après publication"
Scénario: Interdiction modification GPS après publication
Étant donné un audio-guide publié avec points GPS
Et une tentative de modification des coordonnées GPS
Quand je fais un PATCH sur "/api/v1/audio-guides/{id}/sequences/{seq_id}"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "gps_point: impossible de modifier après publication. Créez une nouvelle version."
# Duplication
Scénario: POST /api/v1/audio-guides/{id}/duplicate - Duplication audio-guide
Étant donné un audio-guide publié "ag_999" avec 12 séquences
Quand je fais un POST sur "/api/v1/audio-guides/ag_999/duplicate"
Alors le code HTTP de réponse est 201
Et un nouvel audio-guide draft est créé
Et le titre est "Safari du Paugre (copie)"
Et toutes les séquences sont copiées avec les mêmes métadonnées
Et les fichiers audio sont référencés (pas de duplication S3)
# Statistiques
Scénario: GET /api/v1/audio-guides/{id}/stats - Statistiques parcours
Étant donné un audio-guide avec les séquences suivantes:
| sequence | duration | gps_point | distance_to_next |
| 1 | 2:15 | (43.1234, 2.5678) | 150m |
| 2 | 3:42 | (43.1245, 2.5690) | 200m |
| 3 | 4:10 | (43.1250, 2.5700) | null |
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/stats"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"sequences_count": 3,
"total_duration": "10:07",
"total_distance": "350m",
"avg_sequence_duration": "3:22"
}
"""
# Gestion zone diffusion
Scénario: Validation zone diffusion (polygon géographique)
Étant donné une zone diffusion de type "polygon"
Et les coordonnées forment un polygon valide (min 3 points)
Quand je fais un POST sur "/api/v1/audio-guides"
Alors la zone est validée avec PostGIS ST_IsValid
Et stockée en type geography
Scénario: Zone diffusion via API Nominatim (ville)
Étant donné une zone diffusion de type "city"
Et la valeur "Paris"
Quand je fais un POST sur "/api/v1/audio-guides"
Alors l'API interroge Nominatim pour récupérer le polygon de Paris
Et le polygon est stocké en base
# Cas d'erreur
Scénario: Authentification requise
Étant donné une requête sans token JWT
Quand je fais un POST sur "/api/v1/audio-guides"
Alors le code HTTP de réponse est 401
Et le message d'erreur est "Authentification requise"
Scénario: Compte non vérifié
Étant donné un créateur avec email_verified: false
Quand je fais un POST sur "/api/v1/audio-guides"
Alors le code HTTP de réponse est 403
Et le message d'erreur est "Vérification email requise pour créer des audio-guides"
Scénario: Modification audio-guide d'un autre créateur (interdite)
Étant donné un audio-guide appartenant au créateur "creator_A"
Et une requête authentifiée par "creator_B"
Quand je fais un PATCH sur "/api/v1/audio-guides/{id}"
Alors le code HTTP de réponse est 403
Et le message d'erreur est "Vous n'êtes pas autorisé à modifier cet audio-guide"
Scénario: Audio-guide inexistant
Quand je fais un GET sur "/api/v1/audio-guides/ag_nonexistant"
Alors le code HTTP de réponse est 404
Et le message d'erreur est "Audio-guide non trouvé"
Scénario: Séquence inexistante
Étant donné un audio-guide "ag_123"
Quand je fais un DELETE sur "/api/v1/audio-guides/ag_123/sequences/seq_nonexistant"
Alors le code HTTP de réponse est 404
Et le message d'erreur est "Séquence non trouvée"
Scénario: Suppression audio-guide avec utilisateurs actifs
Étant donné un audio-guide publié "ag_456"
Et 20 utilisateurs ont une progression active
Quand je fais un DELETE sur "/api/v1/audio-guides/ag_456"
Alors le code HTTP de réponse est 200
Et l'audio-guide est marqué "deleted" (soft delete)
Et les progressions utilisateurs sont archivées pendant 30 jours
Et un warning est retourné: "20 utilisateurs actifs. Progressions archivées 30 jours."

View File

@@ -0,0 +1,339 @@
# language: fr
Fonctionnalité: API - Déclenchement GPS et géolocalisation audio-guides
En tant que système backend
Je veux calculer les déclenchements GPS et distances pour audio-guides
Afin de permettre une expérience automatique en mode voiture/vélo/transport
Contexte:
Étant donné que l'API RoadWave est démarrée
Et que l'utilisateur "user@example.com" est authentifié
# 16.3.1 - Calcul de proximité et déclenchement
Scénario: POST /api/v1/audio-guides/{id}/check-proximity - Vérification proximité
Étant donné un audio-guide voiture avec 8 séquences
Et que l'utilisateur est à la position (43.1233, 2.5677)
Et que le prochain point GPS (séquence 2) est à (43.1245, 2.5690) avec rayon 30m
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/check-proximity":
"""json
{
"user_position": {
"latitude": 43.1233,
"longitude": 2.5677
},
"current_sequence": 1
}
"""
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"in_trigger_zone": false,
"next_sequence_id": "seq_2",
"distance_to_next": 145.3,
"eta_seconds": 18,
"direction_degrees": 45,
"should_trigger": false
}
"""
Scénario: Déclenchement automatique dans rayon 30m (voiture)
Étant donné un audio-guide voiture
Et que l'utilisateur entre à 25m du point GPS suivant
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/check-proximity"
Alors le code HTTP de réponse est 200
Et should_trigger est true
Et in_trigger_zone est true
Et le message "Séquence déclenchée automatiquement" est retourné
Plan du Scénario: Rayon de déclenchement selon mode
Étant donné un audio-guide en mode <mode>
Et un point GPS avec rayon par défaut
Quand l'utilisateur entre à <distance> du point
Alors should_trigger est <trigger>
Exemples:
| mode | distance | trigger |
| voiture | 25m | true |
| voiture | 35m | false |
| velo | 45m | true |
| velo | 55m | false |
| transport | 95m | true |
| transport | 105m | false |
# Calcul distance avec PostGIS
Scénario: Calcul distance avec ST_Distance (geography)
Étant donné deux points GPS:
| point | latitude | longitude |
| Position user| 43.1234 | 2.5678 |
| Point séq. 2 | 43.1245 | 2.5690 |
Quand le calcul PostGIS ST_Distance est effectué
Alors la distance retournée est 145.3 mètres
Et le calcul utilise le type geography (WGS84)
Et la précision est au mètre près
Scénario: Calcul ETA basé sur vitesse actuelle
Étant donné que l'utilisateur est à 320m du prochain point
Et que sa vitesse actuelle est 28 km/h
Quand l'ETA est calculé
Alors l'ETA retourné est 41 secondes
Et la formule appliquée est: (distance_m / 1000) / (vitesse_kmh) * 3600
Scénario: ETA non calculé si vitesse < 5 km/h
Étant donné que l'utilisateur est à 200m du prochain point
Et que sa vitesse actuelle est 2 km/h (arrêté)
Quand l'ETA est calculé
Alors l'ETA retourné est null
Et le message "En attente de déplacement" est inclus
Scénario: Calcul direction (bearing) avec PostGIS
Étant donné la position utilisateur (43.1234, 2.5678)
Et le prochain point (43.1245, 2.5690) au nord-est
Quand le bearing est calculé avec ST_Azimuth
Alors l'angle retourné est 45° (nord-est)
Et la flèche correspondante est ""
Plan du Scénario: Conversion angle en flèche (8 directions)
Étant donné un angle de <degrees>°
Quand la flèche est calculée
Alors la direction retournée est "<arrow>"
Exemples:
| degrees | arrow |
| 0 | |
| 45 | |
| 90 | |
| 135 | |
| 180 | |
| 225 | |
| 270 | |
| 315 | |
# 16.3.3 - Gestion point manqué
Scénario: Détection point manqué (hors rayon mais dans tolérance)
Étant donné un audio-guide voiture
Et un point GPS avec rayon 30m et tolérance 100m
Et que l'utilisateur passe à 65m du point
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/check-proximity"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"in_trigger_zone": false,
"missed_point": true,
"distance_to_point": 65,
"tolerance_zone": true,
"actions_available": ["listen_anyway", "skip", "navigate_back"]
}
"""
Scénario: Point manqué au-delà tolérance (>100m en voiture)
Étant donné un audio-guide voiture
Et que l'utilisateur passe à 150m du point GPS
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/check-proximity"
Alors missed_point est false
Et tolerance_zone est false
Et aucune popup "point manqué" n'est déclenchée
Plan du Scénario: Rayon tolérance selon mode
Étant donné un audio-guide en mode <mode>
Et que l'utilisateur passe à <distance> du point
Alors tolerance_zone est <in_tolerance>
Exemples:
| mode | distance | in_tolerance |
| voiture | 60m | true |
| voiture | 110m | false |
| velo | 70m | true |
| velo | 80m | false |
| transport | 120m | true |
| transport | 160m | false |
# Progress bar dynamique
Scénario: Calcul progress bar vers prochain point
Étant donné que la distance initiale vers le prochain point était 500m
Et que l'utilisateur est maintenant à 175m du point
Quand le pourcentage de progression est calculé
Alors le progress_percentage retourné est 65%
Et la formule est: 100 - (distance_actuelle / distance_initiale * 100)
# Gestion trajectoire et itinéraire
Scénario: Calcul distance totale parcours
Étant donné un audio-guide avec les points GPS suivants:
| sequence | latitude | longitude |
| 1 | 43.1234 | 2.5678 |
| 2 | 43.1245 | 2.5690 |
| 3 | 43.1250 | 2.5700 |
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/route-stats"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"total_distance": 350,
"distances_between_points": [
{"from": 1, "to": 2, "distance": 150},
{"from": 2, "to": 3, "distance": 200}
]
}
"""
Scénario: Vérification cohérence itinéraire (alerte distance excessive)
Étant donné un audio-guide en mode "pieton"
Et deux points GPS distants de 465 km (Paris - Lyon)
Quand la cohérence est vérifiée
Alors un warning est retourné:
"""json
{
"warning": "distance_excessive",
"message": "Distance de 465 km entre séquences 2 et 3. Mode 'pieton' inapproprié.",
"suggested_mode": "voiture"
}
"""
# Distinction audio-guides vs contenus géolocalisés simples
Scénario: Pas de notification 7s avant pour audio-guides multi-séquences
Étant donné un audio-guide multi-séquences en mode voiture
Et que l'utilisateur approche du prochain point GPS
Quand la distance et ETA sont calculés
Alors aucun décompte "71" n'est déclenché
Et le déclenchement se fait au point GPS exact (rayon 30m)
Et une notification "Ding" + toast 2s est envoyée
Scénario: Notification 7s avant pour contenus géolocalisés simples (1 séquence)
Étant donné un contenu géolocalisé simple (1 séquence unique)
Et que l'utilisateur approche du point GPS
Quand l'ETA devient 7 secondes
Alors une notification avec compteur "71" est déclenchée
Et l'utilisateur doit valider avec bouton "Suivant"
# Exception quota pour audio-guides multi-séquences
Scénario: Audio-guide multi-séquences compte 1 seul contenu dans quota horaire
Étant donné un audio-guide "Visite Safari" avec 12 séquences
Et que l'utilisateur a un quota de 0/6 contenus géolocalisés
Quand l'utilisateur démarre l'audio-guide (séquence 1)
Alors le quota passe à 1/6
Quand l'utilisateur écoute les 12 séquences complètes
Alors le quota reste à 1/6
Et toutes les séquences ne consomment PAS 12 quotas
Et l'audio-guide entier compte comme 1 seul contenu
Scénario: Contenus géolocalisés simples consomment 1 quota chacun
Étant donné que l'utilisateur a un quota de 0/6
Quand l'utilisateur accepte un contenu géolocalisé simple "Tour Eiffel"
Alors le quota passe à 1/6
Quand l'utilisateur accepte un contenu géolocalisé simple "Arc de Triomphe"
Alors le quota passe à 2/6
Quand l'utilisateur accepte un contenu géolocalisé simple "Louvre"
Alors le quota passe à 3/6
Et chaque contenu simple consomme 1 quota
Scénario: Mixte audio-guides + contenus simples respecte quota 6/h
Étant donné que l'utilisateur a un quota de 0/6
Quand l'utilisateur démarre un audio-guide 8 séquences "Safari"
Alors le quota passe à 1/6
Quand l'utilisateur accepte 5 contenus géolocalisés simples
Alors le quota passe à 6/6
Et le quota horaire est atteint
Quand un 7ème contenu est détecté
Alors aucune notification n'est envoyée (quota atteint)
# Cache et optimisations
Scénario: Cache Redis pour calculs GPS fréquents
Étant donné que les points GPS d'un audio-guide sont en cache Redis
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/check-proximity"
Alors les points GPS sont récupérés depuis Redis (pas PostgreSQL)
Et le temps de réponse est < 50ms
Scénario: Geospatial GEORADIUS Redis pour recherche proximité
Étant donné que tous les audio-guides sont indexés dans Redis (GEOADD)
Et une position utilisateur (43.1234, 2.5678)
Quand je recherche les audio-guides dans un rayon de 5 km
Alors Redis GEORADIUS retourne les audio-guides proches
Et le temps de réponse est < 20ms
# Mise à jour position temps réel
Scénario: WebSocket pour mise à jour position en temps réel
Étant donné une connexion WebSocket active pour l'audio-guide
Quand l'utilisateur envoie sa nouvelle position via WS
Alors le serveur calcule immédiatement la proximité
Et retourne distance + ETA via WS (pas de polling HTTP)
Scénario: Throttling position updates (max 1/seconde)
Étant donné que le client envoie des positions GPS toutes les 200ms
Quand le serveur reçoit les mises à jour
Alors seules les positions espacées de >1 seconde sont traitées
Et les autres sont ignorées (throttling)
# Cas d'erreur
Scénario: Position GPS invalide (coordonnées hors limites)
Étant donné une position avec latitude 95.0000 (invalide)
Quand je fais un POST sur "/api/v1/audio-guides/{id}/check-proximity"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "latitude: doit être entre -90 et 90"
Scénario: Audio-guide sans points GPS (mode piéton)
Étant donné un audio-guide en mode piéton sans points GPS
Quand je fais un POST sur "/api/v1/audio-guides/{id}/check-proximity"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "Audio-guide en mode manuel, pas de déclenchement GPS"
Scénario: Séquence déjà complétée (skip calcul si utilisateur a déjà passé)
Étant donné que l'utilisateur est à la séquence 5
Et qu'il vérifie la proximité du point 3 (déjà écouté)
Quand je fais un POST sur "/api/v1/audio-guides/{id}/check-proximity"
Alors le calcul n'est pas effectué pour les séquences passées
Et le message "Séquence déjà écoutée" est retourné
Scénario: Précision GPS insuffisante
Étant donné une position avec accuracy ±150m
Et un rayon de déclenchement de 30m
Quand la précision est vérifiée
Alors un warning est retourné:
"""json
{
"warning": "low_gps_accuracy",
"message": "Précision GPS insuffisante (±150m). Déclenchement automatique peut être perturbé.",
"accuracy": 150,
"trigger_radius": 30
}
"""
# Performance
Scénario: Optimisation requêtes PostGIS avec index spatial
Étant donné que les points GPS ont un index GIST (PostGIS)
Quand une requête ST_DWithin est exécutée
Alors l'index spatial est utilisé
Et le temps d'exécution est < 10ms
Scénario: Batch proximity check pour tous les points
Étant donné un audio-guide avec 20 séquences
Quand je fais un POST sur "/api/v1/audio-guides/{id}/batch-proximity":
"""json
{
"user_position": {"latitude": 43.1234, "longitude": 2.5678}
}
"""
Alors toutes les distances sont calculées en une seule requête PostGIS
Et le corps de réponse contient:
"""json
{
"sequences": [
{"sequence_id": "seq_1", "distance": 0, "in_zone": true},
{"sequence_id": "seq_2", "distance": 150, "in_zone": false},
{"sequence_id": "seq_3", "distance": 350, "in_zone": false}
],
"current_sequence": 1,
"next_sequence": 2
}
"""

View File

@@ -0,0 +1,485 @@
# language: fr
Fonctionnalité: API - Métriques et analytics audio-guides
En tant que système backend
Je veux collecter et exposer les métriques d'écoute des audio-guides
Afin de fournir des insights aux créateurs
Contexte:
Étant donné que l'API RoadWave est démarrée
Et que le créateur "creator@example.com" est authentifié
# Statistiques globales audio-guide
Scénario: GET /api/v1/creators/me/audio-guides/{id}/stats - Statistiques générales
Étant donné un audio-guide "ag_123" avec les métriques suivantes:
| ecoutes_totales | ecoutes_completes | taux_completion | temps_ecoute_total |
| 1542 | 892 | 58% | 423h |
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/stats"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"listens_total": 1542,
"listens_complete": 892,
"completion_rate": 58,
"total_listen_time_seconds": 1522800,
"avg_listen_time_seconds": 988,
"unique_listeners": 1124,
"repeat_listeners": 418
}
"""
Scénario: Statistiques par période (7j, 30j, 90j, all-time)
Étant donné un audio-guide avec historique
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/stats?period=7d"
Alors le code HTTP de réponse est 200
Et les statistiques sur les 7 derniers jours sont retournées
Plan du Scénario: Périodes disponibles
Quand je fais un GET avec period=<period>
Alors les stats de la période <description> sont retournées
Exemples:
| period | description |
| 7d | 7 derniers jours |
| 30d | 30 derniers jours |
| 90d | 90 derniers jours |
| all | Depuis la création |
# Métriques par séquence
Scénario: GET /api/v1/creators/me/audio-guides/{id}/sequences/stats - Stats par séquence
Étant donné un audio-guide de 12 séquences
Et les métriques suivantes:
| sequence | starts | completions | abandon_rate |
| 1 | 1000 | 950 | 5% |
| 2 | 950 | 920 | 3% |
| 3 | 920 | 850 | 8% |
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/sequences/stats"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"sequences": [
{
"sequence_id": "seq_1",
"sequence_number": 1,
"title": "Introduction",
"starts": 1000,
"completions": 950,
"completion_rate": 95,
"abandon_rate": 5,
"avg_listen_time": 132,
"duration": 135
},
{
"sequence_id": "seq_2",
"sequence_number": 2,
"title": "Pyramide du Louvre",
"starts": 950,
"completions": 920,
"completion_rate": 97,
"abandon_rate": 3,
"avg_listen_time": 106,
"duration": 108
}
]
}
"""
Scénario: Identification séquence la plus écoutée
Étant donné les statistiques par séquence
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/sequences/top"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"most_listened": {
"sequence_id": "seq_3",
"title": "La Joconde",
"starts": 920,
"reason": "popular_highlight"
},
"least_listened": {
"sequence_id": "seq_11",
"title": "Aile Richelieu",
"starts": 580,
"reason": "late_sequence"
}
}
"""
# Points d'abandon
Scénario: Détection point d'abandon critique
Étant donné un audio-guide avec taux de complétion 58%
Et que 35% des utilisateurs abandonnent à la séquence 7
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/abandon-analysis"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"critical_abandon_point": {
"sequence_id": "seq_7",
"sequence_number": 7,
"title": "Aile Richelieu",
"abandon_rate": 35,
"severity": "high",
"suggestion": "Réduire la durée (8 min actuellement) ou rendre plus captivant"
}
}
"""
Scénario: Heatmap des abandons
Étant donné un audio-guide
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/abandon-heatmap"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient une heatmap:
"""json
{
"heatmap": [
{"sequence": 1, "abandon_count": 50, "intensity": "low"},
{"sequence": 2, "abandon_count": 30, "intensity": "low"},
{"sequence": 7, "abandon_count": 320, "intensity": "high"},
{"sequence": 12, "abandon_count": 70, "intensity": "medium"}
]
}
"""
# Métriques géographiques
Scénario: GET /api/v1/creators/me/audio-guides/{id}/geographic-stats - Stats géo
Étant donné un audio-guide géolocalisé
Et les écoutes suivantes par région:
| region | listens | completions |
| Île-de-France | 850 | 520 |
| PACA | 320 | 180 |
| Auvergne | 145 | 90 |
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/geographic-stats"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"by_region": [
{
"region": "Île-de-France",
"listens": 850,
"completions": 520,
"completion_rate": 61
},
{
"region": "PACA",
"listens": 320,
"completions": 180,
"completion_rate": 56
}
]
}
"""
Scénario: Heatmap géographique des écoutes
Étant donné un audio-guide avec points GPS
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/geographic-heatmap"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"points": [
{
"sequence_id": "seq_1",
"gps_point": {"lat": 43.1234, "lon": 2.5678},
"listen_count": 1000,
"density": "high"
},
{
"sequence_id": "seq_2",
"gps_point": {"lat": 43.1245, "lon": 2.5690},
"listen_count": 950,
"density": "high"
}
]
}
"""
# Métriques déclenchement GPS
Scénario: Attribution GPS auto vs manuel
Étant donné un audio-guide voiture avec 8 points GPS
Et les déclenchements suivants:
| type | count |
| GPS auto | 542 |
| Manuel | 123 |
| Point manqué | 89 |
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/trigger-stats"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"total_triggers": 754,
"by_type": {
"gps_auto": 542,
"manual": 123,
"missed_point": 89
},
"gps_auto_rate": 72,
"manual_rate": 16,
"missed_rate": 12
}
"""
Scénario: Points GPS les plus manqués
Étant donné les statistiques de points manqués
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/missed-points"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"most_missed": [
{
"sequence_id": "seq_5",
"title": "Enclos des éléphants",
"missed_count": 45,
"missed_rate": 12,
"suggestion": "Rayon trop petit (30m) ou point mal placé"
}
]
}
"""
# Temps moyen par séquence
Scénario: Comparaison durée audio vs temps d'écoute moyen
Étant donné les métriques temporelles suivantes:
| sequence | duration | avg_listen_time | ecart |
| 1 | 135 | 130 | -5s |
| 2 | 108 | 90 | -18s |
| 3 | 222 | 220 | -2s |
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/time-analysis"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"sequences": [
{
"sequence_id": "seq_1",
"duration_seconds": 135,
"avg_listen_time": 130,
"delta": -5,
"completion_avg": 96
},
{
"sequence_id": "seq_2",
"duration_seconds": 108,
"avg_listen_time": 90,
"delta": -18,
"completion_avg": 83,
"warning": "Séquence souvent skippée ou abandonnée avant la fin"
}
]
}
"""
# Notifications milestones
Scénario: POST /api/v1/audio-guides/{id}/milestones/check - Vérification milestone
Étant donné qu'un audio-guide atteint 1000 écoutes
Quand le système vérifie les milestones
Alors un événement "milestone_reached" est émis
Et une notification est envoyée au créateur:
"""json
{
"type": "milestone",
"milestone_type": "listens_1000",
"audio_guide_id": "ag_123",
"message": "Félicitations ! Votre audio-guide 'Visite du Louvre' a atteint 1000 écoutes !",
"stats": {
"listens": 1000,
"completion_rate": 58
}
}
"""
Plan du Scénario: Milestones prédéfinis
Étant donné qu'un audio-guide atteint <seuil> écoutes
Quand le milestone est vérifié
Alors une notification "<type>" est envoyée
Exemples:
| seuil | type |
| 100 | listens_100 |
| 500 | listens_500 |
| 1000 | listens_1000 |
| 5000 | listens_5000 |
| 10000 | listens_10000 |
# Graphiques et visualisations
Scénario: GET /api/v1/creators/me/audio-guides/{id}/completion-funnel - Entonnoir complétion
Étant donné un audio-guide de 12 séquences
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/completion-funnel"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient un graphique en entonnoir:
"""json
{
"funnel": [
{"sequence": 1, "listeners": 1000, "percentage": 100},
{"sequence": 2, "listeners": 950, "percentage": 95},
{"sequence": 3, "listeners": 890, "percentage": 89},
{"sequence": 12, "listeners": 580, "percentage": 58}
]
}
"""
Scénario: GET /api/v1/creators/me/audio-guides/{id}/listens-over-time - Écoutes dans le temps
Étant donné un audio-guide avec historique
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/listens-over-time?period=30d"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient une série temporelle:
"""json
{
"period": "30d",
"granularity": "day",
"data_points": [
{"date": "2026-01-01", "listens": 45, "completions": 28},
{"date": "2026-01-02", "listens": 52, "completions": 31},
{"date": "2026-01-03", "listens": 38, "completions": 24}
]
}
"""
# Comparaisons et benchmarks
Scénario: GET /api/v1/creators/me/audio-guides/compare - Comparaison audio-guides
Étant donné que le créateur a 3 audio-guides:
| audio_guide_id | title | listens | completion_rate |
| ag_1 | Tour de Paris | 1200 | 65% |
| ag_2 | Visite Louvre | 1542 | 58% |
| ag_3 | Safari du Paugre | 890 | 72% |
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/compare"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"audio_guides": [
{
"audio_guide_id": "ag_2",
"title": "Visite Louvre",
"listens": 1542,
"completion_rate": 58,
"rank_by_listens": 1,
"rank_by_completion": 2
},
{
"audio_guide_id": "ag_1",
"title": "Tour de Paris",
"listens": 1200,
"completion_rate": 65,
"rank_by_listens": 2,
"rank_by_completion": 1
}
]
}
"""
Scénario: Benchmark par rapport à la moyenne plateforme
Étant donné qu'un audio-guide a un taux de complétion de 58%
Et que la moyenne plateforme pour la catégorie "musée" est 62%
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/benchmark"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"your_completion_rate": 58,
"category_avg": 62,
"platform_avg": 60,
"performance": "below_category_avg",
"percentile": 45
}
"""
# Événements trackés
Scénario: POST /api/v1/events/track - Tracking événements utilisateur
Étant donné qu'un utilisateur interagit avec un audio-guide
Quand un événement se produit
Alors il est tracké avec les données suivantes:
| événement | données |
| audio_guide_started | audio_guide_id, mode, user_id |
| sequence_completed | sequence_id, completion_rate, duration |
| audio_guide_completed | audio_guide_id, total_time, sequences_count|
| point_gps_triggered | point_id, distance, auto_or_manual |
| point_gps_missed | point_id, distance, action_taken |
Scénario: Exemple événement audio_guide_started
Quand un audio-guide démarre
Alors l'événement suivant est envoyé:
"""json
{
"event_type": "audio_guide_started",
"timestamp": "2026-01-22T14:00:00Z",
"user_id": "user_456",
"audio_guide_id": "ag_123",
"mode": "voiture",
"device": "ios",
"location": {"lat": 43.1234, "lon": 2.5678}
}
"""
Scénario: Exemple événement point_gps_triggered
Quand un point GPS déclenche une séquence
Alors l'événement suivant est envoyé:
"""json
{
"event_type": "point_gps_triggered",
"timestamp": "2026-01-22T14:05:30Z",
"user_id": "user_456",
"audio_guide_id": "ag_123",
"sequence_id": "seq_2",
"trigger_type": "gps_auto",
"distance_to_point": 25,
"speed_kmh": 28
}
"""
# Export données
Scénario: GET /api/v1/creators/me/audio-guides/{id}/export - Export CSV
Étant donné un audio-guide avec historique complet
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/export?format=csv"
Alors le code HTTP de réponse est 200
Et le Content-Type est "text/csv"
Et le fichier CSV contient:
| user_id | sequence_id | started_at | completed_at | completion_rate |
| user_123 | seq_1 | 2026-01-22 14:10:00 | 2026-01-22 14:12:15 | 100 |
| user_123 | seq_2 | 2026-01-22 14:12:20 | 2026-01-22 14:14:08 | 100 |
Scénario: Export JSON pour analyse externe
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/export?format=json"
Alors le code HTTP de réponse est 200
Et le Content-Type est "application/json"
Et le fichier JSON contient toutes les métriques détaillées
# Cache et performance
Scénario: Cache Redis pour stats fréquemment consultées
Étant donné que les stats globales d'un audio-guide sont en cache Redis
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/stats"
Alors les stats sont récupérées depuis Redis (pas PostgreSQL)
Et le temps de réponse est < 50ms
Et le cache a un TTL de 5 minutes
Scénario: Invalidation cache lors de nouvelles écoutes
Étant donné que les stats sont en cache Redis
Quand une nouvelle écoute complète est enregistrée
Alors le cache Redis est invalidé pour cet audio-guide
Et le prochain GET recalcule les stats depuis PostgreSQL
Scénario: Pré-calcul stats quotidien (job batch)
Étant donné que le job batch s'exécute chaque nuit à 3h
Quand le job démarre
Alors pour chaque audio-guide actif:
- Les stats sont calculées depuis les événements bruts
- Les résultats sont stockés dans audio_guide_stats_daily
- Les agrégations (7j, 30j, 90j) sont pré-calculées
Et les requêtes du lendemain sont instantanées (lecture table pré-calculée)

View File

@@ -0,0 +1,394 @@
# language: fr
Fonctionnalité: API - Progression et synchronisation audio-guides
En tant que système backend
Je veux sauvegarder et synchroniser la progression des audio-guides
Afin de permettre une reprise fluide et multi-device
Contexte:
Étant donné que l'API RoadWave est démarrée
Et que l'utilisateur "user@example.com" est authentifié
# 16.6.1 - Sauvegarde progression
Scénario: POST /api/v1/audio-guides/{id}/progress - Sauvegarde progression
Étant donné un audio-guide "ag_123" en cours d'écoute
Et que l'utilisateur est à la séquence 3 position 1:42
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/progress":
"""json
{
"current_sequence_id": "seq_3",
"current_position_seconds": 102,
"completed_sequences": ["seq_1", "seq_2"],
"gps_position": {
"latitude": 43.1234,
"longitude": 2.5678
}
}
"""
Alors le code HTTP de réponse est 200
Et la progression est sauvegardée en PostgreSQL
Et le timestamp last_played_at est mis à jour
Et le corps de réponse contient:
"""json
{
"saved": true,
"synced_to_cloud": true,
"updated_at": "2026-01-22T14:35:42Z"
}
"""
Scénario: Sauvegarde automatique toutes les 30 secondes (client)
Étant donné que le client mobile envoie la progression toutes les 30s
Quand je fais un POST sur "/api/v1/audio-guides/{id}/progress"
Alors la progression précédente est écrasée
Et le timestamp est mis à jour
Et la réponse est retournée en < 100ms
Scénario: Sauvegarde des séquences complétées (>80%)
Étant donné qu'une séquence de durée 180 secondes
Et que l'utilisateur a écouté 150 secondes (83%)
Quand la progression est sauvegardée
Alors la séquence est ajoutée à completed_sequences
Et le completion_rate est enregistré à 83%
Scénario: Séquence non marquée complétée si <80%
Étant donné qu'une séquence de durée 222 secondes
Et que l'utilisateur a écouté 90 secondes (40%)
Quand la progression est sauvegardée
Alors la séquence n'est PAS ajoutée à completed_sequences
Et le current_position est sauvegardé (pour reprise)
Scénario: Structure de données progression en PostgreSQL
Étant donné une sauvegarde de progression
Alors la table audio_guide_progress contient:
| champ | type | description |
| id | UUID | ID progression |
| user_id | UUID | ID utilisateur |
| audio_guide_id | UUID | ID audio-guide |
| current_sequence_id | UUID | Séquence en cours |
| current_position | INTEGER | Position en secondes |
| completed_sequences | UUID[] | Tableau séquences complétées |
| last_played_at | TIMESTAMP | Dernière écoute |
| gps_position | GEOGRAPHY | Position GPS optionnelle |
| created_at | TIMESTAMP | Création |
| updated_at | TIMESTAMP | Dernière MAJ |
# 16.6.2 - Reprise progression
Scénario: GET /api/v1/audio-guides/{id}/progress - Récupération progression
Étant donné une progression sauvegardée pour "ag_123"
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/progress"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"has_progress": true,
"current_sequence_id": "seq_3",
"current_position_seconds": 102,
"completed_sequences": ["seq_1", "seq_2"],
"completion_percentage": 25,
"last_played_at": "2026-01-20T14:35:42Z",
"can_resume": true
}
"""
Scénario: Calcul completion_percentage
Étant donné un audio-guide de 12 séquences
Et que l'utilisateur a complété 3 séquences
Quand le pourcentage de complétion est calculé
Alors completion_percentage est 25% (3/12)
Scénario: Progression inexistante (première écoute)
Étant donné qu'aucune progression n'existe pour "ag_456"
Quand je fais un GET sur "/api/v1/audio-guides/ag_456/progress"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"has_progress": false,
"can_resume": false
}
"""
Scénario: Progression expirée (>30 jours)
Étant donné une progression avec last_played_at à 35 jours
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/progress"
Alors can_resume est false
Et le message "Progression expirée après 30 jours" est retourné
Et les données sont conservées mais marquées "expired"
Scénario: DELETE /api/v1/audio-guides/{id}/progress - Réinitialisation
Étant donné une progression existante
Quand je fais un DELETE sur "/api/v1/audio-guides/ag_123/progress"
Alors le code HTTP de réponse est 204
Et la progression est supprimée
Et l'utilisateur peut recommencer depuis le début
# 16.6.3 - Multi-device et synchronisation
Scénario: Synchronisation cloud automatique
Étant donné qu'une progression est sauvegardée sur iPhone
Quand l'utilisateur ouvre l'app sur iPad
Et fait un GET sur "/api/v1/audio-guides/ag_123/progress"
Alors la progression iPhone est récupérée
Et l'utilisateur peut reprendre exactement où il était
Scénario: Conflit de synchronisation (dernier timestamp gagne)
Étant donné une progression sur iPhone avec timestamp "2026-01-22T14:00:00Z"
Et une progression sur iPad avec timestamp "2026-01-22T14:05:00Z"
Quand les deux appareils synchronisent
Alors la progression avec timestamp le plus récent (iPad) est conservée
Et la progression iPhone est écrasée
Scénario: GET /api/v1/audio-guides/progress/sync - Synchronisation batch
Étant donné que l'utilisateur a 5 progressions locales non synchronisées
Quand je fais un POST sur "/api/v1/audio-guides/progress/sync":
"""json
{
"progressions": [
{
"audio_guide_id": "ag_1",
"current_sequence_id": "seq_3",
"current_position_seconds": 102,
"updated_at": "2026-01-22T14:00:00Z"
},
{
"audio_guide_id": "ag_2",
"current_sequence_id": "seq_5",
"current_position_seconds": 45,
"updated_at": "2026-01-22T14:10:00Z"
}
]
}
"""
Alors le code HTTP de réponse est 200
Et toutes les progressions sont synchronisées
Et le corps de réponse contient:
"""json
{
"synced_count": 2,
"conflicts": 0
}
"""
Scénario: Résolution conflit avec notification
Étant donné une progression locale sur iPhone avec timestamp ancien
Et une progression cloud plus récente (depuis iPad)
Quand le sync est effectué
Alors la progression cloud est conservée
Et un conflit est signalé dans la réponse:
"""json
{
"synced_count": 1,
"conflicts": 1,
"conflict_details": [
{
"audio_guide_id": "ag_123",
"cloud_timestamp": "2026-01-22T15:00:00Z",
"local_timestamp": "2026-01-22T14:30:00Z",
"resolution": "cloud_wins"
}
]
}
"""
# Historique et statistiques
Scénario: GET /api/v1/audio-guides/progress/history - Historique écoutes
Étant donné que l'utilisateur a écouté 3 séquences d'un audio-guide
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/progress/history"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient l'historique:
"""json
{
"listening_sessions": [
{
"sequence_id": "seq_1",
"started_at": "2026-01-22T14:10:00Z",
"completed_at": "2026-01-22T14:12:15Z",
"completion_rate": 100,
"duration_listened": 135
},
{
"sequence_id": "seq_2",
"started_at": "2026-01-22T14:12:20Z",
"completed_at": "2026-01-22T14:14:08Z",
"completion_rate": 100,
"duration_listened": 108
},
{
"sequence_id": "seq_3",
"started_at": "2026-01-22T14:14:15Z",
"completed_at": "2026-01-22T14:17:45Z",
"completion_rate": 92,
"duration_listened": 204
}
],
"total_time_spent": 447
}
"""
Scénario: Détection complétion 100% de l'audio-guide
Étant donné un audio-guide de 12 séquences
Et que l'utilisateur complète la 12ème et dernière séquence à >80%
Quand la progression est sauvegardée
Alors is_completed passe à true
Et completed_at est mis à jour avec le timestamp actuel
Et un événement "audio_guide_completed" est émis
Scénario: GET /api/v1/users/me/audio-guides/completed - Liste des complétés
Étant donné que l'utilisateur a complété 2 audio-guides
Quand je fais un GET sur "/api/v1/users/me/audio-guides/completed"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"completed_count": 2,
"audio_guides": [
{
"audio_guide_id": "ag_1",
"title": "Tour de Paris",
"completed_at": "2026-01-15T10:00:00Z",
"total_sequences": 10,
"total_duration": 3600
},
{
"audio_guide_id": "ag_2",
"title": "Découverte de Lyon",
"completed_at": "2026-01-20T14:00:00Z",
"total_sequences": 8,
"total_duration": 2700
}
]
}
"""
Scénario: GET /api/v1/users/me/audio-guides/in-progress - Liste en cours
Étant donné que l'utilisateur a 3 audio-guides en cours
Quand je fais un GET sur "/api/v1/users/me/audio-guides/in-progress"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"in_progress_count": 3,
"audio_guides": [
{
"audio_guide_id": "ag_123",
"title": "Visite du Louvre",
"current_sequence": 6,
"total_sequences": 12,
"completion_percentage": 50,
"last_played_at": "2026-01-22T14:35:42Z"
}
]
}
"""
# Badges et achievements
Scénario: Attribution badge "Premier audio-guide"
Étant donné qu'un utilisateur complète son 1er audio-guide
Quand le système détecte la complétion
Alors un badge "first_audio_guide" est attribué
Et une notification est envoyée:
"""json
{
"type": "badge_unlocked",
"badge_id": "first_audio_guide",
"title": "🎧 Premier audio-guide",
"message": "Félicitations ! Vous avez complété votre premier audio-guide"
}
"""
Plan du Scénario: Attribution badges selon nombre complétés
Étant donné qu'un utilisateur complète son <nombre>ème audio-guide
Quand le système détecte la complétion
Alors le badge "<badge_id>" est attribué
Exemples:
| nombre | badge_id |
| 1 | first_audio_guide |
| 5 | explorer |
| 10 | completist |
| 25 | expert |
| 50 | master |
# Nettoyage et archivage
Scénario: Archivage progressions inactives (>6 mois)
Étant donné une progression avec last_played_at à 200 jours
Quand le job de nettoyage automatique s'exécute
Alors la progression est déplacée vers la table audio_guide_progress_archive
Et elle reste récupérable via l'API pendant 30 jours supplémentaires
Et après 7 mois total, elle est supprimée définitivement
Scénario: GET /api/v1/audio-guides/{id}/progress/archived - Récupération archivée
Étant donné une progression archivée
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/progress/archived"
Alors le code HTTP de réponse est 200
Et la progression archivée est retournée
Et un message indique: "Progression archivée. Vous pouvez la restaurer."
Scénario: POST /api/v1/audio-guides/{id}/progress/restore - Restauration
Étant donné une progression archivée
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/progress/restore"
Alors le code HTTP de réponse est 200
Et la progression est déplacée de archive vers la table active
Et l'utilisateur peut reprendre son écoute
# Cas d'erreur
Scénario: Sauvegarde progression audio-guide inexistant
Quand je fais un POST sur "/api/v1/audio-guides/ag_nonexistant/progress"
Alors le code HTTP de réponse est 404
Et le message d'erreur est "Audio-guide non trouvé"
Scénario: Sauvegarde progression séquence invalide
Étant donné un audio-guide "ag_123" avec 8 séquences
Et une requête avec current_sequence_id "seq_99" (inexistant)
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/progress"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "Séquence inexistante pour cet audio-guide"
Scénario: Récupération progression sans authentification
Étant donné une requête sans token JWT
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/progress"
Alors le code HTTP de réponse est 401
Et le message d'erreur est "Authentification requise"
Scénario: Corruption de données progression (récupération)
Étant donné une progression avec données corrompues (JSON invalide)
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/progress"
Alors le système tente une récupération depuis le backup quotidien
Et si récupération réussie, les données sont restaurées
Et un log d'erreur est créé pour investigation
Scénario: Échec synchronisation cloud (mode dégradé)
Étant donné que la base PostgreSQL est temporairement indisponible
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/progress"
Alors le code HTTP de réponse est 503
Et le message d'erreur est "Service temporairement indisponible. Réessayez dans quelques instants."
Et le client doit conserver la progression localement et réessayer
# Performance et optimisations
Scénario: Index sur user_id + audio_guide_id pour requêtes rapides
Étant donné un index composite (user_id, audio_guide_id)
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/progress"
Alors la requête PostgreSQL utilise l'index
Et le temps de réponse est < 20ms
Scénario: Cache Redis pour progressions actives
Étant donné qu'une progression est fréquemment mise à jour (toutes les 30s)
Quand la progression est sauvegardée
Alors elle est également cachée dans Redis avec TTL 1h
Et les GET suivants lisent depuis Redis (pas PostgreSQL)
Et la latence est < 10ms
Scénario: Invalidation cache Redis lors de réinitialisation
Étant donné qu'une progression est en cache Redis
Quand je fais un DELETE sur "/api/v1/audio-guides/ag_123/progress"
Alors l'entrée cache Redis est supprimée
Et la base PostgreSQL est mise à jour
Et la cohérence est garantie

View File

@@ -0,0 +1,357 @@
# language: fr
Fonctionnalité: API - Publicités dans audio-guides
En tant que système backend
Je veux gérer l'insertion et la diffusion de publicités dans les audio-guides
Afin de monétiser les contenus gratuits
Contexte:
Étant donné que l'API RoadWave est démarrée
Et que l'utilisateur "user@example.com" est authentifié (gratuit)
# 16.5.1 - Insertion publicité
Scénario: Calcul insertion publicité (1 pub toutes les 5 séquences)
Étant donné un audio-guide gratuit avec 12 séquences
Et que la fréquence pub est configurée à "1/5"
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/ad-schedule"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"ad_frequency": "1/5",
"ad_insertions": [
{"after_sequence": 5, "position": "after"},
{"after_sequence": 10, "position": "after"}
],
"total_ads": 2
}
"""
Plan du Scénario: Fréquence publicité configurable admin
Étant donné que la fréquence pub est configurée à <frequence>
Et un audio-guide avec 12 séquences
Quand les insertions pub sont calculées
Alors le nombre de pubs insérées est <nombre_pubs>
Exemples:
| frequence | nombre_pubs |
| 1/3 | 4 |
| 1/5 | 2 |
| 1/10 | 1 |
Scénario: Utilisateur Premium - Aucune publicité
Étant donné un utilisateur Premium
Et un audio-guide gratuit
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/ad-schedule"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"ad_frequency": "0",
"ad_insertions": [],
"total_ads": 0,
"reason": "premium_user"
}
"""
Scénario: POST /api/v1/audio-guides/playback/next-ad - Récupération pub suivante
Étant donné qu'un utilisateur termine la séquence 5
Et qu'une pub doit être insérée
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/playback/next-ad":
"""json
{
"sequence_completed": 5,
"user_position": {
"latitude": 43.1234,
"longitude": 2.5678
},
"mode": "voiture"
}
"""
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"should_play_ad": true,
"ad": {
"ad_id": "ad_789",
"audio_url": "https://cdn.roadwave.fr/ads/ad_789.m4a",
"duration_seconds": 30,
"skippable_after": 5,
"advertiser": "Brand X"
}
}
"""
Scénario: Sélection pub géolocalisée
Étant donné que l'utilisateur est en Île-de-France (43.1234, 2.5678)
Et que des publicités géolocalisées existent pour cette région
Quand la pub suivante est sélectionnée
Alors l'API filtre les pubs par:
| critère | valeur |
| Géolocalisation | Île-de-France |
| Catégorie | Tourisme, Culture |
| Langue | Français |
| Budget actif | true |
Et une pub correspondante est retournée
Scénario: Fallback pub nationale si pas de pub locale
Étant donné que l'utilisateur est dans une région sans pubs locales
Quand la pub suivante est sélectionnée
Alors l'API sélectionne une pub nationale (France entière)
Et la pub est retournée normalement
Scénario: Pas de pub si séquence non multiple de 5
Étant donné qu'un utilisateur termine la séquence 4
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/playback/next-ad"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"should_play_ad": false,
"reason": "not_ad_sequence"
}
"""
# Comportement selon mode
Scénario: Pub en mode piéton (auto-play puis pause)
Étant donné un audio-guide en mode piéton
Et qu'une pub doit être insérée après séquence 5
Quand la pub est récupérée
Alors le mode_behavior retourné est:
"""json
{
"auto_play": true,
"pause_after": true,
"reason": "pedestrian_mode"
}
"""
Scénario: Pub en mode voiture/vélo/transport (auto-play puis séquence suivante)
Étant donné un audio-guide en mode voiture
Et qu'une pub doit être insérée
Quand la pub est récupérée
Alors le mode_behavior retourné est:
"""json
{
"auto_play": true,
"pause_after": false,
"continue_to_next": true,
"reason": "vehicle_mode"
}
"""
# 16.5.2 - Métriques et tracking
Scénario: POST /api/v1/ads/{ad_id}/impressions - Enregistrement impression
Étant donné qu'une pub "ad_789" démarre
Quand je fais un POST sur "/api/v1/ads/ad_789/impressions":
"""json
{
"audio_guide_id": "ag_123",
"sequence_after": 5,
"user_id": "user_456",
"timestamp": "2026-01-22T14:35:00Z"
}
"""
Alors le code HTTP de réponse est 201
Et l'impression est enregistrée dans ad_impressions
Et le compteur impressions_count est incrémenté
Scénario: POST /api/v1/ads/{ad_id}/completions - Enregistrement écoute complète
Étant donné qu'une pub de 30 secondes est écoutée à 25 secondes (83%)
Quand je fais un POST sur "/api/v1/ads/ad_789/completions":
"""json
{
"audio_guide_id": "ag_123",
"listened_seconds": 25,
"total_duration": 30,
"completion_rate": 83
}
"""
Alors le code HTTP de réponse est 201
Et l'écoute complète est enregistrée (>80%)
Et le créateur de l'audio-guide reçoit 0.003 de revenus
Scénario: POST /api/v1/ads/{ad_id}/skips - Enregistrement skip
Étant donné qu'une pub est skippée après 6 secondes
Quand je fais un POST sur "/api/v1/ads/ad_789/skips":
"""json
{
"audio_guide_id": "ag_123",
"skipped_at_second": 6
}
"""
Alors le code HTTP de réponse est 201
Et le skip est enregistré dans ad_skips
Et le taux de skip est mis à jour
Scénario: Validation écoute complète (>80%)
Étant donné qu'une pub de 30 secondes est écoutée 20 secondes (66%)
Quand je fais un POST sur "/api/v1/ads/ad_789/completions"
Alors le code HTTP de réponse est 400
Et le message d'erreur est "completion_rate: minimum 80% requis pour écoute complète"
Et aucun revenu n'est attribué
# Métriques créateur
Scénario: GET /api/v1/creators/me/audio-guides/{id}/ad-metrics - Métriques pub
Étant donné un audio-guide "ag_123" avec publicités
Et les métriques suivantes sur 30 jours:
| impressions | ecoutes_completes | skips | revenus |
| 1000 | 650 | 350 | 1.95 |
Quand je fais un GET sur "/api/v1/creators/me/audio-guides/ag_123/ad-metrics"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"period": "30_days",
"impressions": 1000,
"completions": 650,
"skips": 350,
"completion_rate": 65,
"revenue": 1.95,
"cpm": 1.95
}
"""
Scénario: Distinction revenus contenus classiques vs audio-guides
Étant donné un créateur avec contenus classiques et audio-guides
Quand je fais un GET sur "/api/v1/creators/me/revenue-breakdown"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"total_revenue": 85.40,
"breakdown": {
"classic_content": {
"revenue": 45.20,
"impressions": 15000
},
"audio_guides": {
"revenue": 40.20,
"impressions": 13000
}
}
}
"""
# Répartition revenus
Scénario: Calcul revenus créateur (3€ / 1000 écoutes complètes)
Étant donné un audio-guide avec 1000 écoutes complètes pub ce mois
Quand les revenus sont calculés
Alors le créateur reçoit 3
Et le revenu par écoute complète est 0.003
Scénario: POST /api/v1/ads/revenue/process - Calcul revenus batch mensuel
Étant donné le 1er du mois
Et que 500 créateurs ont des revenus pub à calculer
Quand le job batch s'exécute
Alors pour chaque créateur:
| creator_id | ecoutes_completes | revenus |
| creator_1 | 5000 | 15.00 |
| creator_2 | 2000 | 6.00 |
| creator_3 | 1200 | 3.60 |
Et les revenus sont ajoutés au solde creator_balance
# Normalisation audio
Scénario: Validation volume pub (-14 LUFS)
Étant donné qu'une pub est uploadée avec volume -10 LUFS
Quand la pub est validée
Alors un processus de normalisation est déclenché
Et le volume est ajusté à -14 LUFS (standard RoadWave)
Et la pub normalisée est stockée sur le CDN
Scénario: Validation durée pub (max 60 secondes)
Étant donné qu'une pub de 75 secondes est uploadée
Quand la validation est effectuée
Alors le code HTTP de réponse est 400
Et le message d'erreur est "duration: maximum 60 secondes pour une publicité"
# Cas d'erreur
Scénario: Aucune pub disponible (stock épuisé)
Étant donné qu'aucune campagne pub n'est active dans la région
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/playback/next-ad"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"should_play_ad": false,
"reason": "no_ads_available"
}
"""
Et aucune pub n'est insérée (séquence suivante démarre directement)
Scénario: Budget campagne épuisé
Étant donné qu'une campagne pub a un budget de 1000
Et que le budget est épuisé
Quand la pub est sélectionnée
Alors cette campagne est exclue de la sélection
Et une autre campagne active est choisie
Scénario: Pub corrompue ou indisponible
Étant donné qu'une pub sélectionnée a un fichier audio corrompu
Quand le client tente de la charger
Alors une pub de fallback (backup) est retournée
Et un log d'erreur est créé pour investigation
# Filtrage et ciblage
Scénario: Ciblage par catégorie audio-guide
Étant donné un audio-guide tagué "tourisme", "culture", "musée"
Et une campagne pub ciblée "tourisme + culture"
Quand la pub est sélectionnée
Alors cette campagne a une priorité élevée (matching tags)
Et elle est préférée aux pubs génériques
Scénario: Filtrage par classification âge
Étant donné un audio-guide classifié "tout_public"
Et une campagne pub classifiée "18+"
Quand la pub est sélectionnée
Alors cette campagne est exclue
Et seules les pubs "tout_public" sont éligibles
Scénario: Limite fréquence pub par utilisateur (cap frequency)
Étant donné qu'un utilisateur a déjà entendu la pub "ad_789" 3 fois ce jour
Et que le cap frequency est configuré à 3/jour
Quand la pub est sélectionnée
Alors "ad_789" est exclue
Et une autre pub est choisie
Scénario: GET /api/v1/audio-guides/{id}/ad-policy - Politique pub
Étant donné un audio-guide
Quand je fais un GET sur "/api/v1/audio-guides/ag_123/ad-policy"
Alors le code HTTP de réponse est 200
Et le corps de réponse contient:
"""json
{
"has_ads": true,
"frequency": "1/5",
"skippable_after_seconds": 5,
"average_ad_duration": 30,
"revenue_share": {
"creator": "100%",
"platform": "0%"
}
}
"""
# Performance
Scénario: Cache Redis pour pubs actives
Étant donné que les campagnes actives sont en cache Redis
Quand je fais un POST sur "/api/v1/audio-guides/ag_123/playback/next-ad"
Alors les pubs sont récupérées depuis Redis (pas PostgreSQL)
Et le temps de réponse est < 30ms
Scénario: Pre-fetching pub suivante (client)
Étant donné que l'utilisateur est à la séquence 3
Et qu'une pub sera insérée après la séquence 5
Quand le client détecte l'approche de la séquence 5
Alors il peut pré-charger la pub via GET "/api/v1/audio-guides/ag_123/ad-prefetch?after_sequence=5"
Et la transition sera instantanée