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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,9 +6,9 @@
|
|||||||
*.dylib
|
*.dylib
|
||||||
/bin/
|
/bin/
|
||||||
/dist/
|
/dist/
|
||||||
api
|
/api
|
||||||
worker
|
/worker
|
||||||
migrate
|
/migrate
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
402
features/api/audio-guides/creation-gestion.feature
Normal file
402
features/api/audio-guides/creation-gestion.feature
Normal 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."
|
||||||
339
features/api/audio-guides/declenchement-gps.feature
Normal file
339
features/api/audio-guides/declenchement-gps.feature
Normal 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 "7→1" 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 "7→1" 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
|
||||||
|
}
|
||||||
|
"""
|
||||||
485
features/api/audio-guides/metriques-analytics.feature
Normal file
485
features/api/audio-guides/metriques-analytics.feature
Normal 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)
|
||||||
394
features/api/audio-guides/progression-sync.feature
Normal file
394
features/api/audio-guides/progression-sync.feature
Normal 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
|
||||||
357
features/api/audio-guides/publicites.feature
Normal file
357
features/api/audio-guides/publicites.feature
Normal 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
|
||||||
Reference in New Issue
Block a user