diff --git a/features/api/interest-gauges/evolution-jauges.feature b/features/api/interest-gauges/evolution-jauges.feature index 23016d5..b933dbd 100644 --- a/features/api/interest-gauges/evolution-jauges.feature +++ b/features/api/interest-gauges/evolution-jauges.feature @@ -479,3 +479,89 @@ Fonctionnalité: API - Évolution des jauges d'intérêt ] } """ + + # Architecture backend - Services séparés + + Scénario: Gauge Calculation Service calcule l'ajustement (stateless) + Étant donné un événement d'écoute avec 85% de complétion + Quand le Gauge Calculation Service calcule l'ajustement + Alors le service retourne: + """json + { + "adjustment_type": "automatic_reinforced", + "adjustment_value": 2.0, + "reason": "completion_percentage >= 80%" + } + """ + Et le service est stateless (aucune lecture DB) + Et le service est testable unitairement + + Scénario: Gauge Update Service applique l'ajustement (stateful) + Étant donné qu'un ajustement de +2% a été calculé + Et que la jauge "Automobile" de "user123" est à 45% + Quand le Gauge Update Service applique l'ajustement + Alors la nouvelle valeur est 47% (45 + 2) + Et la borne [0, 100] est respectée via fonction clamp + Et la mise à jour est persistée en Redis (immédiat) + Et la mise à jour est persistée en PostgreSQL (batch async) + + Scénario: Pattern de calcul - Addition de points absolus + Étant donné qu'une jauge est à 60% + Et qu'un ajustement de +2% est calculé + Quand le calcul est effectué + Alors la formule est: newValue = currentValue + adjustment + Et la formule est: newValue = clamp(newValue, 0.0, 100.0) + Et la nouvelle valeur est 62% + + Scénario: Pattern de calcul - Éviter multiplication relative + Étant donné qu'une jauge est à 50% + Et qu'un ajustement de +2% est calculé + Quand le calcul est effectué + Alors la formule utilisée est: 50 + 2 = 52 + Et la formule utilisée n'est PAS: 50 * (1 + 2/100) = 51 + Car l'ajustement est en points absolus, pas relatifs + + Scénario: Multi-tags - Mise à jour de N jauges simultanément + Étant donné qu'un contenu a 3 tags: ["Automobile", "Voyage", "Technologie"] + Et qu'un ajustement de +2% est calculé (écoute 85%) + Quand le Gauge Update Service applique + Alors 3 jauges sont mises à jour: + | catégorie | ajustement | + | Automobile | +2% | + | Voyage | +2% | + | Technologie | +2% | + Et toutes les mises à jour sont effectuées en une seule transaction + + Scénario: Persistance Redis immédiate (latence <10ms) + Étant donné qu'un ajustement doit être persisté + Quand le Gauge Update Service écrit en Redis + Alors la latence est < 10ms + Et la jauge est immédiatement disponible pour recommandations + Et Redis sert de cache haute performance + + Scénario: Persistance PostgreSQL asynchrone (batch 5 min) + Étant donné que 100 ajustements ont été appliqués en Redis + Quand le batch async s'exécute toutes les 5 minutes + Alors les 100 ajustements sont écrits en PostgreSQL en batch + Et la cohérence finale est garantie + Et l'application reste performante (pas de write sync) + + Scénario: Séparation responsabilités - Calculation vs Update + Étant donné un événement d'écoute + Quand il est traité + Alors le Gauge Calculation Service calcule l'ajustement (logique métier pure) + Et le Gauge Update Service applique l'ajustement (persistance) + Et les deux services sont indépendants + Et chaque service a une responsabilité unique (SRP) + + Scénario: Réutilisabilité Calculation Service + Étant donné le Gauge Calculation Service + Quand il est utilisé pour: + | contexte | + | Like automatique | + | Skip rapide | + | Like manuel | + | Abonnement créateur | + Alors le même service calcule tous les ajustements + Et la logique métier est centralisée + Et il n'y a pas de duplication de code diff --git a/features/api/navigation/file-attente.feature b/features/api/navigation/file-attente.feature new file mode 100644 index 0000000..82f9abe --- /dev/null +++ b/features/api/navigation/file-attente.feature @@ -0,0 +1,314 @@ +# language: fr +Fonctionnalité: API - File d'attente et pré-calcul des contenus + En tant qu'API backend + Je veux pré-calculer et gérer la file d'attente de contenus + Afin d'assurer une navigation fluide sans latence + + Contexte: + Étant donné que l'API RoadWave est disponible + Et que Redis est accessible + Et que PostgreSQL avec PostGIS est accessible + Et qu'un utilisateur "user123" existe avec token JWT valide + + # Pré-calcul initial + + Scénario: API pré-calcule 5 contenus au démarrage de session + Étant donné que l'utilisateur "user123" démarre une session + Et qu'il est situé à Paris (48.8566, 2.3522) + Et qu'il est en mode voiture (vitesse ≥ 5 km/h) + Quand je POST /api/v1/queue/initialize + """json + { + "user_id": "user123", + "latitude": 48.8566, + "longitude": 2.3522, + "mode": "voiture" + } + """ + Alors le statut de réponse est 201 + Et la réponse contient: + """json + { + "queue_size": 5, + "contents": [ + {"id": "content1", "title": "...", "position": 1}, + {"id": "content2", "title": "...", "position": 2}, + {"id": "content3", "title": "...", "position": 3}, + {"id": "content4", "title": "...", "position": 4}, + {"id": "content5", "title": "...", "position": 5} + ] + } + """ + Et en Redis, la clé "user:user123:queue" contient 5 contenus + Et les métadonnées incluent: + | champ | valeur | + | last_lat | 48.8566 | + | last_lon | 2.3522 | + | mode | voiture | + | computed_at | (timestamp actuel) | + Et le TTL est de 900 secondes (15 minutes) + + Scénario: API GET retourne la file d'attente en cache + Étant donné qu'une file de 5 contenus existe en cache Redis pour "user123" + Quand je GET /api/v1/queue + Alors le statut de réponse est 200 + Et la réponse contient les 5 contenus pré-calculés + Et la latence de réponse est < 50ms (lecture Redis) + + Scénario: API retire un contenu de la file après lecture + Étant donné qu'une file de 5 contenus [C1, C2, C3, C4, C5] existe pour "user123" + Quand je POST /api/v1/queue/consume + """json + { + "user_id": "user123", + "content_id": "C1" + } + """ + Alors le statut de réponse est 200 + Et la file d'attente devient [C2, C3, C4, C5] + Et la taille de la file est 4 + + # Recalcul automatique + + Scénario: API recalcule après déplacement >10km + Étant donné qu'une file a été calculée à Paris (48.8566, 2.3522) + Et que l'utilisateur se déplace à Versailles (48.8049, 2.1204) soit 12km + Quand je POST /api/v1/queue/update-location + """json + { + "user_id": "user123", + "latitude": 48.8049, + "longitude": 2.1204 + } + """ + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "queue_invalidated": true, + "reason": "distance_threshold_exceeded", + "distance_km": 12.1, + "new_queue_size": 5 + } + """ + Et la nouvelle file est basée sur la position Versailles + Et l'ancienne file a été supprimée de Redis + + Scénario: API ne recalcule pas si déplacement ≤10km + Étant donné qu'une file a été calculée à Paris (48.8566, 2.3522) + Et que l'utilisateur se déplace de 8 km + Quand je POST /api/v1/queue/update-location + """json + { + "user_id": "user123", + "latitude": 48.8500, + "longitude": 2.3600 + } + """ + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "queue_invalidated": false, + "distance_km": 8.2, + "threshold": 10 + } + """ + Et la file en cache reste inchangée + + Scénario: API recalcule après 10 minutes + Étant donné qu'une file a été calculée à 10:00:00 + Et que l'heure actuelle est 10:10:01 + Quand je GET /api/v1/queue + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "queue_invalidated": true, + "reason": "time_threshold_exceeded", + "elapsed_minutes": 10, + "new_queue_size": 5 + } + """ + Et une nouvelle file de 5 contenus est recalculée + Et le timestamp "computed_at" est mis à jour + + Scénario: API recalcule quand il reste <3 contenus + Étant donné qu'il reste 3 contenus [C3, C4, C5] dans la file + Quand je POST /api/v1/queue/consume (consomme C3) + Alors le statut de réponse est 200 + Et la file devient [C4, C5] + Et un recalcul asynchrone est déclenché + Et 3 nouveaux contenus [C6, C7, C8] sont ajoutés + Et la file finale est [C4, C5, C6, C7, C8] + + # Invalidation immédiate + + Scénario: API invalide après modification préférences utilisateur + Étant donné qu'une file de 5 contenus existe pour "user123" + Et que l'utilisateur est en mode piéton (vitesse < 5 km/h) + Quand je PUT /api/v1/users/user123/preferences + """json + { + "geo_radius": 20, + "discovery_factor": 0.7, + "political_content": false + } + """ + Alors le statut de réponse est 200 + Et la file d'attente est invalidée immédiatement + Et une nouvelle file est recalculée avec les nouvelles préférences + Et en Redis, l'ancienne file a été supprimée + + Scénario: API refuse modification préférences si vitesse >10 km/h + Étant donné que l'utilisateur roule à 50 km/h + Quand je PUT /api/v1/users/user123/preferences + """json + { + "geo_radius": 30 + } + """ + Alors le statut de réponse est 403 + Et la réponse contient: + """json + { + "error": "MODIFICATION_BLOCKED_WHILE_DRIVING", + "message": "Modification des préférences interdite en conduite (vitesse > 10 km/h)", + "current_speed_kmh": 50 + } + """ + Et les préférences ne sont pas modifiées + + Scénario: API invalide après démarrage live d'un créateur suivi + Étant donné que l'utilisateur "user123" suit le créateur "creator456" + Et qu'une file de 5 contenus existe + Et que l'utilisateur est dans la zone du créateur + Quand le créateur "creator456" démarre un live + Alors une notification push est envoyée à "user123" + Et la file d'attente est recalculée + Et le contenu live est inséré en tête de file + Et la nouvelle file commence par le live + + # Métadonnées et persistence + + Scénario: API stocke métadonnées complètes en Redis + Étant donné qu'une file est calculée à 10:30:00 + Quand je consulte Redis avec la clé "user:user123:queue" + Alors la structure est: + """json + { + "contents": [ + {"id": "C1", "position": 1}, + {"id": "C2", "position": 2}, + {"id": "C3", "position": 3}, + {"id": "C4", "position": 4}, + {"id": "C5", "position": 5} + ], + "metadata": { + "last_lat": 48.8566, + "last_lon": 2.3522, + "computed_at": "2026-02-02T10:30:00Z", + "mode": "voiture" + } + } + """ + Et le TTL est exactement 900 secondes + + Scénario: API calcule distance avec PostGIS + Étant donné que l'ancienne position est Paris (48.8566, 2.3522) + Et que la nouvelle position est Versailles (48.8049, 2.1204) + Quand l'API calcule la distance + Alors la requête SQL utilise: + """sql + SELECT ST_Distance( + ST_MakePoint(2.3522, 48.8566)::geography, + ST_MakePoint(2.1204, 48.8049)::geography + ) / 1000 AS distance_km + """ + Et le résultat est 12.1 km + + # Gestion erreurs + + Scénario: API gère échec Redis gracieusement + Étant donné que Redis est indisponible + Quand je GET /api/v1/queue + Alors le statut de réponse est 503 + Et la réponse contient: + """json + { + "error": "CACHE_UNAVAILABLE", + "message": "Service de cache temporairement indisponible", + "fallback": "Calcul direct sans cache" + } + """ + Et une nouvelle file est calculée directement depuis PostgreSQL + + Scénario: API gère aucun contenu disponible + Étant donné qu'aucun contenu n'existe dans la zone de l'utilisateur + Quand je POST /api/v1/queue/initialize + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "queue_size": 0, + "contents": [], + "message": "Aucun contenu disponible dans cette zone", + "suggested_action": "expand_radius" + } + """ + + Scénario: API élargit la zone de recherche si aucun contenu + Étant donné qu'aucun contenu n'existe dans un rayon de 20km + Quand je POST /api/v1/queue/expand-radius + """json + { + "user_id": "user123", + "additional_radius_km": 50 + } + """ + Alors le statut de réponse est 200 + Et la recherche utilise un rayon de 70km (20 + 50) + Et une nouvelle file est calculée avec ce rayon + + # Performance + + Scénario: API répond en <100ms pour lecture cache + Étant donné qu'une file existe en Redis + Quand je GET /api/v1/queue à 10:30:00.000 + Alors la réponse est reçue à 10:30:00.050 (50ms) + Et la latence est < 100ms + + Scénario: API recalcule en arrière-plan sans bloquer + Étant donné qu'il reste 2 contenus dans la file + Quand je POST /api/v1/queue/consume + Alors le statut de réponse est 200 (immédiat) + Et la réponse est retournée en < 100ms + Et le recalcul asynchrone démarre en parallèle + Et le client ne perçoit aucune latence + + Plan du Scénario: Conditions de recalcul selon distance + Étant donné qu'une file a été calculée à une position donnée + Quand l'utilisateur se déplace de km + Alors la file est + + Exemples: + | distance | action | + | 5 | conservée | + | 9.9 | conservée | + | 10.0 | conservée | + | 10.1 | invalidée et recalculée | + | 15 | invalidée et recalculée | + | 50 | invalidée et recalculée | + + Plan du Scénario: Conditions de recalcul selon temps écoulé + Étant donné qu'une file a été calculée il y a minutes + Quand je consulte la file + Alors la file est + + Exemples: + | temps | action | + | 5 | conservée | + | 9 | conservée | + | 10 | invalidée et recalculée | + | 15 | invalidée et recalculée | + | 60 | invalidée et recalculée | diff --git a/features/api/navigation/notifications-geolocalisees.feature b/features/api/navigation/notifications-geolocalisees.feature new file mode 100644 index 0000000..3d23abc --- /dev/null +++ b/features/api/navigation/notifications-geolocalisees.feature @@ -0,0 +1,390 @@ +# language: fr +Fonctionnalité: API - Notifications géolocalisées et quota anti-spam + En tant qu'API backend + Je veux gérer les notifications de contenus géolocalisés + Afin de respecter les quotas et détecter le bon timing + + Contexte: + Étant donné que l'API RoadWave est disponible + Et que Redis est accessible + Et qu'un utilisateur "user123" existe en mode voiture + + # Calcul ETA et déclenchement notification + + Scénario: API calcule ETA vers point géolocalisé + Étant donné qu'un contenu géolocalisé existe à la position (48.8584, 2.2945) Tour Eiffel + Et que l'utilisateur roule à 50 km/h vers ce point + Et qu'il est actuellement à 98 mètres + Quand je POST /api/v1/geo-notifications/check-eta + """json + { + "user_id": "user123", + "current_lat": 48.8577, + "current_lon": 2.2950, + "speed_kmh": 50, + "target_content_id": "content_tower" + } + """ + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "eta_seconds": 7, + "distance_meters": 98, + "should_notify": true, + "notification_trigger": "eta_threshold" + } + """ + + Scénario: API déclenche notification 7 secondes avant arrivée + Étant donné qu'un contenu géolocalisé existe au point GPS + Et que l'ETA calculé est 7 secondes + Quand je POST /api/v1/geo-notifications/trigger + """json + { + "user_id": "user123", + "content_id": "content_tower", + "eta_seconds": 7 + } + """ + Alors le statut de réponse est 201 + Et une notification est envoyée au mobile + Et la notification contient: + | champ | valeur | + | type | geo_anchored | + | countdown | 7 | + | icon | 🏛️ (selon type contenu) | + | sound | bip court | + + Scénario: API ne déclenche pas si ETA >7 secondes + Étant donné qu'un contenu géolocalisé existe + Et que l'ETA calculé est 10 secondes + Quand je POST /api/v1/geo-notifications/check-eta + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "eta_seconds": 10, + "should_notify": false, + "reason": "eta_above_threshold" + } + """ + Et aucune notification n'est envoyée + + Scénario: API déclenche notification immédiate si vitesse faible et proche + Étant donné qu'un contenu géolocalisé existe + Et que l'utilisateur roule à 3 km/h + Et qu'il est à 40 mètres du point + Quand je POST /api/v1/geo-notifications/check-eta + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "eta_seconds": 0, + "distance_meters": 40, + "speed_kmh": 3, + "should_notify": true, + "notification_trigger": "proximity_and_low_speed" + } + """ + Et une notification est envoyée immédiatement + + # Quota anti-spam + + Scénario: API vérifie quota avant notification (6 contenus max/heure) + Étant donné que l'utilisateur a déjà reçu 5 notifications géo dans la dernière heure + Quand je POST /api/v1/geo-notifications/check-quota + """json + { + "user_id": "user123" + } + """ + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "quota_available": true, + "notifications_count": 5, + "quota_limit": 6, + "remaining": 1 + } + """ + + Scénario: API refuse notification si quota atteint (6/6) + Étant donné que l'utilisateur a déjà reçu 6 notifications géo dans la dernière heure + Quand je POST /api/v1/geo-notifications/trigger + """json + { + "user_id": "user123", + "content_id": "content_x" + } + """ + Alors le statut de réponse est 429 + Et la réponse contient: + """json + { + "error": "QUOTA_EXCEEDED", + "message": "Quota horaire de notifications géolocalisées atteint (6/6)", + "notifications_count": 6, + "quota_limit": 6, + "reset_in_seconds": 1800 + } + """ + Et aucune notification n'est envoyée + + Scénario: API incrémente quota après notification envoyée + Étant donné que l'utilisateur a reçu 3 notifications géo + Quand je POST /api/v1/geo-notifications/trigger (succès) + Alors le quota passe à 4/6 + Et en Redis, la clé "user:user123:geo_quota" contient 4 entrées + Et le sorted set contient les timestamps des 4 notifications + + Scénario: API utilise rolling window pour quota horaire + Étant donné que l'utilisateur a reçu 6 notifications: + | timestamp | + | 10:00:00 | + | 10:15:00 | + | 10:30:00 | + | 10:45:00 | + | 11:00:00 | + | 11:05:00 | + Et que l'heure actuelle est 11:10:00 + Quand je vérifie le quota + Alors les notifications avant 10:10:00 sont expirées + Et seules 4 notifications sont comptées (10:15, 10:30, 10:45, 11:00, 11:05 = 5) + Et le quota disponible est 1/6 + + Scénario: API exclut séquences d'audio-guide multi-séquences du quota + Étant donné qu'un audio-guide "Visite Louvre" a 12 séquences + Et que l'utilisateur a écouté les 12 séquences + Quand je calcule le quota + Alors l'audio-guide compte pour 1 seule notification + Et les 12 séquences ne consomment pas 12 quota + Et le quota utilisé est 1/6 + + # Cooldown après ignorance + + Scénario: API active cooldown 10 min si notification ignorée + Étant donné qu'une notification géo a été envoyée à 10:00:00 + Et que l'utilisateur n'a pas appuyé sur "Suivant" dans les 7 secondes + Quand je POST /api/v1/geo-notifications/ignored + """json + { + "user_id": "user123", + "notification_id": "notif123" + } + """ + Alors le statut de réponse est 200 + Et un cooldown de 10 minutes est activé + Et en Redis, la clé "user:user123:geo_cooldown" est créée + Et le TTL est 600 secondes (10 minutes) + + Scénario: API refuse notification si cooldown actif + Étant donné qu'un cooldown est actif depuis 5 minutes + Et qu'il reste 5 minutes de cooldown + Quand je POST /api/v1/geo-notifications/trigger + Alors le statut de réponse est 429 + Et la réponse contient: + """json + { + "error": "COOLDOWN_ACTIVE", + "message": "Cooldown actif après notification ignorée", + "cooldown_remaining_seconds": 300 + } + """ + Et aucune notification n'est envoyée + + Scénario: API autorise notification après expiration cooldown + Étant donné qu'un cooldown a été activé à 10:00:00 + Et que l'heure actuelle est 10:11:00 (11 minutes après) + Quand je POST /api/v1/geo-notifications/check-quota + Alors le cooldown a expiré + Et les notifications sont à nouveau autorisées + + # Tracking GPS temps réel + + Scénario: API reçoit positions GPS toutes les secondes + Étant donné que le mobile envoie des positions GPS + Quand je POST /api/v1/gps/track + """json + { + "user_id": "user123", + "latitude": 48.8577, + "longitude": 2.2950, + "speed_kmh": 50, + "timestamp": "2026-02-02T10:30:00Z" + } + """ + Alors le statut de réponse est 200 + Et la position est stockée dans l'historique GPS (Redis) + Et l'historique conserve les 30 derniers points + + Scénario: API calcule vitesse moyenne sur 30 derniers points + Étant donné que l'historique GPS contient 30 points + Et que les vitesses enregistrées sont variables + Quand je GET /api/v1/gps/average-speed + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "average_speed_kmh": 48.5, + "samples_count": 30, + "period_seconds": 30 + } + """ + + Scénario: API détecte contenus géolocalisés dans rayon 500m + Étant donné que l'utilisateur est à la position (48.8577, 2.2950) + Et qu'un contenu géolocalisé existe à 300m + Quand je GET /api/v1/geo-notifications/nearby + """json + { + "user_id": "user123", + "latitude": 48.8577, + "longitude": 2.2950, + "radius_meters": 500 + } + """ + Alors le statut de réponse est 200 + Et la réponse contient: + """json + { + "nearby_contents": [ + { + "id": "content_tower", + "distance_meters": 300, + "eta_seconds": 21, + "should_notify_soon": false + } + ] + } + """ + + # Validation contenu géolocalisé + + Scénario: API valide que contenu géolocalisé accepté + Étant donné qu'une notification géo a été envoyée + Et que l'utilisateur a appuyé sur "Suivant" dans les 7 secondes + Quand je POST /api/v1/geo-notifications/accepted + """json + { + "user_id": "user123", + "notification_id": "notif123", + "content_id": "content_tower" + } + """ + Alors le statut de réponse est 200 + Et le contenu géolocalisé est inséré en priorité + Et un décompte de 5 secondes est déclenché côté client + Et le quota n'est PAS incrémenté (acceptation = pas de spam) + + Scénario: API enregistre contenu géolocalisé perdu + Étant donné qu'une notification géo a été envoyée + Et que l'utilisateur n'a pas appuyé sur "Suivant" + Quand je POST /api/v1/geo-notifications/ignored + Alors le contenu géolocalisé est perdu + Et il n'est PAS inséré dans la file d'attente + Et une métrique "geo_notification_ignored" est enregistrée + Et le cooldown 10 min est activé + + # Métriques + + Scénario: API stocke métriques de notifications géolocalisées + Étant donné que l'API envoie des notifications géo + Quand je consulte les métriques + Alors les données suivantes sont disponibles: + | métrique | description | + | geo_notifications_sent | Nombre total envoyées | + | geo_notifications_accepted | Nombre acceptées (Suivant) | + | geo_notifications_ignored | Nombre ignorées | + | geo_acceptance_rate | % acceptation | + | average_eta_seconds | ETA moyen au déclenchement | + | quota_exceeded_count | Nombre de refus quota | + + # Redis structures + + Scénario: API utilise sorted set Redis pour quota + Étant donné que l'utilisateur a reçu 3 notifications + Quand je consulte Redis "user:user123:geo_quota" + Alors la structure est un sorted set: + """ + ZADD user:user123:geo_quota + 1707047400 "notif1" + 1707047700 "notif2" + 1707048000 "notif3" + """ + Et les scores sont les timestamps Unix + Et le TTL est 3600 secondes (1 heure) + + Scénario: API compte notifications avec ZCOUNT + Étant donné que le sorted set contient plusieurs notifications + Et que l'heure actuelle est 11:00:00 (timestamp 1707048000) + Quand l'API vérifie le quota + Alors la commande Redis est: + """ + ZCOUNT user:user123:geo_quota 1707044400 1707048000 + """ + Et seules les notifications des 60 dernières minutes sont comptées + + # Gestion erreurs + + Scénario: API gère échec calcul ETA gracieusement + Étant donné qu'une position GPS est invalide + Quand je POST /api/v1/geo-notifications/check-eta + """json + { + "user_id": "user123", + "current_lat": 200, + "current_lon": 300 + } + """ + Alors le statut de réponse est 400 + Et la réponse contient: + """json + { + "error": "INVALID_GPS_COORDINATES", + "message": "Coordonnées GPS invalides" + } + """ + + Scénario: API gère vitesse nulle sans crash + Étant donné que l'utilisateur est à l'arrêt (vitesse = 0) + Et qu'un contenu géolocalisé est à 100m + Quand je POST /api/v1/geo-notifications/check-eta + Alors l'ETA est incalculable (vitesse nulle) + Et aucune notification n'est envoyée + Et la réponse contient: + """json + { + "should_notify": false, + "reason": "speed_zero" + } + """ + + Plan du Scénario: Déclenchement selon ETA + Étant donné qu'un contenu géolocalisé existe + Et que l'ETA calculé est secondes + Quand je vérifie si notification doit être envoyée + Alors la décision est + + Exemples: + | eta | decision | + | 3 | notifier | + | 5 | notifier | + | 7 | notifier | + | 8 | ne pas notifier | + | 10 | ne pas notifier | + | 15 | ne pas notifier | + + Plan du Scénario: Quota selon nombre notifications + Étant donné que l'utilisateur a reçu notifications dans l'heure + Quand je vérifie le quota + Alors le quota est + + Exemples: + | count | status | + | 0 | disponible | + | 3 | disponible | + | 5 | disponible | + | 6 | atteint | + | 7 | atteint | diff --git a/features/ui/navigation/mode-pieton-notifications-push.feature b/features/ui/navigation/mode-pieton-notifications-push.feature new file mode 100644 index 0000000..df04ba8 --- /dev/null +++ b/features/ui/navigation/mode-pieton-notifications-push.feature @@ -0,0 +1,265 @@ +# language: fr +Fonctionnalité: Mode piéton - Notifications push et basculement automatique + En tant qu'utilisateur à pied + Je veux recevoir des notifications push pour audio-guides à proximité + Afin d'être alerté même avec l'application en arrière-plan + + Contexte: + Étant donné que l'application RoadWave est installée + Et que l'utilisateur est connecté + + # Détection automatique mode piéton + + Scénario: Basculement automatique vers mode piéton (vitesse < 5 km/h) + Étant donné que je suis en mode voiture (vitesse 50 km/h) + Quand ma vitesse GPS moyenne passe à 3 km/h pendant 10 secondes + Alors l'application bascule automatiquement en mode piéton + Et aucune popup de confirmation n'est affichée + Et les notifications passent de "sonores + icône" à "push arrière-plan" + Et le rayon de détection passe de 7s ETA à 200 mètres + + Scénario: Basculement automatique vers mode voiture (vitesse ≥ 5 km/h) + Étant donné que je suis en mode piéton (vitesse 3 km/h) + Quand ma vitesse GPS moyenne passe à 20 km/h pendant 10 secondes + Alors l'application bascule automatiquement en mode voiture + Et aucune popup de confirmation n'est affichée + Et les notifications passent de "push arrière-plan" à "sonores + icône" + Et le rayon de détection passe de 200m à 7s ETA + + Scénario: Hysteresis pour éviter basculements intempestifs + Étant donné que je suis en mode piéton (vitesse 3 km/h) + Quand ma vitesse passe brièvement à 6 km/h pendant 2 secondes + Et qu'elle redescend à 3 km/h + Alors le mode piéton est conservé + Et aucun basculement n'a lieu + Car la durée de 10 secondes stables n'a pas été atteinte + + Scénario: Vitesse moyenne calculée sur 30 secondes + Étant donné que je suis en mode voiture + Et que mes vitesses sur 30 secondes sont: [50, 48, 52, 3, 2, 4, 3, 2, ...] + Quand la vitesse moyenne sur 30s devient < 5 km/h + Et qu'elle reste stable pendant 10 secondes + Alors le basculement vers mode piéton s'effectue + + # Permissions et activation + + Scénario: Permission "While Using App" demandée au premier lancement + Étant donné que c'est le premier lancement de l'application + Quand j'arrive sur l'onboarding + Alors une demande de permission "Autoriser la localisation pendant l'utilisation" s'affiche + Et l'explication est: "RoadWave utilise votre position pour proposer des contenus audio géolocalisés adaptés à votre trajet" + Quand j'accepte + Alors le mode voiture est pleinement fonctionnel + Et le mode piéton notifications push n'est PAS encore activé + + Scénario: Permission "Always" demandée uniquement si user active mode piéton + Étant donné que j'utilise RoadWave avec permission "While Using App" + Et que je vais dans Réglages > Notifications audio-guides piéton + Quand je clique sur le toggle "Activer notifications piéton" + Alors un écran d'éducation s'affiche: + """ + 📍 Notifications audio-guides piéton + + Pour vous alerter d'audio-guides à proximité même + quand vous marchez avec l'app fermée, RoadWave a + besoin de votre position en arrière-plan. + + Votre position sera utilisée pour : + ✅ Détecter monuments à 200m + ✅ Vous envoyer une notification + + Votre position ne sera jamais : + ❌ Vendue à des tiers + ❌ Utilisée pour de la publicité + + Cette fonctionnalité est optionnelle. + Vous pouvez utiliser RoadWave sans cette permission. + + [Continuer] [Non merci] + """ + Quand je clique sur "Continuer" + Alors la demande système "Autoriser toujours" (iOS) ou "Autoriser tout le temps" (Android) s'affiche + Et le mode piéton push est activé si j'accepte + + Scénario: Permission arrière-plan refusée désactive mode piéton push + Étant donné que je refuse la permission "Autoriser toujours" + Quand je reviens dans l'application + Alors le toggle "Notifications piéton" est grisé et désactivé + Et un message s'affiche: "Permission refusée. Mode piéton désactivé." + Et le mode voiture reste pleinement fonctionnel + Et je peux toujours utiliser les audio-guides en mode manuel + + Scénario: Permission arrière-plan peut être accordée plus tard + Étant donné que j'ai refusé la permission arrière-plan + Quand je vais dans Réglages de l'app + Alors un bouton "Activer notifications piéton" est disponible + Et un lien vers les réglages système iOS/Android est fourni + Quand j'accorde la permission dans les réglages système + Et que je reviens dans l'app + Alors le mode piéton push est automatiquement activé + + # Notifications push en arrière-plan + + Scénario: Notification push quand app en arrière-plan (mode piéton) + Étant donné que je suis en mode piéton + Et que l'application est en arrière-plan (fermée) + Et que je marche à proximité du Louvre + Quand je passe dans le rayon de 200 mètres d'un audio-guide + Alors une notification push système s'affiche: + """ + Audio-guide à proximité + Musée du Louvre : La Joconde - @paris_museum + """ + Et je reçois la notification même si l'app est fermée + + Scénario: Tap sur notification ouvre app sur le contenu + Étant donné qu'une notification push "Audio-guide à proximité" s'affiche + Quand je tape sur la notification + Alors l'application s'ouvre + Et je suis redirigé vers la page du contenu audio-guide + Et je peux démarrer la lecture manuellement + Et je peux voir la description, le créateur, la durée, etc. + + Scénario: Geofencing iOS/Android pour économie batterie + Étant donné que le mode piéton est activé + Et que l'application utilise geofencing natif + Quand je me déplace à pied + Alors l'OS iOS/Android gère la détection de proximité + Et l'application n'a pas besoin d'être constamment active + Et la batterie est préservée (< 5% consommation supplémentaire) + + Scénario: Rayon de détection 200 mètres en mode piéton + Étant donné que je suis en mode piéton + Et qu'un audio-guide existe à 250 mètres + Quand je me déplace + Alors aucune notification n'est envoyée (hors rayon) + Quand je passe à 180 mètres + Alors une notification push est envoyée + Car je suis dans le rayon de 200m + + # Quota anti-spam mode piéton + + Scénario: Même quota 6 notifications/heure en mode piéton + Étant donné que je suis en mode piéton + Et que j'ai reçu 6 notifications push dans la dernière heure + Quand je passe près d'un 7ème audio-guide + Alors aucune notification n'est envoyée + Et le quota horaire est respecté + + Scénario: Cooldown 10 min si notification ignorée (app pas ouverte) + Étant donné qu'une notification push a été envoyée + Et que je ne l'ai pas ouverte dans les 10 minutes + Quand le système détecte l'ignorance + Alors un cooldown de 10 minutes est activé + Et aucune nouvelle notification n'est envoyée pendant 10 min + + # Basculement automatique + + Scénario: Basculement transparent sans friction + Étant donné que je marche à 3 km/h (mode piéton) + Et que je monte dans ma voiture + Quand ma vitesse passe à 50 km/h (stable 10s) + Alors le basculement vers mode voiture s'effectue automatiquement + Et je n'ai rien à faire + Et aucune popup ne m'interrompt + Et l'expérience est transparente + + Scénario: Notifications adaptées automatiquement + Étant donné que je passe de piéton (3 km/h) à voiture (50 km/h) + Quand le basculement s'effectue + Alors les notifications push arrière-plan sont désactivées + Et les notifications sonores + icône (app ouverte) sont activées + Et le rayon passe de 200m à ETA 7 secondes + Et l'adaptation est immédiate + + # Garantie RGPD et fonctionnalité optionnelle + + Scénario: Application utilisable sans permission arrière-plan + Étant donné que je refuse la permission "Autoriser toujours" + Quand j'utilise RoadWave + Alors le mode voiture fonctionne à 100% + Et je peux accéder à tous les audio-guides en mode manuel + Et je peux télécharger des contenus offline + Et seules les notifications push piéton sont désactivées + + Scénario: Révocation permission arrière-plan désactive mode piéton + Étant donné que le mode piéton push était actif + Quand je révoque la permission dans les réglages iOS/Android + Et que j'ouvre l'application + Alors un message s'affiche: "Permission arrière-plan révoquée. Mode piéton désactivé." + Et le toggle est grisé dans les réglages + Et le mode voiture reste fonctionnel + + Scénario: Désactivation mode piéton dans réglages + Étant donné que le mode piéton push est actif + Quand je vais dans Réglages > Notifications audio-guides piéton + Et que je désactive le toggle + Alors les notifications push sont arrêtées + Et le geofencing est désactivé + Et la permission arrière-plan reste accordée (mais non utilisée) + Et je peux réactiver plus tard + + # Messages d'information + + Scénario: Message clair sur usage permission arrière-plan + Étant donné que je consulte les réglages + Quand j'affiche les informations sur le mode piéton + Alors je vois: + """ + RoadWave utilise votre position en arrière-plan uniquement + pour vous alerter d'audio-guides à proximité quand vous + marchez. Cette fonctionnalité peut être désactivée à tout + moment. Votre position n'est jamais partagée avec des tiers. + """ + + # Cas limites + + Scénario: Mode indéterminé si vitesse exactement 5 km/h + Étant donné que ma vitesse moyenne est exactement 5.0 km/h + Quand le système vérifie le mode + Alors le mode actuel est conservé (pas de basculement) + Car le seuil est strict (< 5 km/h pour piéton, ≥ 5 km/h pour voiture) + + Scénario: Basculement impossible si GPS désactivé + Étant donné que je désactive le GPS + Quand le système tente de détecter la vitesse + Alors aucun basculement automatique ne se produit + Et le mode actuel est conservé par défaut + Et un message "GPS désactivé" s'affiche si je tente de naviguer + + Scénario: Notification push ignorée ne consomme pas de quota + Étant donné qu'une notification push est envoyée + Et que je ne l'ouvre pas (ignorée) + Quand je consulte mon quota + Alors cette notification compte quand même dans le quota 6/h + Et le cooldown 10 min est activé + + Plan du Scénario: Basculement selon vitesse + Étant donné que ma vitesse moyenne est km/h pendant 10s + Et que je suis en mode + Quand le système détecte la vitesse stable + Alors le mode devient + + Exemples: + | vitesse | mode_initial | mode_final | + | 2 | voiture | piéton | + | 3 | voiture | piéton | + | 4 | voiture | piéton | + | 5 | piéton | voiture | + | 10 | piéton | voiture | + | 50 | piéton | voiture | + | 130 | piéton | voiture | + + Plan du Scénario: Rayon de détection selon mode + Étant donné que je suis en mode + Quand un contenu géolocalisé existe à + Alors une notification est + + Exemples: + | mode | distance | decision | + | piéton | 150m | envoyée | + | piéton | 199m | envoyée | + | piéton | 201m | non envoyée | + | piéton | 300m | non envoyée | + | voiture | 98m (ETA 7s) | envoyée | + | voiture | 150m (ETA 10s) | non envoyée |