diff --git a/.gitignore b/.gitignore index 3283dfe..472563a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,9 @@ *.dylib /bin/ /dist/ -api -worker -migrate +/api +/worker +/migrate # Test binary, built with `go test -c` *.test diff --git a/features/api/audio-guides/creation-gestion.feature b/features/api/audio-guides/creation-gestion.feature new file mode 100644 index 0000000..461de55 --- /dev/null +++ b/features/api/audio-guides/creation-gestion.feature @@ -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 "" + Quand je fais un POST sur "/api/v1/audio-guides" + Alors le code HTTP de réponse est + + 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 "" + 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 + + 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." diff --git a/features/api/audio-guides/declenchement-gps.feature b/features/api/audio-guides/declenchement-gps.feature new file mode 100644 index 0000000..d4e568e --- /dev/null +++ b/features/api/audio-guides/declenchement-gps.feature @@ -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 + Et un point GPS avec rayon par défaut + Quand l'utilisateur entre à du point + Alors should_trigger est + + 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 ° + Quand la flèche est calculée + Alors la direction retournée est "" + + 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 + Et que l'utilisateur passe à du point + Alors tolerance_zone est + + 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 + } + """ diff --git a/features/api/audio-guides/metriques-analytics.feature b/features/api/audio-guides/metriques-analytics.feature new file mode 100644 index 0000000..208ab21 --- /dev/null +++ b/features/api/audio-guides/metriques-analytics.feature @@ -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= + Alors les stats de la période 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 écoutes + Quand le milestone est vérifié + Alors une notification "" 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) diff --git a/features/api/audio-guides/progression-sync.feature b/features/api/audio-guides/progression-sync.feature new file mode 100644 index 0000000..616ae1b --- /dev/null +++ b/features/api/audio-guides/progression-sync.feature @@ -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 ème audio-guide + Quand le système détecte la complétion + Alors le badge "" 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 diff --git a/features/api/audio-guides/publicites.feature b/features/api/audio-guides/publicites.feature new file mode 100644 index 0000000..0b354f6 --- /dev/null +++ b/features/api/audio-guides/publicites.feature @@ -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 à + Et un audio-guide avec 12 séquences + Quand les insertions pub sont calculées + Alors le nombre de pubs insérées est + + 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