diff --git a/backend/gestion/db/db_clients.go b/backend/gestion/db/db_clients.go index d0a4df0d..fe652c92 100644 --- a/backend/gestion/db/db_clients.go +++ b/backend/gestion/db/db_clients.go @@ -791,13 +791,6 @@ func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (rem return remainingAvailable, nil } -// ClaimPoolRewardAndAddToBasket réclame une récompense ET ajoute les articles -// récompense au panier dans une seule transaction : si les articles ne -// peuvent pas être ajoutés (produit supprimé/inexistant configuré par -// l'admin), toute l'opération est annulée — la récompense n'est pas -// consommée. Corrige un bug où ClaimPoolReward et AddRewardsToBasket, -// appelés séparément, pouvaient consommer une récompense sans livrer aucun -// produit au client si l'ajout au panier échouait après coup. func (d *Database) ClaimPoolRewardAndAddToBasket(username, poolKey string, threshold int, items []models.RewardItem) (remainingAvailable int, added []models.Panier, err error) { err = d.GDB.Transaction(func(tx *gorm.DB) error { var err error diff --git a/backend/gestion/db/db_gps_link.go b/backend/gestion/db/db_gps_link.go index 3a33b86c..40a2cddd 100644 --- a/backend/gestion/db/db_gps_link.go +++ b/backend/gestion/db/db_gps_link.go @@ -25,20 +25,16 @@ func wazeAppLink(lat, lon float64) string { return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon) } -// GenerateMapLinks génère tous les liens de cartes pour une position GPS func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks { return MapLinks{ WazeApp: wazeAppLink(lat, lon), } } -// GenerateNavigationLink génère un lien de navigation vers une destination -// fromLat/fromLon sont ignorés : Waze part toujours de la position GPS courante func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string { return wazeAppLink(toLat, toLon) } -// GenerateMapLinksForCommand génère les liens de navigation pour une commande func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) { _, _, err := d.GetDeliveryPersonLocation(deliverymanUsername) if err != nil { diff --git a/backend/gestion/db/db_stat.go b/backend/gestion/db/db_stat.go index f042a2d7..9c283d8b 100644 --- a/backend/gestion/db/db_stat.go +++ b/backend/gestion/db/db_stat.go @@ -388,3 +388,62 @@ func (d *Database) OrdersCountLast30(resetAt time.Time) (int64, error) { err := d.GDB.Raw(query, args...).Scan(&count).Error return count, err } + +func (d *Database) GetMyDeliveryStatsPerDay(statsRows *[]models.DayRowWithResult, username string) error { + query := ` + SELECT DATE(updated_at) AS day, + COUNT(*) AS count, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE livreur_assign = ? + AND status IN ('livre', 'approved') + AND updated_at >= NOW() - INTERVAL '30 days' + GROUP BY DATE(updated_at) + ORDER BY day + ` + return d.GDB.Raw(query, username).Scan(statsRows).Error +} + +func (d *Database) GetMyDeliveryStatsPerWeek(statsRow *[]models.WeekRow, username string) error { + query := ` + SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num, + EXTRACT(YEAR FROM updated_at)::int AS year, + COUNT(*) AS count, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE livreur_assign = ? + AND status IN ('livre', 'approved') + AND updated_at >= NOW() - INTERVAL '12 weeks' + GROUP BY week_num, year + ORDER BY year, week_num + ` + return d.GDB.Raw(query, username).Scan(statsRow).Error +} + +func (d *Database) GetMyDeliveryStatsPerMonth(statsRow *[]models.MonthRow, username string) error { + query := ` + SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num, + EXTRACT(YEAR FROM updated_at)::int AS year, + COUNT(*) AS count, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE livreur_assign = ? + AND status IN ('livre', 'approved') + AND updated_at >= NOW() - INTERVAL '12 months' + GROUP BY month_num, year + ORDER BY year, month_num + ` + return d.GDB.Raw(query, username).Scan(statsRow).Error +} + +func (d *Database) GetMyDeliveryStatsToday(statsRow *models.TodayRow, username string) error { + query := ` + SELECT COUNT(*) AS count, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE livreur_assign = ? + AND status IN ('livre', 'approved') + AND DATE(updated_at) = CURRENT_DATE + ` + return d.GDB.Raw(query, username).Scan(statsRow).Error +} diff --git a/backend/gestion/db/redis_eta_management.go b/backend/gestion/db/redis_eta_management.go index 3aaf39d1..a84c25f7 100644 --- a/backend/gestion/db/redis_eta_management.go +++ b/backend/gestion/db/redis_eta_management.go @@ -163,13 +163,7 @@ func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition i arrivalTime := now.Add(time.Duration(totalETA) * time.Minute) eta := map[string]any{ - "command_id": commandID, - // total_eta_minutes ET eta_minutes doivent tous les deux être présents : - // l'app mobile et le site web lisent eta_minutes (voir - // OrderTrackingScreen.tsx / api.ts), tandis que d'autres lecteurs - // backend (deleviry.go, validation_deleviry.go, geoloca.go) lisent - // total_eta_minutes. Un seul des deux absent reproduit le bug - // "le client ne voit pas le temps". + "command_id": commandID, "total_eta_minutes": totalETA, "eta_minutes": totalETA, "queue_position": queuePosition, diff --git a/backend/gestion/handlers/alert.go b/backend/gestion/handlers/alert.go index 3a6f883a..655e59e4 100644 --- a/backend/gestion/handlers/alert.go +++ b/backend/gestion/handlers/alert.go @@ -27,7 +27,6 @@ func AlertPolice(c *gin.Context) { var req struct { Message string `json:"message"` } - // message optionnel — on ignore l'erreur de bind _ = c.ShouldBindJSON(&req) usernameStr := username.(string) @@ -61,6 +60,25 @@ func DeleteAlert(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"}) return } + + // Un livreur ne peut supprimer que ses propres alertes — admin garde l'accès complet. + if userRole == "livreur" { + username, exists := c.Get("username") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + alert, err := database.GetAlertPolicy(alertID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"}) + return + } + if alert.Username != username.(string) { + c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"}) + return + } + } + if err = database.DeleteAlertPolicy(alertID); err != nil { utils.ServerErr(c, "Impossible de supprimer l'alerte", err) return diff --git a/backend/gestion/handlers/auth.go b/backend/gestion/handlers/auth.go index d0943bda..4ffc960a 100644 --- a/backend/gestion/handlers/auth.go +++ b/backend/gestion/handlers/auth.go @@ -55,13 +55,13 @@ func generateAdminToken(user *models.User) (string, error) { claims := models.AdminClaims{ UserID: user.ID, Username: user.Username, - Role: user.Role, // ← "admin" ou "cabine" ou "livreur" + Role: user.Role, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)), IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()), - Issuer: "api-admin", // Même issuer pour tous les admins + Issuer: "api-admin", Subject: strconv.Itoa(user.ID), }, } @@ -693,3 +693,9 @@ func CreateUser(c *gin.Context) { log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role) c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"}) } + +func Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + }) +} diff --git a/backend/gestion/handlers/cancel_command.go b/backend/gestion/handlers/cancel_command.go index ee437f8f..a00d275c 100644 --- a/backend/gestion/handlers/cancel_command.go +++ b/backend/gestion/handlers/cancel_command.go @@ -195,7 +195,6 @@ func CancelCommandByClient(c *gin.Context) { return } - // ✅ AUTRES ERREURS switch err.Error() { case "commande non trouvée": c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"}) @@ -312,7 +311,6 @@ func GetAllCancelledOrders(c *gin.Context) { return } - // ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles) var enrichedOrders []map[string]any for _, order := range cancelledOrders { orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"])) diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go index bfe11b2f..e71ce7cc 100644 --- a/backend/gestion/handlers/commands.go +++ b/backend/gestion/handlers/commands.go @@ -482,10 +482,6 @@ func StaffApproveDelivery(c *gin.Context) { }) } -// ============================================ -// APPROBATION PAR ADMIN -// ============================================ - func ValidateDelivery(c *gin.Context) { database := c.MustGet("database").(*db.Database) diff --git a/backend/gestion/handlers/deleviry.go b/backend/gestion/handlers/deleviry.go index c83c7b0e..3ddf9677 100644 --- a/backend/gestion/handlers/deleviry.go +++ b/backend/gestion/handlers/deleviry.go @@ -4,13 +4,13 @@ import ( "encoding/json" "fmt" "gestion/db" + "gestion/models" "gestion/services" "gestion/utils" "log" "net/http" "slices" "strconv" - "time" "github.com/gin-gonic/gin" ) @@ -552,10 +552,8 @@ func ReportDeliveryIssue(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue}) } -// GET /api/v1/livreur/stats func GetMyDeliveryStats(c *gin.Context) { database := c.MustGet("database").(*db.Database) - username, exists := c.Get("username") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) @@ -566,80 +564,30 @@ func GetMyDeliveryStats(c *gin.Context) { return } usernameStr := username.(string) - gdb := database.GDB - type DayRow struct { - Day time.Time `gorm:"column:day"` - Count int `gorm:"column:count"` - Revenue float64 `gorm:"column:revenue"` - } - type WeekRow struct { - WeekNum int `gorm:"column:week_num"` - Year int `gorm:"column:year"` - Count int `gorm:"column:count"` - Revenue float64 `gorm:"column:revenue"` - } - type MonthRow struct { - MonthNum int `gorm:"column:month_num"` - Year int `gorm:"column:year"` - Count int `gorm:"column:count"` - Revenue float64 `gorm:"column:revenue"` + var dayRows []models.DayRowWithResult + if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"}) + return } - var dayRows []DayRow - gdb.Raw(` - SELECT DATE(updated_at) AS day, - COUNT(*) AS count, - COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE livreur_assign = ? - AND status IN ('livre', 'approved') - AND updated_at >= NOW() - INTERVAL '30 days' - GROUP BY DATE(updated_at) - ORDER BY day - `, usernameStr).Scan(&dayRows) + var weekRows []models.WeekRow + if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"}) + return + } - var weekRows []WeekRow - gdb.Raw(` - SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num, - EXTRACT(YEAR FROM updated_at)::int AS year, - COUNT(*) AS count, - COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE livreur_assign = ? - AND status IN ('livre', 'approved') - AND updated_at >= NOW() - INTERVAL '12 weeks' - GROUP BY week_num, year - ORDER BY year, week_num - `, usernameStr).Scan(&weekRows) + var monthRows []models.MonthRow + if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"}) + return + } - var monthRows []MonthRow - gdb.Raw(` - SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num, - EXTRACT(YEAR FROM updated_at)::int AS year, - COUNT(*) AS count, - COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE livreur_assign = ? - AND status IN ('livre', 'approved') - AND updated_at >= NOW() - INTERVAL '12 months' - GROUP BY month_num, year - ORDER BY year, month_num - `, usernameStr).Scan(&monthRows) - - type TodayRow struct { - Count int `gorm:"column:count"` - Revenue float64 `gorm:"column:revenue"` + var todayRow models.TodayRow + if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"}) + return } - var todayRow TodayRow - gdb.Raw(` - SELECT COUNT(*) AS count, - COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE livreur_assign = ? - AND status IN ('livre', 'approved') - AND DATE(updated_at) = CURRENT_DATE - `, usernameStr).Scan(&todayRow) monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"} diff --git a/backend/gestion/handlers/delivery_admin.go b/backend/gestion/handlers/delivery_admin.go index a156638a..3439a2df 100644 --- a/backend/gestion/handlers/delivery_admin.go +++ b/backend/gestion/handlers/delivery_admin.go @@ -62,9 +62,9 @@ func GetDeliveryPersonDetails(c *gin.Context) { // Utiliser la fonction GPS existante lat, lon, err := database.GetDeliveryPersonLocation(username) - var locationInfo map[string]interface{} + var locationInfo map[string]any if err == nil { - locationInfo = map[string]interface{}{ + locationInfo = map[string]any{ "latitude": lat, "longitude": lon, } diff --git a/backend/gestion/handlers/eta.go b/backend/gestion/handlers/eta.go index ce650012..11b55cc0 100644 --- a/backend/gestion/handlers/eta.go +++ b/backend/gestion/handlers/eta.go @@ -85,7 +85,6 @@ func GetOrderETA(c *gin.Context) { return } - // 4️⃣ VÉRIFIER LES DROITS D'ACCÈS cmdUsername, _ := command["username"].(string) userRole := c.GetString("role") @@ -112,10 +111,8 @@ func GetOrderETA(c *gin.Context) { } } - // 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE cmdStatus, _ := command["status"].(string) - // ✅ CORRECTION: Vérifier si commande terminée if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" { log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus) c.JSON(http.StatusOK, gin.H{ @@ -129,7 +126,6 @@ func GetOrderETA(c *gin.Context) { return } - // Pour pending/assigned: pas encore de position livreur disponible if cmdStatus == "pending" || cmdStatus == "assigned" { log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus) c.JSON(http.StatusOK, gin.H{ @@ -142,7 +138,6 @@ func GetOrderETA(c *gin.Context) { return } - // Pour arrived: livreur sur place, ETA non pertinent if cmdStatus == "arrived" { log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place") c.JSON(http.StatusOK, gin.H{ @@ -154,9 +149,7 @@ func GetOrderETA(c *gin.Context) { }) return } - // Pour en_route: calcul ETA réel via position du livreur - // 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA etaKey := fmt.Sprintf("command:eta:%d", commandID) etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result() @@ -197,10 +190,8 @@ func GetOrderETA(c *gin.Context) { } } - // 7️⃣ Pas de cache valide - Recalculer l'ETA log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...") - // Récupérer coordonnées destination var destLat, destLon float64 destCacheKey := fmt.Sprintf("command:destination:%d", commandID) @@ -248,13 +239,10 @@ func GetOrderETA(c *gin.Context) { toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon} - // Cas 1 : GPS livreur disponible livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign) if gpsErr != nil { - // Cas 2 : GPS absent → dernière adresse de livraison lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign) if lastErr != nil || lastLat == 0 { - // Cas 3 : Aucune position → cache périmé ou message log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign) c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData)) return @@ -263,7 +251,6 @@ func GetOrderETA(c *gin.Context) { log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon) } - // Calculer ETA avec TomTom log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)", livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude) @@ -274,11 +261,10 @@ func GetOrderETA(c *gin.Context) { etaMinutes = services.CalculateETA(distanceKm) } - // Sauvegarder en cache now := time.Now() arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute) - etaCache := map[string]interface{}{ + etaCache := map[string]any{ "command_id": commandID, "eta_minutes": etaMinutes, "updated_at": now.Unix(), diff --git a/backend/gestion/handlers/geoloca.go b/backend/gestion/handlers/geoloca.go index 70245fe5..61a0cc9a 100644 --- a/backend/gestion/handlers/geoloca.go +++ b/backend/gestion/handlers/geoloca.go @@ -169,10 +169,6 @@ func FindNearestDeliveryPerson(c *gin.Context) { }) } -// ============================================ -// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE -// ============================================ - // GetAllDeliveryDistances retourne tous les livreurs triés par distance func GetAllDeliveryDistances(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -258,9 +254,6 @@ func GetAllDeliveryDistances(c *gin.Context) { }) } -// ============================================ -// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES -// ============================================ func AutoAssignNearestDeliveryPerson(c *gin.Context) { database := c.MustGet("database").(*db.Database) geoService := c.MustGet("geoService").(*services.GeoService) @@ -347,12 +340,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("🚗 %d livreur(s) actif(s)", activeCount) - // Récupérer les livreurs actifs avec capacité disponible activeLivreurs, err := database.GetAllActiveDeliveryPersons() - // Si aucun livreur avec capacité disponible if err != nil || len(activeLivreurs) == 0 { - // Cas 1: Un seul livreur actif -> pas de limite if activeCount == 1 { singleDeliveryman, err := database.GetSingleActiveDeliveryman() if err != nil { @@ -371,7 +361,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // ✅ Passer les coordonnées à la fonction d'assignation err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -386,7 +375,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance) - // ✅ CORRECTION: Utiliser etaData directement sans accès aux clés c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Commande assignée au seul livreur actif (sans limite)", @@ -399,7 +387,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { "single_driver": true, "traffic_aware": true, }, - "eta": etaData, // ✅ Directement l'objet complet + "eta": etaData, "delivery_address": address, "coordinates": gin.H{ "latitude": location.Latitude, @@ -410,13 +398,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity() if allAtCapacity && numActive > 1 { log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive) - // Trouver le livreur le moins chargé (même s'il dépasse 10) leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced() if err != nil { c.JSON(http.StatusNotFound, gin.H{ @@ -424,8 +410,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { }) return } - - // Calculer le temps de trajet avec TomTom travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -433,8 +417,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { }) return } - - // ✅ Assigner de force avec coordonnées err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -449,7 +431,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance) - // ✅ CORRECTION: Utiliser etaData directement c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Commande assignée par distribution forcée (capacité max dépassée)", @@ -463,7 +444,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { "over_capacity": true, "traffic_aware": true, }, - "eta": etaData, // ✅ Directement l'objet complet + "eta": etaData, "delivery_address": address, "coordinates": gin.H{ "latitude": location.Latitude, @@ -474,7 +455,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // Cas 3: Erreur générique c.JSON(http.StatusNotFound, gin.H{ "error": "Aucun livreur actif avec capacité disponible", "active_count": activeCount, @@ -483,13 +463,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // Cas normal: Au moins un livreur avec capacité disponible usernames := make([]string, len(activeLivreurs)) for i, livreur := range activeLivreurs { usernames[i] = livreur.Username } - // Trouver le livreur le plus proche (calcul rapide) nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames) if err != nil { c.JSON(http.StatusNotFound, gin.H{ @@ -498,7 +476,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // Recalculer l'ETA avec TomTom pour plus de précision travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords) if err != nil { // Fallback sur le calcul initial @@ -509,7 +486,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime) - // ✅ Assigner à la queue du livreur avec coordonnées err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -524,7 +500,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username) - // ✅ CORRECTION: Utiliser etaData directement c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Commande assignée à la queue du livreur", @@ -537,7 +512,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { "single_driver": activeCount == 1, "traffic_aware": err == nil, }, - "eta": etaData, // ✅ Directement l'objet complet + "eta": etaData, "delivery_address": address, "coordinates": gin.H{ "latitude": location.Latitude, @@ -547,8 +522,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { }) } -// AutoAssignAllPendingCommands assigne toutes les commandes en attente -// POST /api/v2/admin/protected/commands/auto-assign-all func AutoAssignAllPendingCommands(c *gin.Context) { database := c.MustGet("database").(*db.Database) geoService := c.MustGet("geoService").(*services.GeoService) @@ -559,7 +532,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { return } - // Récupérer toutes les commandes pending commands, err := database.GetAllCommands("pending", "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -585,7 +557,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { for _, cmd := range commands { commandID, ok := cmd["id"].(int) if !ok { - // Essayer avec float64 if idFloat, ok := cmd["id"].(float64); ok { commandID = int(idFloat) } else { @@ -593,7 +564,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { } } - // Récupérer l'adresse address, ok := cmd["adresse"].(string) if !ok || address == "" || address == "Adresse non spécifiée" { failed = append(failed, gin.H{ @@ -603,7 +573,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { continue } - // Géocoder l'adresse location, err := geoService.GeocodeAddress(address) if err != nil { failed = append(failed, gin.H{ @@ -618,7 +587,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { Longitude: location.Longitude, } - // Récupérer les livreurs actifs activeLivreurs, err := database.GetAllActiveDeliveryPersons() if err != nil || len(activeLivreurs) == 0 { failed = append(failed, gin.H{ @@ -633,7 +601,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { usernames[i] = livreur.Username } - // Trouver le livreur le plus proche (version rapide pour assignation masse) nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames) if err != nil { failed = append(failed, gin.H{ @@ -643,7 +610,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { continue } - // Pour l'assignation en masse, on utilise le calcul rapide travelTime := nearest.EstimatedTime distance := nearest.Distance @@ -657,13 +623,10 @@ func AutoAssignAllPendingCommands(c *gin.Context) { continue } - // Mettre à jour le statut du livreur database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID) - - // Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement etaData, _ := database.GetCommandETA(commandID) - var totalETA, waitTime interface{} + var totalETA, waitTime any totalETA = "N/A" waitTime = "N/A" @@ -688,7 +651,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA) } - // Récupérer l'overview des queues queuesOverview, _ := database.GetAllQueuesOverview() c.JSON(http.StatusOK, gin.H{ @@ -705,7 +667,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { } // GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs -// GET /api/v2/admin/protected/delivery/queues func GetAllDeliveryQueues(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -723,7 +684,6 @@ func GetAllDeliveryQueues(c *gin.Context) { return } - // Récupérer les détails de chaque livreur var deliverymenDetails []gin.H keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result() @@ -734,7 +694,7 @@ func GetAllDeliveryQueues(c *gin.Context) { // Récupérer le statut statusData, _ := db.Redis.Get(db.RedisCtx, key).Result() - var status map[string]interface{} + var status map[string]any if statusData != "" { json.Unmarshal([]byte(statusData), &status) } diff --git a/backend/gestion/handlers/gps.go b/backend/gestion/handlers/gps.go index cd154e6b..146443dc 100644 --- a/backend/gestion/handlers/gps.go +++ b/backend/gestion/handlers/gps.go @@ -20,7 +20,6 @@ import ( func GetDeliveryPersonMapLinks(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // Vérification du rôle admin userRole := c.GetString("role") if userRole != "admin" && userRole != "cabine" { c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) @@ -35,7 +34,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) { log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username) - // Récupérer la position GPS du livreur lat, lon, err := database.GetDeliveryPersonLocation(username) if err != nil { log.Printf("❌ [MAP_LINKS] Erreur position: %v", err) @@ -47,7 +45,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) { return } - // Validation des coordonnées if lat == 0 && lon == 0 { log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username) c.JSON(http.StatusNotFound, gin.H{ @@ -58,7 +55,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) { return } - // Générer les liens de cartes mapLinks := database.GenerateMapLinks(lat, lon, username) log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon) @@ -77,8 +73,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) { }) } -// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté -// GET /api/v1/livreur/deliveries/:id/nav-link func GetLivreurNavLink(c *gin.Context) { database := c.MustGet("database").(*db.Database) username := c.GetString("username") @@ -101,7 +95,6 @@ func GetLivreurNavLink(c *gin.Context) { return } - // Priorité : coordonnées GPS de la destination var wazeLink string destLat, hasLat := command["dest_latitude"].(float64) destLon, hasLon := command["dest_longitude"].(float64) diff --git a/backend/gestion/handlers/history.go b/backend/gestion/handlers/history.go index 57430dcb..292bffa2 100644 --- a/backend/gestion/handlers/history.go +++ b/backend/gestion/handlers/history.go @@ -4,6 +4,7 @@ import ( "fmt" "gestion/db" "log" + "maps" "net/http" "strconv" @@ -15,7 +16,6 @@ import ( func GetMyCompletedOrdersWithItems(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ SÉCURITÉ: Récupérer depuis JWT validé username, exists := c.Get("username") if !exists { log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié") @@ -28,7 +28,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { usernameStr := username.(string) log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr) - // ✅ Récupérer les commandes terminées commands, err := database.GetCompletedCommandsByUsername(usernameStr) if err != nil { log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err) @@ -38,7 +37,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { return } - // ✅ Enrichir chaque commande avec ses items var enrichedCommands []map[string]any for _, command := range commands { @@ -47,7 +45,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { continue } - // Récupérer les items de cette commande items, err := database.GetCommandItems(commandID) if err != nil { log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err) @@ -56,9 +53,7 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { // Ajouter les items à la commande enrichedCommand := make(map[string]any) - for k, v := range command { - enrichedCommand[k] = v - } + maps.Copy(enrichedCommand, command) enrichedCommand["items"] = items enrichedCommand["items_count"] = len(items) @@ -67,7 +62,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands)) - // ✅ Récupérer les infos client client, err := database.GetClientByUsername(usernameStr) response := gin.H{ @@ -94,7 +88,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { func GetOrderHistory(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ SÉCURITÉ: Récupérer depuis JWT validé username, exists := c.Get("username") if !exists { log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié") @@ -106,7 +99,6 @@ func GetOrderHistory(c *gin.Context) { usernameStr := username.(string) - // Récupérer l'ID de la commande var commandID int if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil { c.JSON(http.StatusBadRequest, gin.H{ @@ -117,7 +109,6 @@ func GetOrderHistory(c *gin.Context) { log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr) - // ✅ Vérifier que la commande existe command, err := database.GetCommandByID(commandID) if err != nil { log.Printf("❌ [ORDER_HISTORY] Commande non trouvée") @@ -127,7 +118,6 @@ func GetOrderHistory(c *gin.Context) { return } - // ✅ Vérifier que la commande appartient au client cmdUsername, ok := command["username"].(string) if !ok || cmdUsername != usernameStr { log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr) @@ -137,18 +127,16 @@ func GetOrderHistory(c *gin.Context) { return } - // ✅ Récupérer les logs de la commande logs, err := database.GetCommandLogs(commandID) if err != nil { log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err) - logs = []map[string]interface{}{} + logs = []map[string]any{} } - // ✅ Récupérer les items items, err := database.GetCommandItems(commandID) if err != nil { log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err) - items = []map[string]interface{}{} + items = []map[string]any{} } log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items)) diff --git a/backend/gestion/handlers/notifications.go b/backend/gestion/handlers/notifications.go index e26d2337..4f614770 100644 --- a/backend/gestion/handlers/notifications.go +++ b/backend/gestion/handlers/notifications.go @@ -20,7 +20,6 @@ func GetClientNotifications(c *gin.Context) { notifKey := "notifications:" + username - // Récupérer toutes les notifications (max 50) results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result() if err != nil { log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err) @@ -128,7 +127,7 @@ func MarkLivreurNotificationsRead(c *gin.Context) { markedCount := 0 for i, raw := range results { - var n map[string]interface{} + var n map[string]any if err := json.Unmarshal([]byte(raw), &n); err != nil { continue } @@ -171,7 +170,7 @@ func MarkNotificationsRead(c *gin.Context) { // Réécrire chaque notification avec read=true markedCount := 0 for i, raw := range results { - var n map[string]interface{} + var n map[string]any if err := json.Unmarshal([]byte(raw), &n); err != nil { continue } diff --git a/backend/gestion/handlers/panier.go b/backend/gestion/handlers/panier.go index c8a636ad..c581ecf5 100644 --- a/backend/gestion/handlers/panier.go +++ b/backend/gestion/handlers/panier.go @@ -22,9 +22,6 @@ type BasketsRequest struct { Quantity float64 `json:"quantity"` } -// ============================================ -// ✅ SÉCURISÉ: AddProductsBasket -// ============================================ // POST /api/v1/panier/add func AddProductsBasket(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -90,7 +87,6 @@ func GetAllBaskets(c *gin.Context) { return } - // ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé) authUsername, hasAuth := c.Get("username") if !hasAuth { log.Printf("❌ [GET_PANIER] Username manquant dans JWT") @@ -100,7 +96,6 @@ func GetAllBaskets(c *gin.Context) { authUsernameStr := authUsername.(string) - // ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session if username != authUsernameStr { log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!") log.Printf(" Username du JWT: %s", authUsernameStr) @@ -111,10 +106,8 @@ func GetAllBaskets(c *gin.Context) { return } - // ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT username = authUsernameStr - // ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT _, err := database.GetClientByUsername(username) if err != nil { log.Printf("❌ [GET_PANIER] Client inexistant: %s", username) @@ -130,7 +123,7 @@ func GetAllBaskets(c *gin.Context) { var totalAmount float64 for _, item := range baskets { - totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts) + totalAmount += item.Price } log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount) @@ -156,7 +149,6 @@ func DeleteProductFromBasket(c *gin.Context) { return } - // ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé) authUsername, hasAuth := c.Get("username") if !hasAuth { log.Printf("❌ [DEL_PANIER] Username manquant dans JWT") @@ -168,7 +160,6 @@ func DeleteProductFromBasket(c *gin.Context) { log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr) - // ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client itemUsername, err := database.GetBasketItemOwner(req.ID) if err != nil { @@ -187,7 +178,6 @@ func DeleteProductFromBasket(c *gin.Context) { return } - // Supprimer l'article err = database.DeleteProductFromBasket(req.ID) if err != nil { utils.ServerErr(c, "Erreur lors de la suppression", err) @@ -205,7 +195,6 @@ func DeleteProductFromBasket(c *gin.Context) { func ClearBasket(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé) authUsername, hasAuth := c.Get("username") if !hasAuth { log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT") @@ -262,8 +251,8 @@ func ValidateBasket(c *gin.Context) { var req struct { DeliveryAddress string `json:"delivery_address" binding:"required"` UseReferralBalance bool `json:"use_referral_balance"` - PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto" - PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto) + PaymentMethod string `json:"payment_method"` + PayCurrency string `json:"pay_currency"` } if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"}) @@ -277,7 +266,6 @@ func ValidateBasket(c *gin.Context) { } req.DeliveryAddress = cmd.DeliveryAddress - // Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées) if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked { c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"}) @@ -287,9 +275,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr) - // ============================================ - // 1️⃣ Vérifier que le panier n'est pas vide - // ============================================ items, err := database.GetBasketItems(usernameStr) if err != nil { utils.ServerErr(c, "Impossible de récupérer le panier", err) @@ -304,9 +289,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items)) - // ============================================ - // 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant - // ============================================ var cartTotal float64 for _, item := range items { if price, ok := item["price"].(float64); ok { @@ -314,7 +296,6 @@ func ValidateBasket(c *gin.Context) { } } - // Détecter si le panier contient un article récompense (prix 0) hasRewardItem := false for _, item := range items { if price, ok := item["price"].(float64); ok && price == 0 { @@ -322,17 +303,13 @@ func ValidateBasket(c *gin.Context) { break } } - // Si récompense présente mais aucun produit payant → refuser if hasRewardItem && cartTotal <= 0 { log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr) c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"}) return } - - // Récupérer les paramètres globaux (zones + parrainage) appSettings, _ := database.GetSettings() - // Récupérer le solde parrainage disponible (seulement si le système est activé) var referralBalance float64 if req.UseReferralBalance && appSettings.ReferralEnabled { referralBalance, _ = database.GetClientReferralBalance(usernameStr) @@ -366,8 +343,6 @@ func ValidateBasket(c *gin.Context) { return } - // Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone. - // Ex : zone 50€, crédit 50€ → panier doit être >= 100€ var referralUsed float64 if req.UseReferralBalance && referralBalance > 0 { effectivePayment := cartTotal - referralBalance @@ -399,7 +374,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr) } - // Vérifier que tous les produits du panier ont encore un prix actif unavailable, err := database.GetUnavailableBasketItems(usernameStr) if err != nil { utils.ServerErr(c, "Erreur vérification produits", err) @@ -414,7 +388,6 @@ func ValidateBasket(c *gin.Context) { return } - // Vérification option crypto isCrypto := req.PaymentMethod == "crypto" if isCrypto { npRaw, npExists := c.Get("nowpayments") @@ -464,7 +437,6 @@ func ValidateBasket(c *gin.Context) { } payResp, err := np.CreatePayment(payReq) if err != nil { - // Annuler la commande et restaurer le panier / parrainage _ = database.CancelCryptoCommand(commandID) if referralUsed > 0 { _ = database.CreditClientReferral(usernameStr, referralUsed) @@ -474,7 +446,6 @@ func ValidateBasket(c *gin.Context) { return } - // Passer la commande en 'pending_payment' (attente confirmation) if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil { log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err) } @@ -507,12 +478,8 @@ func ValidateBasket(c *gin.Context) { return } - // Notifier immédiatement tous les admins et agents cabine go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress) - // ============================================ - // 3️⃣ Auto-assignation livreur (optionnel) - // ============================================ var assigned bool var assignInfo gin.H @@ -532,7 +499,6 @@ func ValidateBasket(c *gin.Context) { if err == nil { log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance) - // ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic travelTime, distance, err := services.CalculateETAWithTomTom( nearest.Location, services.Coordinates{ @@ -550,7 +516,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance) - // Assigner la commande au livreur err = database.AssignCommandToDeliverymanQueueWithCoords( commandID, nearest.Username, @@ -563,13 +528,10 @@ func ValidateBasket(c *gin.Context) { if err != nil { log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err) } else { - // Mettre à jour le statut du livreur err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID) if err != nil { log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err) } - - // Notifier le livreur de la nouvelle commande notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance) if referralUsed > 0 { notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed) @@ -577,8 +539,6 @@ func ValidateBasket(c *gin.Context) { if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil { log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr) } - - // Notifier le client clientOrderID := database.GetClientOrderID(commandID) clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID) database.NotifyClient(usernameStr, commandID, "assigned", clientMsg) @@ -601,9 +561,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err) } - // ============================================ - // 5️⃣ Réponse - // ============================================ newBalance, _ := database.GetClientReferralBalance(usernameStr) resp := gin.H{ "success": true, @@ -630,7 +587,6 @@ func ValidateBasket(c *gin.Context) { c.JSON(http.StatusCreated, resp) } -// getBaseURL construit l'URL de base depuis la requête en cours func getBaseURL(c *gin.Context) string { scheme := "https" if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { diff --git a/backend/gestion/handlers/parrain.go b/backend/gestion/handlers/parrain.go index 4e311c21..ab7bccc8 100644 --- a/backend/gestion/handlers/parrain.go +++ b/backend/gestion/handlers/parrain.go @@ -9,8 +9,6 @@ import ( "github.com/gin-gonic/gin" ) -// SetClientParrainAdmin — POST /api/v2/admin/protected/client/:username/parrain/set (admin) -// Assigne un parrain à un client. Le parrain reçoit settings.ReferralAmount sur son solde. func SetClientParrainAdmin(c *gin.Context) { database := c.MustGet("database").(*db.Database) targetUsername := c.Param("username") @@ -28,14 +26,12 @@ func SetClientParrainAdmin(c *gin.Context) { return } - // Vérifier que le parrain existe parrain, err := database.GetClientByUsername(req.Parrain) if err != nil || parrain == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"}) return } - // Vérifier que le client n'a pas déjà un parrain existing, err := database.GetClientParrain(targetUsername) if err != nil { utils.ServerErr(c, "Erreur vérification parrain", err) diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go index 4ff3ab77..ff15ca13 100644 --- a/backend/gestion/handlers/points.go +++ b/backend/gestion/handlers/points.go @@ -83,12 +83,9 @@ func GetMyPointsRewards(c *gin.Context) { if reward != nil && reward.Threshold > 0 { earned = pts / reward.Threshold available = earned - redeemed - if available < 0 { - available = 0 - } + available = max(earned-redeemed, 0) } - // Filtrer les category_configs aux seules catégories du pool poolCats := make(map[string]bool, len(pool.Categories)) for _, c := range pool.Categories { poolCats[c] = true @@ -168,7 +165,7 @@ func ClaimMyReward(c *gin.Context) { var req struct { PoolKey string `json:"pool_key" binding:"required"` - ProductID int `json:"product_id"` // optionnel : 0 = automatique (1 seul item) + ProductID int `json:"product_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"}) @@ -207,7 +204,6 @@ func ClaimMyReward(c *gin.Context) { return } - // Si le client a sélectionné un produit spécifique parmi plusieurs, ne donner que celui-là itemsToAdd := reward.RewardItems if req.ProductID > 0 && len(reward.RewardItems) > 1 { for _, item := range reward.RewardItems { @@ -218,9 +214,6 @@ func ClaimMyReward(c *gin.Context) { } } - // Réclamation + ajout au panier dans une seule transaction : si l'ajout - // échoue (produit récompense supprimé/introuvable), la récompense n'est - // pas consommée non plus — pas de perte sèche pour le client. remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd) if err != nil { if strings.Contains(err.Error(), "pas de récompense disponible") { @@ -254,7 +247,6 @@ func ClaimMyReward(c *gin.Context) { }) } -// AdminResetClientRedeemed remet à zéro les récompenses réclamées d'un client (admin). func AdminResetClientRedeemed(c *gin.Context) { username := c.Param("username") poolKey := c.Query("pool_key") diff --git a/backend/gestion/handlers/product.go b/backend/gestion/handlers/product.go index 9ea777ca..47bf91d2 100644 --- a/backend/gestion/handlers/product.go +++ b/backend/gestion/handlers/product.go @@ -118,7 +118,6 @@ func validateCategory(database *db.Database, category string) error { return nil } -// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension) func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) { file, err := fileHeader.Open() if err != nil { @@ -132,7 +131,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) { } mimeType := mtype.String() - // Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...") if idx := strings.Index(mimeType, ";"); idx != -1 { mimeType = strings.TrimSpace(mimeType[:idx]) } @@ -144,17 +142,17 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) { return mimeType, nil } -// ✅ PROTECTION CONTRE PATH TRAVERSAL func sanitizeFilePath(path string) (string, error) { - // Nettoyer le chemin cleaned := filepath.Clean(path) - // Vérifier qu'il ne contient pas de ".." - if strings.Contains(cleaned, "..") { + if strings.Contains(cleaned, "..") || strings.Contains(cleaned, ".") { + return "", fmt.Errorf("path traversal détecté") + } + + if strings.Contains(cleaned, "//..//") || strings.Contains(cleaned, "/../") { return "", fmt.Errorf("path traversal détecté") } - // Vérifier qu'il commence par "uploads/" if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") { return "", fmt.Errorf("chemin invalide") } @@ -165,7 +163,6 @@ func sanitizeFilePath(path string) (string, error) { func CreateProduct(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check) role := c.GetString("role") if role != "admin" && role != "cabine" { c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) @@ -174,14 +171,12 @@ func CreateProduct(c *gin.Context) { username, _ := safeGetUsername(c) - // ✅ PARSER AVEC LIMITE DE TAILLE if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil { log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"}) return } - // ✅ RÉCUPÉRER ET VALIDER LES DONNÉES name := strings.TrimSpace(c.PostForm("name")) category := strings.TrimSpace(c.PostForm("category")) description := strings.TrimSpace(c.PostForm("description")) @@ -191,7 +186,6 @@ func CreateProduct(c *gin.Context) { unit = "u" } - // ✅ VALIDATION STRICTE if err := validateProductName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -202,7 +196,6 @@ func CreateProduct(c *gin.Context) { return } - // ✅ NETTOYER ET VALIDER LA CATÉGORIE category = strings.ToLower(strings.TrimSpace(category)) category = strings.Map(func(r rune) rune { if r < 32 || r == 127 { @@ -221,7 +214,6 @@ func CreateProduct(c *gin.Context) { return } - // ✅ VALIDER LE STOCK stock, err := strconv.ParseFloat(stockStr, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"}) @@ -233,11 +225,10 @@ func CreateProduct(c *gin.Context) { return } - // ✅ RÉCUPÉRER ET VALIDER LES PRIX prices := []models.ProductPrice{} priceIndex := 0 - for priceIndex < 100 { // Limite anti-spam + for priceIndex < 100 { quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex) priceKey := fmt.Sprintf("prices[%d][price]", priceIndex) activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex) @@ -325,7 +316,6 @@ func CreateProduct(c *gin.Context) { return } - // ✅ LIMITER LE NOMBRE DE FICHIERS if len(files) > MaxFilesPerProduct { database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{ @@ -342,7 +332,6 @@ func CreateProduct(c *gin.Context) { var totalSize int64 = 0 for i, fileHeader := range files { - // ✅ VÉRIFIER LA TAILLE INDIVIDUELLE if fileHeader.Size > MaxFileSize { rollbackFiles(savedFiles) database.DeleteProduct(product.ID) @@ -353,8 +342,6 @@ func CreateProduct(c *gin.Context) { } totalSize += fileHeader.Size - - // ✅ VÉRIFIER LA TAILLE TOTALE if totalSize > MaxTotalUploadSize { rollbackFiles(savedFiles) database.DeleteProduct(product.ID) @@ -366,7 +353,6 @@ func CreateProduct(c *gin.Context) { log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename) - // ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension) mimeType, err := validateFileMimeType(fileHeader) if err != nil { log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err) @@ -376,7 +362,6 @@ func CreateProduct(c *gin.Context) { return } - // ✅ DÉTERMINER LE TYPE DE MÉDIA var mediaType string if strings.HasPrefix(mimeType, "image/") { mediaType = "image" @@ -389,10 +374,8 @@ func CreateProduct(c *gin.Context) { return } - // ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename) - // ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE destFolder := filepath.Join("uploads", mediaType+"s") if err := os.MkdirAll(destFolder, 0750); err != nil { log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err) @@ -404,7 +387,6 @@ func CreateProduct(c *gin.Context) { filePath := filepath.Join(destFolder, uniqueFileName) - // ✅ VALIDER LE CHEMIN (protection path traversal) safeFilePath, err := sanitizeFilePath(filePath) if err != nil { log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err) @@ -414,7 +396,6 @@ func CreateProduct(c *gin.Context) { return } - // ✅ SAUVEGARDER LE FICHIER if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil { log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err) rollbackFiles(savedFiles) @@ -425,7 +406,6 @@ func CreateProduct(c *gin.Context) { savedFiles = append(savedFiles, safeFilePath) - // ✅ CRÉER L'ENTRÉE MÉDIA mediaURL := "/" + filepath.ToSlash(safeFilePath) media := models.Media{ ProductID: product.ID, @@ -482,7 +462,6 @@ func GetProductsByCategory(c *gin.Context) { category := strings.ToLower(strings.TrimSpace(c.Param("category"))) - // ✅ VALIDATION if err := validateCategory(database, category); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, @@ -501,7 +480,6 @@ func GetProductsByCategory(c *gin.Context) { return } - // ✅ Charger les médias for i := range products { media, _ := database.GetMediaByProductID(products[i].ID) products[i].Media = media @@ -535,11 +513,9 @@ func GetProductByID(c *gin.Context) { }) return } - // ✅ Charger les médias media, _ := database.GetMediaByProductID(product.ID) product.Media = media - // ✅ Filtrer les prix désactivés (sauf pour admin/cabine) role := c.GetString("role") if role != "admin" && role != "cabine" { filterActivepricesSingle(&product) @@ -554,7 +530,6 @@ func GetProductByID(c *gin.Context) { func UpdateProduct(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ VÉRIFIER LE RÔLE role := c.GetString("role") if role != "admin" && role != "cabine" { c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) @@ -569,7 +544,6 @@ func UpdateProduct(c *gin.Context) { return } - // ✅ VÉRIFIER QUE LE PRODUIT EXISTE _, err = database.GetProductByID(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) @@ -591,7 +565,6 @@ func UpdateProduct(c *gin.Context) { return } - // ✅ VALIDATION COMPLÈTE if err := validateProductName(updateData.Name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -737,7 +710,6 @@ func DeleteMedia(c *gin.Context) { database := c.MustGet("database").(*db.Database) s3Service := c.MustGet("s3Service").(*services.S3Service) - // ✅ VÉRIFIER LE RÔLE role := c.GetString("role") if role != "admin" && role != "cabine" { c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) @@ -756,18 +728,14 @@ func DeleteMedia(c *gin.Context) { return } - // ✅ SUPPRIMER DE LA DB EN PREMIER (source de vérité) if err := database.DeleteMedia(mediaID); err != nil { log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"}) return } - // ✅ SUPPRIMER LE FICHIER SUR RUSTFS if media.Key != "" { if err := s3Service.DeleteFile(media.Key); err != nil { - // On ne fait pas échouer la requête : l'entrée DB est déjà supprimée, - // mais on log pour pouvoir nettoyer manuellement un fichier orphelin si besoin. log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err) } else { log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key) @@ -784,7 +752,6 @@ func UploadMedia(c *gin.Context) { database := c.MustGet("database").(*db.Database) s3Service := c.MustGet("s3Service").(*services.S3Service) - // ✅ VÉRIFIER LE RÔLE username, err := safeGetUsername(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) @@ -797,14 +764,12 @@ func UploadMedia(c *gin.Context) { return } - // ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT productID, err := strconv.Atoi(c.Param("id")) if err != nil || productID <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"}) return } - // ✅ VÉRIFIER QUE LE PRODUIT EXISTE productName, err := database.GetProductNameByID(productID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) @@ -813,7 +778,6 @@ func UploadMedia(c *gin.Context) { log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName) - // ✅ RÉCUPÉRER LE TYPE ET LE FICHIER fileType := c.PostForm("type") if fileType != "image" && fileType != "video" { c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"}) @@ -827,7 +791,6 @@ func UploadMedia(c *gin.Context) { return } - // ✅ VÉRIFIER LA TAILLE const MaxFileSize = 10 * 1024 * 1024 // 10MB if file.Size > MaxFileSize { c.JSON(http.StatusBadRequest, gin.H{ @@ -836,7 +799,6 @@ func UploadMedia(c *gin.Context) { return } - // ✅ VÉRIFIER LE TYPE MIME RÉEL detectedMime, err := validateFileMimeType(file) if err != nil { log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err) @@ -846,7 +808,6 @@ func UploadMedia(c *gin.Context) { log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime) - // Vérifier que le MIME correspond au type déclaré if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") { c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"}) return @@ -856,12 +817,10 @@ func UploadMedia(c *gin.Context) { return } - // ✅ GÉNÉRER UN NOM UNIQUE cleanProductName := cleanFileName(productName) uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename) - // ✅ UPLOAD VERS RUSTFS (remplace la sauvegarde disque locale) - folder := fileType + "s" // "images" ou "videos" + folder := fileType + "s" key, err := s3Service.UploadFileWithName(file, folder, uniqueFileName) if err != nil { log.Printf("❌ [UploadMedia] Erreur upload RustFS: %v", err) @@ -871,9 +830,6 @@ func UploadMedia(c *gin.Context) { log.Printf("✅ [UploadMedia] Fichier uploadé sur RustFS: %s", key) - // ✅ CRÉER L'ENTRÉE EN BASE - // L'URL exposée passe par notre proxy /media/:key (RustFS est derrière le VPN, - // donc inaccessible directement depuis le client/navigateur). media := models.Media{ ProductID: productID, Type: fileType, @@ -883,7 +839,6 @@ func UploadMedia(c *gin.Context) { err = database.CreateMedia(&media) if err != nil { - // ✅ Rollback: supprimer le fichier sur RustFS if delErr := s3Service.DeleteFile(key); delErr != nil { log.Printf("⚠️ [UploadMedia] Échec rollback RustFS (clé: %s): %v", key, delErr) } @@ -971,10 +926,6 @@ func DesActivePrice(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"}) } -// ============================================ -// DELETE PRODUCT - VERSION SÉCURISÉE -// ============================================ - func DeleteProduct(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -995,14 +946,12 @@ func DeleteProduct(c *gin.Context) { log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id) - // ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION mediaList, err := database.GetMediaByProductID(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"}) return } - // ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ for _, media := range mediaList { filePath := strings.TrimPrefix(media.URL, "/") @@ -1017,10 +966,8 @@ func DeleteProduct(c *gin.Context) { } } - // ✅ SUPPRIMER LES MÉDIAS DE LA DB database.DeleteMediaByProductID(id) - // ✅ SUPPRIMER LE PRODUIT err = database.DeleteProduct(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"}) @@ -1035,10 +982,6 @@ func DeleteProduct(c *gin.Context) { }) } -// ============================================ -// HELPERS -// ============================================ - func rollbackFiles(files []string) { for _, file := range files { safeFilePath, err := sanitizeFilePath(file) diff --git a/backend/gestion/models/stats.go b/backend/gestion/models/stats.go index 11207521..77bd1196 100644 --- a/backend/gestion/models/stats.go +++ b/backend/gestion/models/stats.go @@ -52,3 +52,25 @@ type DailyProductRow struct { OrderCount int `gorm:"column:order_count"` Revenue float64 `gorm:"column:revenue"` } + +type DayRowWithResult struct { + Day time.Time `gorm:"column:day"` + Count int `gorm:"column:count"` + Revenue float64 `gorm:"column:revenue"` +} +type WeekRow struct { + WeekNum int `gorm:"column:week_num"` + Year int `gorm:"column:year"` + Count int `gorm:"column:count"` + Revenue float64 `gorm:"column:revenue"` +} +type MonthRow struct { + MonthNum int `gorm:"column:month_num"` + Year int `gorm:"column:year"` + Count int `gorm:"column:count"` + Revenue float64 `gorm:"column:revenue"` +} +type TodayRow struct { + Count int `gorm:"column:count"` + Revenue float64 `gorm:"column:revenue"` +} diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index d2c6092c..79b7e799 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -145,6 +145,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // ============================================ router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink) + // ============================================ + // 📋 HEALTH CHECK + // ============================================ + router.GET("/health", handlers.Health) + // ============================================ // 📋 PATTERN v2: ADMIN API // ============================================ diff --git a/backend/gestion/services/adresses_correction.go b/backend/gestion/services/adresses_correction.go index 1d0e1ce7..c1ff1fbb 100644 --- a/backend/gestion/services/adresses_correction.go +++ b/backend/gestion/services/adresses_correction.go @@ -64,29 +64,12 @@ func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionServi } } -// ============================================ -// POINT D'ENTRÉE PRINCIPAL -// ============================================ - -// ResolveAddress tente de géocoder une adresse avec correction automatique. -// Retourne toujours une suggestion, même approximative. -// Ordre de résolution : -// 1. Géocodage exact → succès immédiat -// 2. Nominatim fuzzy search (addressdetails + limit=5) -// 3. Décomposition structurée de l'adresse -// 4. Erreur explicite avec suggestions si dispo func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) { rawAddress = strings.TrimSpace(rawAddress) if rawAddress == "" { return nil, fmt.Errorf("adresse vide") } - // ── Étape 1 : essai exact (cache Redis puis Nominatim direct) ── - // Volontairement pas d'appel à acs.geoService.GeocodeAddress ici : cette - // méthode retombe elle-même sur ResolveAddress quand le géocodage direct - // échoue, ce qui provoquerait une récursion infinie GeocodeAddress <-> - // ResolveAddress pour toute adresse nécessitant réellement une - // correction (le cas d'usage même de cette fonction). if loc, err := acs.geoService.getFromCache(rawAddress); err == nil { return &AddressSuggestion{ OriginalAddress: rawAddress, @@ -122,11 +105,6 @@ func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*Address return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress) } -// ============================================ -// ÉTAPE 2 : FUZZY SEARCH NOMINATIM -// ============================================ - -// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) { variants := buildAddressVariants(address) @@ -156,7 +134,6 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr return nil, fmt.Errorf("aucune correspondance fuzzy trouvée") } -// queryNominatim exécute une requête vers l'API Nominatim func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) { query = strings.TrimSpace(query) if query == "" { @@ -212,7 +189,6 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([] func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) { parts := parseAddressParts(address) - // Essai 1 : numéro + rue + ville (sans code postal) if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" { q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city) if s, err := acs.nominatimFuzzySearch(q); err == nil { @@ -222,7 +198,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS } } - // Essai 2 : rue + code postal uniquement if parts.streetName != "" && parts.postcode != "" { q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode) if s, err := acs.nominatimFuzzySearch(q); err == nil { @@ -232,7 +207,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS } } - // Essai 3 : ville + code postal comme zone de repli if parts.city != "" && parts.postcode != "" { q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode) suggestions, err := acs.queryNominatim(q, 3) diff --git a/backend/gestion/services/adresses_correction_test.go b/backend/gestion/services/adresses_correction_test.go deleted file mode 100644 index 43cde2fd..00000000 --- a/backend/gestion/services/adresses_correction_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package services - -import "testing" - -// Ces tests couvrent la partie pure de l'algorithme de correction d'adresse -// (normalisation, décomposition, score de confiance) — sans appel réseau à -// Nominatim (rate-limité à 1 req/s, non adapté à une suite de tests). Les -// méthodes qui interrogent Nominatim (nominatimFuzzySearch, structuredSearch, -// ResolveAddress) ne sont donc pas exercées ici. - -func TestNormalize_RemovesAccentsAndNormalizesSpacing(t *testing.T) { - cases := []struct { - name string - input string - want string - }{ - {"accent simple", "Crébillon", "Crebillon"}, - {"plusieurs accents", "Cours des 50 Otages à Nantes", "Cours des 50 Otages a Nantes"}, - {"espaces multiples", "12 Rue de Verdun", "12 Rue de Verdun"}, - {"déjà normalisé", "Rue de Verdun", "Rue de Verdun"}, - {"cédille", "Façade", "Facade"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := normalize(c.input); got != c.want { - t.Errorf("normalize(%q) = %q, want %q", c.input, got, c.want) - } - }) - } -} - -// Les abréviations ne sont reconnues qu'avec leur point final (sauf "Rte ") -// — une adresse mal écrite sans point ne sera pas développée. Ce test -// documente ce comportement réel plutôt que de le supposer. -func TestExpandFrenchAbbreviations(t *testing.T) { - cases := []struct { - name string - input string - want string - }{ - {"Av. développé", "12 Av. de la Paix", "12 Avenue de la Paix"}, - {"Bd. développé", "5 Bd. Jean Moulin", "5 Boulevard Jean Moulin"}, - {"Rte avec espace développé", "Rte de Vannes", "Route de Vannes"}, - {"Pl. développé", "3 Pl. Royale", "3 Place Royale"}, - {"Bd sans point NON développé (limite connue)", "5 Bd Jean Moulin", "5 Bd Jean Moulin"}, - {"pas d'abréviation", "12 Rue Crébillon", "12 Rue Crébillon"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := expandFrenchAbbreviations(c.input); got != c.want { - t.Errorf("expandFrenchAbbreviations(%q) = %q, want %q", c.input, got, c.want) - } - }) - } -} - -// Exemple tiré du commentaire du code source lui-même : une particule ("le") -// insérée dans un nom de rue peut faire échouer un géocodage exact. -func TestSimplifyStreetName_RemovesEmbeddedArticles(t *testing.T) { - input := "20 Rue Gabriel le Pan de Ligny" - want := "20 Rue Gabriel Pan Ligny" - if got := simplifyStreetName(input); got != want { - t.Errorf("simplifyStreetName(%q) = %q, want %q", input, got, want) - } -} - -func TestSimplifyStreetName_LeavesShortAddressesUnchanged(t *testing.T) { - // La garde ne s'applique qu'en dessous de 5 mots ("3 Rue de la Paix" en - // fait exactement 5 et serait donc simplifiée, voir le test ci-dessus). - input := "3 Rue Crébillon" - if got := simplifyStreetName(input); got != input { - t.Errorf("simplifyStreetName ne doit pas modifier une adresse de moins de 5 mots: got=%q want=%q", got, input) - } -} - -// Décomposition d'adresses de Nantes (44000), y compris des cas mal écrits : -// ville en minuscule (non détectée par l'heuristique de majuscule), code -// postal mal saisi (lettre au lieu d'un zéro). -func TestParseAddressParts_HandlesRealisticAndBadlyWrittenNantesAddresses(t *testing.T) { - cases := []struct { - name string - input string - wantNumber string - wantStreet string - wantPostcode string - wantCity string - }{ - { - name: "adresse bien formée", - input: "12 Rue Crébillon 44000 Nantes", - wantNumber: "12", - wantStreet: "Rue Crébillon", - wantPostcode: "44000", - wantCity: "Nantes", - }, - { - name: "ville en minuscule non détectée (limite connue)", - input: "3 place royale 44000 nantes", - wantNumber: "3", - wantStreet: "place royale nantes", // la ville minuscule reste fondue dans la rue - wantPostcode: "44000", - wantCity: "", - }, - { - name: "code postal mal saisi (lettre O au lieu de zéro) non reconnu", - input: "8 Rue de Verdun 44OOO Nantes", - wantNumber: "8", - wantStreet: "Rue de Verdun 44OOO", // "44OOO" n'est pas un code postal valide, reste dans la rue - wantPostcode: "", - wantCity: "Nantes", - }, - { - name: "particule intégrée au nom de rue", - input: "20 Rue Gabriel le Pan de Ligny 44000 Nantes", - wantNumber: "20", - wantStreet: "Rue Gabriel le Pan de Ligny", - wantPostcode: "44000", - wantCity: "Nantes", - }, - { - name: "sans numéro de rue", - input: "Rue Crébillon 44000 Nantes", - wantNumber: "", - wantStreet: "Rue Crébillon", - wantPostcode: "44000", - wantCity: "Nantes", - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got := parseAddressParts(c.input) - if got.streetNumber != c.wantNumber { - t.Errorf("streetNumber = %q, want %q", got.streetNumber, c.wantNumber) - } - if got.streetName != c.wantStreet { - t.Errorf("streetName = %q, want %q", got.streetName, c.wantStreet) - } - if got.postcode != c.wantPostcode { - t.Errorf("postcode = %q, want %q", got.postcode, c.wantPostcode) - } - if got.city != c.wantCity { - t.Errorf("city = %q, want %q", got.city, c.wantCity) - } - }) - } -} - -func TestIsPostcode(t *testing.T) { - cases := []struct { - input string - want bool - }{ - {"44000", true}, - {"44100", true}, - {"44OOO", false}, // lettre O au lieu de zéro — typo réaliste - {"4400", false}, // trop court - {"440000", false}, // trop long - {"", false}, - {"abcde", false}, - } - for _, c := range cases { - if got := isPostcode(c.input); got != c.want { - t.Errorf("isPostcode(%q) = %v, want %v", c.input, got, c.want) - } - } -} - -func TestIsNumeric(t *testing.T) { - cases := []struct { - input string - want bool - }{ - {"12", true}, - {"0", true}, - {"", false}, - {"12b", false}, - {"-1", false}, - } - for _, c := range cases { - if got := isNumeric(c.input); got != c.want { - t.Errorf("isNumeric(%q) = %v, want %v", c.input, got, c.want) - } - } -} - -// La distance de Levenshtein doit rester tolérante aux fautes de frappe -// courantes (lettre manquante, inversion) et normalize() doit annuler l'écart -// dû aux accents. -func TestLevenshteinRatio_TypoTolerance(t *testing.T) { - cases := []struct { - name string - a, b string - minWant float64 - }{ - {"faute de frappe simple (Nantse/Nantes)", "Nantse", "Nantes", 0.6}, - {"lettre manquante (Verdun/Verdu)", "Verdu", "Verdun", 0.7}, - {"identique", "Nantes", "Nantes", 1.0}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := levenshteinRatio(c.a, c.b); got < c.minWant { - t.Errorf("levenshteinRatio(%q, %q) = %.2f, want >= %.2f", c.a, c.b, got, c.minWant) - } - }) - } -} - -func TestLevenshteinRatio_AccentDifferenceResolvedByNormalize(t *testing.T) { - a, b := "Crebillon", "Crébillon" - if levenshteinRatio(a, b) >= 1.0 { - t.Fatalf("précondition: %q et %q ne devraient pas être identiques sans normalisation", a, b) - } - if got := levenshteinRatio(normalize(a), normalize(b)); got != 1.0 { - t.Errorf("après normalize(), les deux formes doivent être identiques: ratio=%.2f", got) - } -} - -// Le score de confiance doit favoriser nettement une suggestion proche de -// l'adresse saisie (même mal orthographiée) par rapport à une suggestion -// sans rapport. -func TestComputeConfidence_ScoresCloseMatchHigherThanUnrelated(t *testing.T) { - original := "12 Rue Crebillon 44000 Nantse" // fautes: pas d'accent + "Nantse" - closeMatch := "12 Rue Crébillon, 44000, Nantes" - unrelated := "1 Avenue des Champs-Élysées, 75008, Paris" - - closeScore := computeConfidence(original, closeMatch, 0.5) - unrelatedScore := computeConfidence(original, unrelated, 0.5) - - if closeScore <= unrelatedScore { - t.Errorf("score adresse proche (%.2f) devrait être supérieur au score adresse sans rapport (%.2f)", closeScore, unrelatedScore) - } - if closeScore < 0.40 { - t.Errorf("score adresse proche trop bas pour dépasser le seuil d'acceptation (0.40): got=%.2f", closeScore) - } -} - -// buildAddressVariants doit inclure la forme sans accent et la forme avec -// abréviation développée pour une adresse mal écrite combinant les deux. -func TestBuildAddressVariants_IncludesNormalizedAndExpandedForms(t *testing.T) { - input := "12 Av. de la Paix 44000 Nantes" // abréviation, pas d'accent ici mais le principe se généralise - variants := buildAddressVariants(input) - - if len(variants) < 2 { - t.Fatalf("attendu plusieurs variantes, got=%d: %v", len(variants), variants) - } - if variants[0] != input { - t.Errorf("la première variante doit être l'adresse originale: got=%q", variants[0]) - } - - foundExpanded := false - for _, v := range variants { - if v == "12 Avenue de la Paix 44000 Nantes" { - foundExpanded = true - } - } - if !foundExpanded { - t.Errorf("attendu une variante avec l'abréviation développée parmi: %v", variants) - } - - // Pas de doublons. - seen := map[string]bool{} - for _, v := range variants { - if seen[v] { - t.Errorf("variante en double: %q dans %v", v, variants) - } - seen[v] = true - } -} - -func TestFormatNominatimAddress_PrefersStructuredFieldsOverDisplayName(t *testing.T) { - s := NominatimSuggestion{ - DisplayName: "12, Rue Crébillon, Nantes, Loire-Atlantique, France métropolitaine, France", - } - s.Address.HouseNumber = "12" - s.Address.Road = "Rue Crébillon" - s.Address.Postcode = "44000" - s.Address.City = "Nantes" - - want := "12 Rue Crébillon, 44000, Nantes" - if got := formatNominatimAddress(s); got != want { - t.Errorf("formatNominatimAddress = %q, want %q", got, want) - } -} - -func TestFormatNominatimAddress_FallsBackToDisplayNameWhenNoStructuredFields(t *testing.T) { - s := NominatimSuggestion{DisplayName: "Quelque part en France"} - if got := formatNominatimAddress(s); got != s.DisplayName { - t.Errorf("formatNominatimAddress sans champs structurés doit renvoyer DisplayName: got=%q want=%q", got, s.DisplayName) - } -} diff --git a/backend/gestion/services/geo_services.go b/backend/gestion/services/geo_services.go index fc7e1092..170eaffa 100644 --- a/backend/gestion/services/geo_services.go +++ b/backend/gestion/services/geo_services.go @@ -211,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string { return fmt.Sprintf("geocode:cache:%s", address) } -// ============================================ -// CALCULS GÉOGRAPHIQUES -// ============================================ - // CalculateDistance calcule la distance entre deux points (formule Haversine) func CalculateDistance(from, to Coordinates) float64 { // Conversion en radians @@ -223,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 { lat2Rad := toRadians(to.Latitude) lon2Rad := toRadians(to.Longitude) - // Différences dLat := lat2Rad - lat1Rad dLon := lon2Rad - lon1Rad @@ -239,13 +234,10 @@ func CalculateDistance(from, to Coordinates) float64 { // CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback) func CalculateETA(distanceKm float64) int { - // ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance if distanceKm < 0.1 { - return MinETA // Très proche: minimum 3 minutes + return MinETA } - // Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic) - // Plus réaliste que 30 km/h travelTime := (distanceKm / 25.0) * 60.0 // Ajouter une marge pour le trafic (environ 20%) @@ -262,8 +254,6 @@ func CalculateETA(distanceKm float64) int { return totalMinutes } -// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel) -// Retourne (etaMinutes, distanceKm, error) func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) { if len(tomTomKeys.keys) == 0 { distance := CalculateDistance(from, to) diff --git a/backend/gestion/services/geo_services_test.go b/backend/gestion/services/geo_services_test.go deleted file mode 100644 index d1d45bf5..00000000 --- a/backend/gestion/services/geo_services_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package services - -import ( - "math" - "testing" -) - -// Repères réels de Nantes (44000) utilisés pour vérifier le calcul de -// distance/temps de trajet des commandes. -var ( - placeRoyale = Coordinates{Latitude: 47.2148, Longitude: -1.5584} - gareNantes = Coordinates{Latitude: 47.2173, Longitude: -1.5426} - aeroportNantes = Coordinates{Latitude: 47.1532, Longitude: -1.6107} -) - -func almostEqual(a, b, tolerance float64) bool { - return math.Abs(a-b) <= tolerance -} - -func TestCalculateDistance_SamePointIsZero(t *testing.T) { - if got := CalculateDistance(placeRoyale, placeRoyale); got != 0 { - t.Errorf("distance entre un point et lui-même: got=%.4f want=0", got) - } -} - -// Le long d'un même méridien (même longitude), la distance Haversine est -// exacte : 1° de latitude = R * (π/180) ≈ 111.19 km. -func TestCalculateDistance_OneDegreeLatitudeIsExact(t *testing.T) { - from := Coordinates{Latitude: 47.0, Longitude: -1.5536} - to := Coordinates{Latitude: 48.0, Longitude: -1.5536} - want := EarthRadiusKm * (math.Pi / 180.0) - - got := CalculateDistance(from, to) - if !almostEqual(got, want, 0.01) { - t.Errorf("distance 1° de latitude: got=%.4f want=%.4f", got, want) - } -} - -func TestCalculateDistance_IsSymmetric(t *testing.T) { - d1 := CalculateDistance(placeRoyale, gareNantes) - d2 := CalculateDistance(gareNantes, placeRoyale) - if !almostEqual(d1, d2, 0.0001) { - t.Errorf("la distance doit être symétrique: A->B=%.4f B->A=%.4f", d1, d2) - } -} - -// Place Royale <-> Aéroport de Nantes : environ 8 km à vol d'oiseau. -func TestCalculateDistance_RealNantesLandmarks(t *testing.T) { - got := CalculateDistance(placeRoyale, aeroportNantes) - if got < 6 || got > 10 { - t.Errorf("distance Place Royale -> Aéroport Nantes hors plage réaliste: got=%.2f km, want=[6,10]", got) - } -} - -func TestCalculateETA_VeryCloseReturnsMinETA(t *testing.T) { - cases := []float64{0, 0.01, 0.05, 0.099} - for _, d := range cases { - if got := CalculateETA(d); got != MinETA { - t.Errorf("CalculateETA(%.3f km): got=%d want=%d (MinETA)", d, got, MinETA) - } - } -} - -// Formule : (distance/25 km/h)*60 min, +20% de marge trafic, arrondi par troncature. -func TestCalculateETA_MatchesFormulaForNormalDistance(t *testing.T) { - distanceKm := 10.0 - travelTime := (distanceKm / 25.0) * 60.0 - want := int(travelTime * 1.2) - - got := CalculateETA(distanceKm) - if got != want { - t.Errorf("CalculateETA(%.1f km): got=%d want=%d", distanceKm, got, want) - } -} - -func TestCalculateETA_VeryFarClampsToMaxETA(t *testing.T) { - if got := CalculateETA(1000); got != MaxETA { - t.Errorf("CalculateETA(1000 km): got=%d want=%d (MaxETA)", got, MaxETA) - } -} - -// L'ETA ne doit jamais sortir de l'intervalle [MinETA, MaxETA], quelle que -// soit la distance fournie (y compris des valeurs aberrantes). -func TestCalculateETA_AlwaysWithinBounds(t *testing.T) { - distances := []float64{-5, 0, 0.05, 1, 5, 10, 50, 100, 500, 10000} - for _, d := range distances { - got := CalculateETA(d) - if got < MinETA || got > MaxETA { - t.Errorf("CalculateETA(%.2f): got=%d, hors bornes [%d,%d]", d, got, MinETA, MaxETA) - } - } -} - -// Sans clé TomTom configurée (cas de cet environnement de test), le calcul -// doit retomber sur Haversine + CalculateETA, sans appel réseau. -func TestCalculateETAWithTomTom_FallsBackToHaversineWithoutAPIKey(t *testing.T) { - if len(tomTomKeys.keys) != 0 { - t.Skip("test valable uniquement sans clé TomTom configurée dans l'environnement") - } - - wantDistance := CalculateDistance(placeRoyale, aeroportNantes) - wantETA := CalculateETA(wantDistance) - - gotETA, gotDistance, err := CalculateETAWithTomTom(placeRoyale, aeroportNantes) - if err != nil { - t.Fatalf("CalculateETAWithTomTom (fallback): %v", err) - } - if gotDistance != wantDistance { - t.Errorf("distance fallback: got=%.4f want=%.4f", gotDistance, wantDistance) - } - if gotETA != wantETA { - t.Errorf("ETA fallback: got=%d want=%d", gotETA, wantETA) - } -} - -func TestValidateCoordinates(t *testing.T) { - cases := []struct { - name string - coords Coordinates - wantErr bool - }{ - {"Nantes valide", placeRoyale, false}, - {"latitude limite haute valide", Coordinates{Latitude: 90, Longitude: 0}, false}, - {"latitude limite basse valide", Coordinates{Latitude: -90, Longitude: 0}, false}, - {"latitude trop haute", Coordinates{Latitude: 90.1, Longitude: 0}, true}, - {"latitude trop basse", Coordinates{Latitude: -90.1, Longitude: 0}, true}, - {"longitude limite haute valide", Coordinates{Latitude: 0, Longitude: 180}, false}, - {"longitude trop haute", Coordinates{Latitude: 0, Longitude: 180.1}, true}, - {"longitude trop basse", Coordinates{Latitude: 0, Longitude: -180.1}, true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - err := ValidateCoordinates(c.coords) - if c.wantErr && err == nil { - t.Error("attendu une erreur, reçu nil") - } - if !c.wantErr && err != nil { - t.Errorf("erreur inattendue: %v", err) - } - }) - } -} diff --git a/backend/gestion/services/lbtelegram.go b/backend/gestion/services/lbtelegram.go index 094d01d9..041306fb 100644 --- a/backend/gestion/services/lbtelegram.go +++ b/backend/gestion/services/lbtelegram.go @@ -42,7 +42,7 @@ func (s *LBTelegramService) IsConfigured() bool { // EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte. // LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3). func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "user_id": chatID, "username": username, "role": role, @@ -68,7 +68,7 @@ func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) erro // SendNotification envoie un message via la gateway LBTelegram. // Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn). func (s *LBTelegramService) SendNotification(userID int64, message string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "user_id": userID, "message": message, } diff --git a/backend/gestion/services/telegram.go b/backend/gestion/services/telegram.go index 6a035ab4..a7cd9a37 100644 --- a/backend/gestion/services/telegram.go +++ b/backend/gestion/services/telegram.go @@ -64,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error { return fmt.Errorf("telegram non configuré") } - payload := map[string]interface{}{ + payload := map[string]any{ "chat_id": chatID, "text": text, "parse_mode": "HTML", @@ -107,11 +107,11 @@ func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, butt row = append(row, map[string]string{"text": b[0], "url": b[1]}) } - payload := map[string]interface{}{ + payload := map[string]any{ "chat_id": chatID, "text": text, "parse_mode": "HTML", - "reply_markup": map[string]interface{}{ + "reply_markup": map[string]any{ "inline_keyboard": [][]map[string]string{row}, }, } @@ -147,7 +147,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error { return fmt.Errorf("telegram non configuré") } - payload := map[string]interface{}{ + payload := map[string]any{ "url": webhookURL, "allowed_updates": []string{"message"}, } diff --git a/backend/gestion/services/tomtom_keys.go b/backend/gestion/services/tomtom_keys.go index ce1f483f..e87081db 100644 --- a/backend/gestion/services/tomtom_keys.go +++ b/backend/gestion/services/tomtom_keys.go @@ -68,27 +68,23 @@ func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*h _, startIdx := m.currentKey() - for attempt := 0; attempt < n; attempt++ { + for attempt := range n { idx := (startIdx + attempt) % n key := m.keys[idx] - req, err := buildReq(key) if err != nil { return nil, err } - resp, err := client.Do(req) if err != nil { return nil, err } - if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { io.Copy(io.Discard, resp.Body) resp.Body.Close() m.rotate(idx) continue } - return resp, nil } diff --git a/backend/gestion/tests/address_correction_test.go b/backend/gestion/tests/address_correction_test.go new file mode 100644 index 00000000..c7746130 --- /dev/null +++ b/backend/gestion/tests/address_correction_test.go @@ -0,0 +1,125 @@ +package tests + +import ( + "gestion/models" + "testing" +) + +// db_address.go gère une table de correspondances gérées par l'admin +// (adresse_correction) : à chaque checkout, CheckAddress vérifie si l'adresse +// saisie par le client correspond à une entrée connue comme invalide, et si +// oui, substitue l'adresse correcte tout en signalant une erreur pour forcer +// une nouvelle confirmation côté client (voir ValidateBasket). + +func cleanupAddressCorrections(t *testing.T, ids ...int64) { + t.Helper() + t.Cleanup(func() { + for _, id := range ids { + testDB.GDB.Exec(`DELETE FROM adresse_correction WHERE id = ?`, id) + } + }) +} + +func TestCheckAddress_NoMatchReturnsNilAndLeavesAddressUnchanged(t *testing.T) { + cmd := &models.Command{DeliveryAddress: testUserPrefix + "adresse jamais enregistrée 44000 Nantes"} + original := cmd.DeliveryAddress + + if err := testDB.CheckAddress(cmd); err != nil { + t.Fatalf("CheckAddress sans correspondance ne doit jamais échouer: %v", err) + } + if cmd.DeliveryAddress != original { + t.Errorf("adresse ne doit pas être modifiée sans correspondance: got=%q want=%q", cmd.DeliveryAddress, original) + } +} + +func TestCheckAddress_MatchSubstitutesCorrectAddressAndReturnsError(t *testing.T) { + invalid := testUserPrefix + "12 Rue Crebillon Nantes" + correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes" + if err := testDB.AddAddress(correct, invalid); err != nil { + t.Fatalf("AddAddress: %v", err) + } + var id int64 + testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id) + cleanupAddressCorrections(t, id) + + cmd := &models.Command{DeliveryAddress: invalid} + err := testDB.CheckAddress(cmd) + if err == nil { + t.Fatal("attendu une erreur signalant la correction (pour forcer une re-confirmation client)") + } + if cmd.DeliveryAddress != correct { + t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct) + } +} + +func TestAddAddress_ThenAllAddressIncludesIt(t *testing.T) { + invalid := testUserPrefix + "adresse invalide test" + correct := testUserPrefix + "adresse correcte test" + if err := testDB.AddAddress(correct, invalid); err != nil { + t.Fatalf("AddAddress: %v", err) + } + var id int64 + testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id) + cleanupAddressCorrections(t, id) + + all, err := testDB.AllAddress() + if err != nil { + t.Fatalf("AllAddress: %v", err) + } + found := false + for _, a := range all { + if a.InvalidAddress == invalid && a.CorrectAddress == correct { + found = true + break + } + } + if !found { + t.Errorf("la correspondance ajoutée n'apparaît pas dans AllAddress") + } +} + +// DeleteAddress doit cibler la correspondance exacte, sans affecter une autre +// correspondance non liée. Note : invalid_address a une contrainte UNIQUE en +// base (adresse_correction_invalid_address_key), donc deux corrections ne +// peuvent jamais partager la même adresse invalide — le risque réel est +// seulement qu'un DELETE mal ciblé touche une correspondance différente. +func TestDeleteAddress_RemovesOnlyTargetedPairNotUnrelatedOne(t *testing.T) { + invalidA := testUserPrefix + "adresse A" + correctA := testUserPrefix + "correction A" + invalidB := testUserPrefix + "adresse B" + correctB := testUserPrefix + "correction B" + if err := testDB.AddAddress(correctA, invalidA); err != nil { + t.Fatalf("AddAddress A: %v", err) + } + if err := testDB.AddAddress(correctB, invalidB); err != nil { + t.Fatalf("AddAddress B: %v", err) + } + var idA, idB int64 + testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidA).Scan(&idA) + testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidB).Scan(&idB) + cleanupAddressCorrections(t, idA, idB) + + if err := testDB.DeleteAddress(invalidA, correctA); err != nil { + t.Fatalf("DeleteAddress: %v", err) + } + + all, err := testDB.AllAddress() + if err != nil { + t.Fatalf("AllAddress: %v", err) + } + var stillHasA, stillHasB bool + for _, a := range all { + if a.InvalidAddress == invalidA && a.CorrectAddress == correctA { + stillHasA = true + } + if a.InvalidAddress == invalidB && a.CorrectAddress == correctB { + stillHasB = true + } + } + if stillHasA { + t.Error("la correspondance ciblée (A) doit être supprimée") + } + if !stillHasB { + t.Error("l'autre correspondance (B), non ciblée, ne doit pas être supprimée") + } +} diff --git a/backend/gestion/tests/alert_test.go b/backend/gestion/tests/alert_test.go new file mode 100644 index 00000000..db5467b8 --- /dev/null +++ b/backend/gestion/tests/alert_test.go @@ -0,0 +1,273 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "gestion/handlers" + + "github.com/gin-gonic/gin" +) + +func alertContext(username, role string, body []byte, alertID int) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/livreur/alert", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + if username != "" { + c.Set("username", username) + } + c.Set("role", role) + if alertID != 0 { + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", alertID)}} + } + return c, rec +} + +func createTestAlert(t *testing.T, username, message string) int { + t.Helper() + alert, err := testDB.CreateAlert(username, message) + if err != nil { + t.Fatalf("CreateAlert: %v", err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE id = ?`, alert.ID) + }) + return alert.ID +} + +// ── AlertPolice ────────────────────────────────────────────────────────────── + +func TestAlertPolice_LivreurCreatesAlert(t *testing.T) { + livreur := testUserPrefix + "alert_create_livreur" + body, _ := json.Marshal(map[string]string{"message": "Contrôle en cours"}) + c, rec := alertContext(livreur, "livreur", body, 0) + handlers.AlertPolice(c) + t.Cleanup(func() { testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE username = ?`, livreur) }) + + if rec.Code != http.StatusCreated { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + AlertID int `json:"alert_id"` + User string `json:"user"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.User != livreur { + t.Errorf("user: got=%q want=%q", resp.User, livreur) + } + + alert, err := testDB.GetAlertPolicy(resp.AlertID) + if err != nil { + t.Fatalf("GetAlertPolicy: %v", err) + } + if alert.Message != "Contrôle en cours" || alert.Status != "true" { + t.Errorf("alerte créée: message=%q status=%q", alert.Message, alert.Status) + } +} + +func TestAlertPolice_NonLivreurForbidden(t *testing.T) { + for _, role := range []string{"client", "admin", "cabine"} { + t.Run(role, func(t *testing.T) { + body, _ := json.Marshal(map[string]string{"message": "test"}) + c, rec := alertContext(testUserPrefix+"alert_forbidden_"+role, role, body, 0) + handlers.AlertPolice(c) + if rec.Code != http.StatusForbidden { + t.Errorf("le rôle %q ne doit pas pouvoir déclencher une alerte police: got=%d", role, rec.Code) + } + }) + } +} + +// ── GetAlert ───────────────────────────────────────────────────────────────── + +func TestGetAlert_LivreurCanViewOwnAlert(t *testing.T) { + livreur := testUserPrefix + "alert_view_own" + alertID := createTestAlert(t, livreur, "test") + + c, rec := alertContext(livreur, "livreur", nil, alertID) + handlers.GetAlert(c) + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetAlert_LivreurCannotViewOthersAlert(t *testing.T) { + owner := testUserPrefix + "alert_view_owner" + intruder := testUserPrefix + "alert_view_intruder" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(intruder, "livreur", nil, alertID) + handlers.GetAlert(c) + if rec.Code != http.StatusForbidden { + t.Fatalf("un livreur ne doit pas pouvoir consulter l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetAlert_AdminCanViewAnyAlert(t *testing.T) { + owner := testUserPrefix + "alert_view_admin_owner" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(testUserPrefix+"alert_view_admin", "admin", nil, alertID) + handlers.GetAlert(c) + if rec.Code != http.StatusOK { + t.Fatalf("un admin doit pouvoir consulter n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +// ── EndAlert ───────────────────────────────────────────────────────────────── + +func TestEndAlert_OwnerCanEnd(t *testing.T) { + livreur := testUserPrefix + "alert_end_owner" + alertID := createTestAlert(t, livreur, "test") + + c, rec := alertContext(livreur, "livreur", nil, alertID) + handlers.EndAlert(c) + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + alert, _ := testDB.GetAlertPolicy(alertID) + if alert.Status != "false" { + t.Errorf("statut après EndAlert: got=%q want=false", alert.Status) + } +} + +func TestEndAlert_NonOwnerLivreurRejected(t *testing.T) { + owner := testUserPrefix + "alert_end_owner2" + intruder := testUserPrefix + "alert_end_intruder" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(intruder, "livreur", nil, alertID) + handlers.EndAlert(c) + if rec.Code != http.StatusForbidden { + t.Fatalf("un livreur tiers ne doit pas pouvoir terminer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String()) + } + + alert, _ := testDB.GetAlertPolicy(alertID) + if alert.Status != "true" { + t.Errorf("l'alerte ne doit pas être terminée par un intrus: got=%q want=true", alert.Status) + } +} + +// ── DeleteAlert ────────────────────────────────────────────────────────────── +// +// Corrigé : un livreur ne peut supprimer que ses propres alertes (comme +// EndAlert) ; un admin garde l'accès complet sans restriction de propriétaire. + +func TestDeleteAlert_OwnerLivreurCanDeleteOwnAlert(t *testing.T) { + owner := testUserPrefix + "alert_delete_owner_ok" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(owner, "livreur", nil, alertID) + handlers.DeleteAlert(c) + + if rec.Code != http.StatusOK { + t.Fatalf("le propriétaire doit pouvoir supprimer sa propre alerte: got=%d body=%s", rec.Code, rec.Body.String()) + } + if _, err := testDB.GetAlertPolicy(alertID); err == nil { + t.Error("l'alerte doit être supprimée") + } +} + +func TestDeleteAlert_NonOwnerLivreurRejected(t *testing.T) { + owner := testUserPrefix + "alert_delete_owner" + intruder := testUserPrefix + "alert_delete_intruder" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(intruder, "livreur", nil, alertID) + handlers.DeleteAlert(c) + + if rec.Code != http.StatusForbidden { + t.Fatalf("un livreur tiers ne doit pas pouvoir supprimer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String()) + } + if _, err := testDB.GetAlertPolicy(alertID); err != nil { + t.Error("l'alerte ne doit pas être supprimée par un intrus") + } +} + +func TestDeleteAlert_AdminCanDeleteAnyAlertRegardlessOfOwner(t *testing.T) { + owner := testUserPrefix + "alert_delete_admin_owner" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(testUserPrefix+"alert_delete_admin", "admin", nil, alertID) + handlers.DeleteAlert(c) + + if rec.Code != http.StatusOK { + t.Fatalf("un admin doit pouvoir supprimer n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String()) + } + if _, err := testDB.GetAlertPolicy(alertID); err == nil { + t.Error("l'alerte doit être supprimée par l'admin") + } +} + +func TestDeleteAlert_NonLivreurNonAdminForbidden(t *testing.T) { + owner := testUserPrefix + "alert_delete_forbidden_owner" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(testUserPrefix+"alert_delete_forbidden_cabine", "cabine", nil, alertID) + handlers.DeleteAlert(c) + if rec.Code != http.StatusForbidden { + t.Errorf("le rôle cabine ne doit pas pouvoir supprimer une alerte: got=%d", rec.Code) + } +} + +// ── Listing ────────────────────────────────────────────────────────────────── + +func TestGetMyAlerts_ReturnsOnlyOwnAlerts(t *testing.T) { + mine := testUserPrefix + "alert_mine" + other := testUserPrefix + "alert_other" + createTestAlert(t, mine, "à moi 1") + createTestAlert(t, mine, "à moi 2") + createTestAlert(t, other, "pas à moi") + + c, rec := alertContext(mine, "livreur", nil, 0) + handlers.GetMyAlerts(c) + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Count int `json:"count"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Count != 2 { + t.Errorf("nombre d'alertes du livreur: got=%d want=2", resp.Count) + } +} + +func TestGetActiveAlerts_ExcludesEndedAlerts(t *testing.T) { + livreur := testUserPrefix + "alert_active_filter" + activeID := createTestAlert(t, livreur, "active") + endedID := createTestAlert(t, livreur, "terminée") + if err := testDB.EndAlert(endedID); err != nil { + t.Fatalf("EndAlert (setup): %v", err) + } + + alerts, err := testDB.GetActiveAlerts() + if err != nil { + t.Fatalf("GetActiveAlerts: %v", err) + } + var foundActive, foundEnded bool + for _, a := range alerts { + if a.ID == activeID { + foundActive = true + } + if a.ID == endedID { + foundEnded = true + } + } + if !foundActive { + t.Error("l'alerte active doit apparaître dans GetActiveAlerts") + } + if foundEnded { + t.Error("l'alerte terminée ne doit pas apparaître dans GetActiveAlerts") + } +} diff --git a/backend/gestion/tests/commands_address_proposal_test.go b/backend/gestion/tests/commands_address_proposal_test.go new file mode 100644 index 00000000..72c9e72b --- /dev/null +++ b/backend/gestion/tests/commands_address_proposal_test.go @@ -0,0 +1,210 @@ +package tests + +import "testing" + +// commandAddressState lit adresse/proposed_address/address_proposal_status +// directement pour vérifier le flux propose -> respond. +type commandAddressState struct { + Adresse string `gorm:"column:adresse"` + ProposedAddress string `gorm:"column:proposed_address"` + AddressProposalStatus string `gorm:"column:address_proposal_status"` +} + +func getCommandAddressState(t *testing.T, commandID int) commandAddressState { + t.Helper() + var s commandAddressState + if err := testDB.GDB.Raw( + `SELECT adresse, COALESCE(proposed_address, '') as proposed_address, + COALESCE(address_proposal_status, '') as address_proposal_status + FROM commandes WHERE id = ?`, commandID, + ).Scan(&s).Error; err != nil { + t.Fatalf("getCommandAddressState: %v", err) + } + return s +} + +// ── UpdateCommandAddress (modification directe admin) ─────────────────────── + +func TestUpdateCommandAddress_UpdatesAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "upd_addr_ok") + productID := newTestProduct(t, "UpdAddrOk", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + if err := testDB.UpdateCommandAddress(cmdID, "42 Nouvelle Adresse, 44000 Nantes"); err != nil { + t.Fatalf("UpdateCommandAddress: %v", err) + } + if got := getCommandAddressState(t, cmdID).Adresse; got != "42 Nouvelle Adresse, 44000 Nantes" { + t.Errorf("adresse après mise à jour: got=%q", got) + } +} + +func TestUpdateCommandAddress_RejectsEmptyAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "upd_addr_empty") + productID := newTestProduct(t, "UpdAddrEmpty", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + before := getCommandAddressState(t, cmdID).Adresse + + if err := testDB.UpdateCommandAddress(cmdID, " "); err == nil { + t.Fatal("attendu un rejet pour une adresse vide/blanche") + } + if got := getCommandAddressState(t, cmdID).Adresse; got != before { + t.Errorf("adresse ne doit pas changer sur un rejet: got=%q want=%q", got, before) + } +} + +func TestUpdateCommandAddress_RejectsUnknownCommand(t *testing.T) { + if err := testDB.UpdateCommandAddress(999999999, "1 rue inexistante"); err == nil { + t.Fatal("attendu une erreur pour une commande inexistante") + } +} + +// Note : db.UpdateCommandAddress lui-même n'interdit pas de modifier l'adresse +// d'une commande terminée — cette règle ("livre/approved/cancelled interdits") +// est uniquement appliquée par le handler HTTP (UpdateCommandAddress dans +// handlers/commands.go), pas par la fonction DB. Ce test documente ce fait +// explicitement pour qu'un futur appelant direct de la fonction DB (ex. un +// script, un worker) ne suppose pas à tort que la protection est là. +func TestUpdateCommandAddress_DBFunctionAloneDoesNotBlockTerminalStatuses(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "upd_addr_terminal") + productID := newTestProduct(t, "UpdAddrTerminal", 10) + cmdID := newTestCommandWithItem(t, username, "approved", "", productID, 1, 10) + + if err := testDB.UpdateCommandAddress(cmdID, "Adresse modifiée après coup"); err != nil { + t.Fatalf("la fonction DB seule n'impose pas la restriction de statut (attendu, voir commentaire): %v", err) + } + if got := getCommandAddressState(t, cmdID).Adresse; got != "Adresse modifiée après coup" { + t.Errorf("adresse: got=%q", got) + } +} + +// ── ProposeAddressChange / RespondToAddressProposal ───────────────────────── + +func TestProposeAddressChange_SetsProposedAddressAndPendingStatus(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "propose_addr_ok") + productID := newTestProduct(t, "ProposeAddrOk", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + + if err := testDB.ProposeAddressChange(cmdID, "Nouvelle adresse proposée, 44000 Nantes", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + + s := getCommandAddressState(t, cmdID) + if s.ProposedAddress != "Nouvelle adresse proposée, 44000 Nantes" { + t.Errorf("proposed_address: got=%q", s.ProposedAddress) + } + if s.AddressProposalStatus != "pending" { + t.Errorf("address_proposal_status: got=%q want=pending", s.AddressProposalStatus) + } +} + +func TestRespondToAddressProposal_AcceptedAppliesProposedAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "respond_addr_accept") + productID := newTestProduct(t, "RespondAddrAccept", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée acceptée", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + + if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil { + t.Fatalf("RespondToAddressProposal (accepté): %v", err) + } + + s := getCommandAddressState(t, cmdID) + if s.Adresse != "Adresse proposée acceptée" { + t.Errorf("adresse de livraison après acceptation: got=%q want=%q", s.Adresse, "Adresse proposée acceptée") + } + if s.ProposedAddress != "" { + t.Errorf("proposed_address doit être vidé après réponse: got=%q", s.ProposedAddress) + } + if s.AddressProposalStatus != "accepted" { + t.Errorf("address_proposal_status: got=%q want=accepted", s.AddressProposalStatus) + } +} + +func TestRespondToAddressProposal_RejectedKeepsOriginalAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "respond_addr_reject") + productID := newTestProduct(t, "RespondAddrReject", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + original := getCommandAddressState(t, cmdID).Adresse + + if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée refusée", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + if err := testDB.RespondToAddressProposal(cmdID, username, false); err != nil { + t.Fatalf("RespondToAddressProposal (refusé): %v", err) + } + + s := getCommandAddressState(t, cmdID) + if s.Adresse != original { + t.Errorf("l'adresse de livraison ne doit pas changer sur un refus: got=%q want=%q", s.Adresse, original) + } + if s.ProposedAddress != "" { + t.Errorf("proposed_address doit être vidé même en cas de refus: got=%q", s.ProposedAddress) + } + if s.AddressProposalStatus != "rejected" { + t.Errorf("address_proposal_status: got=%q want=rejected", s.AddressProposalStatus) + } +} + +func TestRespondToAddressProposal_FailsWhenNoProposalPending(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "respond_addr_none") + productID := newTestProduct(t, "RespondAddrNone", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + + if err := testDB.RespondToAddressProposal(cmdID, username, true); err == nil { + t.Fatal("attendu une erreur : aucune proposition en attente") + } +} + +// La proposition est liée au client propriétaire de la commande : un autre +// client ne doit pas pouvoir y répondre à sa place. +func TestRespondToAddressProposal_WrongClientCannotRespond(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "respond_addr_owner") + intruder := newTestClient(t, "respond_addr_intruder") + productID := newTestProduct(t, "RespondAddrIntruder", 10) + cmdID := newTestCommandWithItem(t, owner, "assigned", "", productID, 1, 10) + if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + + if err := testDB.RespondToAddressProposal(cmdID, intruder, true); err == nil { + t.Fatal("un client tiers ne doit pas pouvoir répondre à la proposition d'un autre client") + } + + s := getCommandAddressState(t, cmdID) + if s.AddressProposalStatus != "pending" { + t.Errorf("la proposition doit rester en attente après une tentative d'un intrus: got=%q want=pending", s.AddressProposalStatus) + } +} + +// Rejeu (double-tap) : une fois traitée, la même proposition ne doit pas +// pouvoir être acceptée/refusée une seconde fois. +func TestRespondToAddressProposal_DoubleRespondFails(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "respond_addr_double") + productID := newTestProduct(t, "RespondAddrDouble", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + + if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil { + t.Fatalf("1ère réponse: %v", err) + } + if err := testDB.RespondToAddressProposal(cmdID, username, false); err == nil { + t.Fatal("une 2e réponse sur une proposition déjà traitée doit échouer") + } + + // La 2e tentative (rejet) ne doit pas être appliquée par-dessus la 1ère (acceptation). + if got := getCommandAddressState(t, cmdID).AddressProposalStatus; got != "accepted" { + t.Errorf("le statut doit rester celui de la 1ère réponse: got=%q want=accepted", got) + } +} diff --git a/backend/gestion/tests/deleviry_gps_test.go b/backend/gestion/tests/deleviry_gps_test.go new file mode 100644 index 00000000..ebee46a0 --- /dev/null +++ b/backend/gestion/tests/deleviry_gps_test.go @@ -0,0 +1,188 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "testing" + + "gestion/handlers" + + "github.com/gin-gonic/gin" +) + +// UpdateDeliveryStatus (handlers/deleviry.go) refuse de valider une livraison +// (statut "livre") si le livreur se trouve à plus de 350m de la destination +// (contrôle anti-fraude — seuil relevé de 100m à 350m à la demande explicite, +// pour tolérer l'imprécision GPS réelle en zone urbaine/immeuble). + +const earthRadiusMeters = 6371000.0 + +// destinationPointNorthOf renvoie un point situé à "meters" au nord de +// (lat, lon) — même longitude, donc distance ≈ purement le delta de latitude +// (formule identique à utils.CalculateDistance pour ce cas particulier). +func destinationPointNorthOf(lat, lon, meters float64) (float64, float64) { + latOffsetRad := meters / earthRadiusMeters + latOffsetDeg := latOffsetRad * (180 / math.Pi) + return lat + latOffsetDeg, lon +} + +func setCommandDestination(t *testing.T, commandID int, lat, lon float64) { + t.Helper() + if err := testDB.GDB.Exec( + `UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`, + lat, lon, commandID, + ).Error; err != nil { + t.Fatalf("setCommandDestination: %v", err) + } +} + +// deliveryStatusContextJSON est la variante de deliveryStatusContext (voir +// penalty_test.go) qui accepte un corps JSON arbitraire — nécessaire ici pour +// pouvoir passer latitude/longitude, que l'helper existant ne supporte pas. +func deliveryStatusContextJSON(username string, commandID int, body []byte) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/livreur/deliveries/%d/status", commandID), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}} + c.Set("database", testDB) + c.Set("username", username) + c.Set("role", "livreur") + return c, rec +} + +const nantesLat, nantesLon = 47.2184, -1.5536 + +func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_within") + client := newTestClient(t, "gps_client_within") + productID := newTestProduct(t, "GPSWithin", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200) // 200m < 350m + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon}) + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "livre" { + t.Errorf("statut après validation à 200m: got=%s want=livre", got) + } +} + +func TestUpdateDeliveryStatus_GPS_BeyondThresholdRejectsValidation(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_beyond") + client := newTestClient(t, "gps_client_beyond") + productID := newTestProduct(t, "GPSBeyond", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 400) // 400m > 350m + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon}) + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "en_route" { + t.Errorf("le statut ne doit pas passer à 'livre' au-delà de 350m: got=%s want=en_route", got) + } +} + +// Preuve directe du changement demandé : une distance de 150m, qui aurait +// échoué sous l'ancien seuil de 100m, doit maintenant réussir sous 350m. +func TestUpdateDeliveryStatus_GPS_150Meters_PassesUnderNewThreshold(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_150m") + client := newTestClient(t, "gps_client_150m") + productID := newTestProduct(t, "GPS150m", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 150) + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon}) + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusOK { + t.Fatalf("150m doit être accepté sous le nouveau seuil de 350m: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "livre" { + t.Errorf("statut après validation à 150m: got=%s want=livre", got) + } +} + +func TestUpdateDeliveryStatus_GPS_MissingCoordinatesRejected(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_missing_coords") + client := newTestClient(t, "gps_client_missing_coords") + productID := newTestProduct(t, "GPSMissingCoords", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + body, _ := json.Marshal(map[string]any{"status": "livre"}) // latitude/longitude absents (zéro) + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status HTTP sans coordonnées: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "en_route" { + t.Errorf("le statut ne doit pas changer sans coordonnées GPS: got=%s want=en_route", got) + } +} + +// Si la commande n'a pas de coordonnées de destination enregistrées (adresse +// non géocodée), la validation GPS est ignorée plutôt que de bloquer le +// livreur indéfiniment. +func TestUpdateDeliveryStatus_GPS_MissingDestinationCoordinatesSkipsValidation(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_no_dest") + client := newTestClient(t, "gps_client_no_dest") + productID := newTestProduct(t, "GPSNoDest", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + // Pas d'appel à setCommandDestination : dest_latitude/dest_longitude restent à 0/NULL. + + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": 48.8566, "longitude": 2.3522}) // Paris, sans rapport + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusOK { + t.Fatalf("sans destination enregistrée, la validation GPS doit être ignorée: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "livre" { + t.Errorf("statut: got=%s want=livre", got) + } +} + +func TestUpdateDeliveryStatus_RejectsWhenNotAssignedToThisLivreur(t *testing.T) { + cleanupStockTestData(t) + assignedLivreur := newTestClient(t, "gps_assigned_livreur") + intruder := newTestClient(t, "gps_intruder_livreur") + client := newTestClient(t, "gps_client_wrong_livreur") + productID := newTestProduct(t, "GPSWrongLivreur", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", assignedLivreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": nantesLat, "longitude": nantesLon}) + c, rec := deliveryStatusContextJSON(intruder, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusForbidden { + t.Fatalf("un livreur non assigné doit être rejeté: got=%d want=%d body=%s", rec.Code, http.StatusForbidden, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "en_route" { + t.Errorf("le statut ne doit pas changer: got=%s want=en_route", got) + } +} diff --git a/backend/gestion/tests/delivery_mode_test.go b/backend/gestion/tests/delivery_mode_test.go new file mode 100644 index 00000000..5e51c076 --- /dev/null +++ b/backend/gestion/tests/delivery_mode_test.go @@ -0,0 +1,261 @@ +package tests + +import ( + "encoding/json" + "gestion/db" + "gestion/models" + "testing" + "time" +) + +// newTestProductWithCategory crée un produit de test avec une catégorie +// personnalisée (contrairement à newTestProduct qui pose toujours "test") — +// nécessaire ici pour distinguer les catégories dans le routage par livreur. +func newTestProductWithCategory(t *testing.T, name, category string, stock float64) int { + t.Helper() + fullName := testProductPrefix + name + var id int + if err := testDB.GDB.Raw( + `INSERT INTO products (name, category, description, stock) VALUES (?, ?, '', ?) RETURNING id`, + fullName, category, stock, + ).Scan(&id).Error; err != nil { + t.Fatalf("création produit test %q: %v", fullName, err) + } + if err := testDB.GDB.Exec( + `INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`, + id, + ).Error; err != nil { + t.Fatalf("création prix produit test %q: %v", fullName, err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id) + testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id) + }) + return id +} + +// setLivreurStatus place directement en Redis le statut d'un livreur, comme +// le ferait l'app livreur en production (clé "delivery:status:{username}"). +func setLivreurStatus(t *testing.T, username, status string) { + t.Helper() + data, err := json.Marshal(models.DeliveryPersonStatus{ + Username: username, + Status: status, + LastUpdate: time.Now(), + }) + if err != nil { + t.Fatalf("marshal DeliveryPersonStatus: %v", err) + } + key := "delivery:status:" + username + if err := db.Redis.Set(db.RedisCtx, key, data, 0).Err(); err != nil { + t.Fatalf("setLivreurStatus: %v", err) + } + t.Cleanup(func() { + db.Redis.Del(db.RedisCtx, key) + }) +} + +func setDeliveryModeSettings(t *testing.T, mode models.DeliveryModeConfig) { + t.Helper() + data, err := json.Marshal(mode) + if err != nil { + t.Fatalf("marshal delivery_mode: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO app_settings (key, value) VALUES ('delivery_mode', ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + string(data), + ).Error; err != nil { + t.Fatalf("setDeliveryModeSettings: %v", err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'delivery_mode'`) + }) +} + +func containsUsername(list []string, username string) bool { + for _, u := range list { + if u == username { + return true + } + } + return false +} + +// ── GetCommandCategories ───────────────────────────────────────────────────── + +func TestGetCommandCategories_ReturnsDistinctProductCategories(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_categories") + productA := newTestProductWithCategory(t, "CatA", "cat_a", 10) + productB := newTestProductWithCategory(t, "CatB", "cat_b", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10) + testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`, + cmdID, productB, + ) + + categories, err := testDB.GetCommandCategories(cmdID) + if err != nil { + t.Fatalf("GetCommandCategories: %v", err) + } + if len(categories) != 2 || !containsUsername(categories, "cat_a") || !containsUsername(categories, "cat_b") { + t.Errorf("catégories: got=%v want=[cat_a cat_b]", categories) + } +} + +// ── GetEligibleDeliverymenForCommand ───────────────────────────────────────── + +func TestGetEligibleDeliverymenForCommand_SingleModeReturnsAllActive(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_single") + productID := newTestProductWithCategory(t, "Single", "cat_a", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + livreurA := testUserPrefix + "delivmode_single_a" + livreurB := testUserPrefix + "delivmode_single_b" + setLivreurStatus(t, livreurA, "available") + setLivreurStatus(t, livreurB, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"}) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) { + t.Errorf("mode single doit renvoyer tous les livreurs actifs: got=%v", eligible) + } +} + +func TestGetEligibleDeliverymenForCommand_CategoryBasedFiltersToMatchingRoute(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_filter") + productID := newTestProductWithCategory(t, "Filter", "cat_a", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + livreurA := testUserPrefix + "delivmode_filter_a" + livreurB := testUserPrefix + "delivmode_filter_b" + setLivreurStatus(t, livreurA, "available") + setLivreurStatus(t, livreurB, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{ + Mode: "category_based", + CategoryRoutes: []models.CategoryRoute{ + {DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, + {DeliverymanUsername: livreurB, Categories: []string{"cat_b"}}, + }, + }) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) { + t.Errorf("livreurA (cat_a) doit être éligible: got=%v", eligible) + } + if containsUsername(eligible, livreurB) { + t.Errorf("livreurB (cat_b, non commandée) ne doit pas être éligible: got=%v", eligible) + } +} + +// Cas limite documenté explicitement dans le modèle métier : une commande +// mixte (catégories relevant de livreurs différents) doit renvoyer l'UNION +// des livreurs éligibles, pas une intersection (aucun livreur unique ne gère +// forcément toutes les catégories à la fois). +func TestGetEligibleDeliverymenForCommand_MixedCategoryCommand_ReturnsUnion(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_mixed") + productA := newTestProductWithCategory(t, "MixedA", "cat_a", 10) + productB := newTestProductWithCategory(t, "MixedB", "cat_b", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10) + testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`, + cmdID, productB, + ) + + livreurA := testUserPrefix + "delivmode_mixed_a" + livreurB := testUserPrefix + "delivmode_mixed_b" + setLivreurStatus(t, livreurA, "available") + setLivreurStatus(t, livreurB, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{ + Mode: "category_based", + CategoryRoutes: []models.CategoryRoute{ + {DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, + {DeliverymanUsername: livreurB, Categories: []string{"cat_b"}}, + }, + }) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) { + t.Errorf("commande mixte cat_a+cat_b doit renvoyer l'union des deux livreurs: got=%v", eligible) + } +} + +func TestGetEligibleDeliverymenForCommand_NoRouteMatchesFallsBackToAllActive(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_nomatch") + productID := newTestProductWithCategory(t, "NoMatch", "cat_c", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + livreurA := testUserPrefix + "delivmode_nomatch_a" + setLivreurStatus(t, livreurA, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{ + Mode: "category_based", + CategoryRoutes: []models.CategoryRoute{ + {DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, // ne couvre pas cat_c + }, + }) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) { + t.Errorf("aucune route ne couvre cat_c -> repli sur tous les livreurs actifs: got=%v", eligible) + } +} + +func TestGetEligibleDeliverymenForCommand_EmptyCategoryRoutesFallsBackToAllActive(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_emptyroutes") + productID := newTestProductWithCategory(t, "EmptyRoutes", "cat_a", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + livreurA := testUserPrefix + "delivmode_emptyroutes_a" + setLivreurStatus(t, livreurA, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "category_based", CategoryRoutes: []models.CategoryRoute{}}) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) { + t.Errorf("category_based sans route configurée -> repli sur tous les livreurs actifs: got=%v", eligible) + } +} + +func TestGetEligibleDeliverymenForCommand_OfflineLivreurNeverEligible(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_offline") + productID := newTestProductWithCategory(t, "Offline", "cat_a", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + onlineLivreur := testUserPrefix + "delivmode_offline_online" + offlineLivreur := testUserPrefix + "delivmode_offline_offline" + setLivreurStatus(t, onlineLivreur, "available") + setLivreurStatus(t, offlineLivreur, "offline") + setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"}) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if containsUsername(eligible, offlineLivreur) { + t.Errorf("un livreur offline ne doit jamais être éligible: got=%v", eligible) + } + if !containsUsername(eligible, onlineLivreur) { + t.Errorf("le livreur en ligne doit être éligible: got=%v", eligible) + } +} diff --git a/backend/gestion/tests/parrain_test.go b/backend/gestion/tests/parrain_test.go new file mode 100644 index 00000000..6a5dae5f --- /dev/null +++ b/backend/gestion/tests/parrain_test.go @@ -0,0 +1,160 @@ +package tests + +import ( + "sync" + "testing" +) + +// SetClientParrainAndCredit lie un parrain à un client ET crédite le parrain +// dans une seule transaction — la doc métier avertit explicitement que sans +// cette atomicité, un crédit peut être appliqué sans lien enregistré, ou +// l'inverse (lien enregistré sans jamais créditer le parrain). + +func TestSetClientParrainAndCredit_LinksAndCreditsAtomically(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_ok_client") + parrain := newTestClient(t, "parrain_ok_parrain") + setClientReferralBalance(t, parrain, 5) + + if err := testDB.SetClientParrainAndCredit(client, parrain, 10); err != nil { + t.Fatalf("SetClientParrainAndCredit: %v", err) + } + + got, err := testDB.GetClientParrain(client) + if err != nil { + t.Fatalf("GetClientParrain: %v", err) + } + if got != parrain { + t.Errorf("parrain enregistré: got=%q want=%q", got, parrain) + } + if bal := referralBalance(t, parrain); bal != 15 { + t.Errorf("solde du parrain après crédit (5 + 10): got=%.2f want=15", bal) + } +} + +// Un client qui a déjà un parrain ne doit jamais pouvoir en changer via cette +// fonction (contrainte "parrain déjà défini") — et surtout, le nouveau +// parrain proposé ne doit recevoir aucun crédit si le lien est refusé. +func TestSetClientParrainAndCredit_RejectsIfClientAlreadyHasParrain(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_already_client") + firstParrain := newTestClient(t, "parrain_already_first") + secondParrain := newTestClient(t, "parrain_already_second") + + if err := testDB.SetClientParrainAndCredit(client, firstParrain, 10); err != nil { + t.Fatalf("1er lien: %v", err) + } + + if err := testDB.SetClientParrainAndCredit(client, secondParrain, 10); err == nil { + t.Fatal("attendu un rejet : le client a déjà un parrain") + } + + if got, _ := testDB.GetClientParrain(client); got != firstParrain { + t.Errorf("le parrain enregistré ne doit pas changer: got=%q want=%q", got, firstParrain) + } + if bal := referralBalance(t, secondParrain); bal != 0 { + t.Errorf("le second parrain (lien refusé) ne doit recevoir aucun crédit: got=%.2f want=0", bal) + } + if bal := referralBalance(t, firstParrain); bal != 10 { + t.Errorf("le premier parrain garde son crédit initial, pas de second crédit: got=%.2f want=10", bal) + } +} + +// Scénario exact mis en garde par la doc métier : si le parrain indiqué +// n'existe pas, le crédit échoue — et le lien parrain (première moitié de la +// transaction) doit être annulé avec, pas laissé enregistré tout seul. +func TestSetClientParrainAndCredit_RejectsUnknownParrain_RollsBackLink(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_unknown_client") + unknownParrain := testUserPrefix + "does_not_exist_parrain" + + if err := testDB.SetClientParrainAndCredit(client, unknownParrain, 10); err == nil { + t.Fatal("attendu une erreur : le parrain n'existe pas") + } + + got, err := testDB.GetClientParrain(client) + if err != nil { + t.Fatalf("GetClientParrain: %v", err) + } + if got != "" { + t.Errorf("le lien parrain ne doit PAS être enregistré si le crédit échoue (rollback complet): got=%q want=\"\"", got) + } +} + +// Quand le parrainage est désactivé (ReferralEnabled=false côté handler), le +// montant crédité vaut 0 — la liaison doit tout de même réussir sans tenter +// de créditer personne. +func TestSetClientParrainAndCredit_ZeroCreditLinksWithoutCrediting(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_zero_client") + parrain := newTestClient(t, "parrain_zero_parrain") + + if err := testDB.SetClientParrainAndCredit(client, parrain, 0); err != nil { + t.Fatalf("SetClientParrainAndCredit avec montant nul: %v", err) + } + + if got, _ := testDB.GetClientParrain(client); got != parrain { + t.Errorf("le lien doit être enregistré même sans crédit: got=%q want=%q", got, parrain) + } + if bal := referralBalance(t, parrain); bal != 0 { + t.Errorf("aucun crédit ne doit être appliqué avec un montant nul: got=%.2f want=0", bal) + } +} + +func TestSetClientParrainAndCredit_RejectsUnknownClient(t *testing.T) { + cleanupStockTestData(t) + parrain := newTestClient(t, "parrain_unknown_client_target") + unknownClient := testUserPrefix + "does_not_exist_client" + + if err := testDB.SetClientParrainAndCredit(unknownClient, parrain, 10); err == nil { + t.Fatal("attendu une erreur : le client cible n'existe pas") + } + if bal := referralBalance(t, parrain); bal != 0 { + t.Errorf("le parrain ne doit pas être crédité si le client cible est introuvable: got=%.2f want=0", bal) + } +} + +// Deux tentatives concurrentes de parrainage sur le MÊME client (deux +// parrains différents) ne doivent en laisser passer qu'une seule — la +// condition "parrain IS NULL OR parrain = ''" de l'UPDATE sérialise +// naturellement les deux tentatives au niveau de la ligne. +func TestSetClientParrainAndCredit_ConcurrentSetOnSameClientOnlyOneSucceeds(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_concurrent_client") + parrainA := newTestClient(t, "parrain_concurrent_a") + parrainB := newTestClient(t, "parrain_concurrent_b") + + var wg sync.WaitGroup + errs := make([]error, 2) + wg.Add(2) + go func() { + defer wg.Done() + errs[0] = testDB.SetClientParrainAndCredit(client, parrainA, 10) + }() + go func() { + defer wg.Done() + errs[1] = testDB.SetClientParrainAndCredit(client, parrainB, 10) + }() + wg.Wait() + + successCount := 0 + for _, err := range errs { + if err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("une seule tentative concurrente de parrainage doit réussir: got=%d succès", successCount) + } + + finalParrain, _ := testDB.GetClientParrain(client) + if finalParrain != parrainA && finalParrain != parrainB { + t.Fatalf("parrain final inattendu: %q", finalParrain) + } + + balA := referralBalance(t, parrainA) + balB := referralBalance(t, parrainB) + if (balA == 10) == (balB == 10) { + t.Errorf("exactement un des deux parrains doit être crédité de 10, pas les deux ni aucun: balA=%.2f balB=%.2f", balA, balB) + } +} diff --git a/backend/gestion/tests/points_approval_staff_test.go b/backend/gestion/tests/points_approval_staff_test.go new file mode 100644 index 00000000..5fc107d7 --- /dev/null +++ b/backend/gestion/tests/points_approval_staff_test.go @@ -0,0 +1,256 @@ +package tests + +import ( + "sync" + "testing" + + "gestion/models" +) + +// ── ApproveDeliveryAtomicByStaff : confirmation de réception par admin/cabine +// à la place du client. Contrairement au chemin client (ApproveDeliveryAtomic), +// aucune vérification de propriétaire n'est faite ici (le staff agit au nom +// du client) — mais la contrainte de statut ('livre' uniquement) est +// identique, et la double-approbation renvoie une VRAIE erreur (pas un no-op +// silencieux comme côté client). + +func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "staff_approve_ok") + productID := newTestProduct(t, "StaffApproveOk", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + pts, _, clientOut, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test") + if err != nil { + t.Fatalf("ApproveDeliveryAtomicByStaff: %v", err) + } + if pts != 6 { + t.Errorf("points retournés: got=%d want=6", pts) + } + if clientOut != username { + t.Errorf("client retourné: got=%q want=%q", clientOut, username) + } + if got := commandStatus(t, cmdID); got != "approved" { + t.Errorf("statut après confirmation staff: got=%s want=approved", got) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points_extra après confirmation staff: got=%d want=6", got) + } +} + +func TestApproveDeliveryAtomicByStaff_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) { + cleanupStockTestData(t) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + + for _, status := range []string{"pending", "assigned", "en_route", "arrived"} { + t.Run(status, func(t *testing.T) { + username := newTestClient(t, "staff_reject_"+status) + productID := newTestProduct(t, "StaffReject"+status, 20) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err == nil { + t.Fatalf("attendu un rejet pour une commande en statut %q", status) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 0 { + t.Errorf("aucun point ne doit être crédité (statut=%s): got=%d want=0", status, got) + } + if got := commandStatus(t, cmdID); got != status { + t.Errorf("le statut ne doit pas changer: got=%s want=%s", got, status) + } + }) + } +} + +// Contrairement à ApproveDeliveryAtomic (client), une seconde confirmation +// staff sur une commande déjà approuvée renvoie une VRAIE erreur, pas un +// no-op silencieux — divergence de comportement à documenter explicitement. +func TestApproveDeliveryAtomicByStaff_DoubleApprove_ReturnsErrorAndDoesNotDoublePoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "staff_double") + productID := newTestProduct(t, "StaffDouble", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err != nil { + t.Fatalf("1ère confirmation: %v", err) + } + if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err == nil { + t.Fatal("la 2e confirmation sur une commande déjà approuvée doit renvoyer une erreur (contrairement au chemin client)") + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après double confirmation staff: got=%d want=6 (un seul crédit)", got) + } +} + +func TestApproveDeliveryAtomicByStaff_ConcurrentApprove_CreditsPointsOnlyOnce(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "staff_concurrent") + productID := newTestProduct(t, "StaffConcurrent", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + var wg sync.WaitGroup + n := 3 + errs := make([]error, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, _, _, errs[idx] = testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test") + }(i) + } + wg.Wait() + + successCount := 0 + for _, err := range errs { + if err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("une seule confirmation concurrente doit réussir: got=%d succès", successCount) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après confirmations concurrentes: got=%d want=6 (un seul crédit)", got) + } +} + +// Le staff n'est pas le client : aucune vérification de propriétaire n'est +// faite (comportement voulu, à la différence du chemin client). +func TestApproveDeliveryAtomicByStaff_NoOwnershipCheck_AnyStaffCanConfirmAnyClient(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "staff_no_owner_check") + productID := newTestProduct(t, "StaffNoOwnerCheck", 20) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + if _, _, clientOut, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "un_autre_membre_staff"); err != nil { + t.Fatalf("un membre du staff quelconque doit pouvoir confirmer la réception: %v", err) + } else if clientOut != username { + t.Errorf("client retourné: got=%q want=%q", clientOut, username) + } +} + +// ── ValidateDeliveryAtomic : validation admin en masse ────────────────────── +// +// Divergence de règle métier volontaire (confirmée) : contrairement aux deux +// autres chemins d'approbation (client et staff), qui exigent tous deux le +// statut 'livre', ValidateDeliveryAtomic accepte "pending", "assigned", +// "en_route" ET "livre" — c'est un override admin assumé pour régulariser une +// commande gérée hors flux normal, pas un bug. Les tests suivants documentent +// ce comportement réel pour qu'une future régression involontaire soit détectée. + +func TestValidateDeliveryAtomic_AcceptsAllDocumentedStatusesAndCreditsPoints(t *testing.T) { + cleanupStockTestData(t) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + + for _, status := range []string{"pending", "assigned", "en_route", "livre"} { + t.Run(status, func(t *testing.T) { + username := newTestClient(t, "validate_status_"+status) + productID := newTestProduct(t, "ValidateStatus"+status, 20) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + pts, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test") + if err != nil { + t.Fatalf("ValidateDeliveryAtomic depuis le statut %q: %v", status, err) + } + if pts != 6 { + t.Errorf("points depuis statut %q: got=%d want=6", status, pts) + } + if got := commandStatus(t, cmdID); got != "approved" { + t.Errorf("statut final: got=%s want=approved", got) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points_extra depuis statut %q: got=%d want=6", status, got) + } + }) + } +} + +func TestValidateDeliveryAtomic_RejectsStatusOutsideAllowedList(t *testing.T) { + cleanupStockTestData(t) + for _, status := range []string{"cancelled", "pending_payment", "arrived"} { + t.Run(status, func(t *testing.T) { + username := newTestClient(t, "validate_invalid_"+status) + productID := newTestProduct(t, "ValidateInvalid"+status, 20) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err == nil { + t.Fatalf("statut %q ne fait pas partie de la liste autorisée, attendu un rejet", status) + } + if got := commandStatus(t, cmdID); got != status { + t.Errorf("le statut ne doit pas changer: got=%s want=%s", got, status) + } + }) + } +} + +// Ici aussi la double-validation renvoie une vraie erreur ("commande déjà +// approuvée"), pas un no-op silencieux. +func TestValidateDeliveryAtomic_DoubleValidate_ReturnsErrorAndDoesNotDoublePoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "validate_double") + productID := newTestProduct(t, "ValidateDouble", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err != nil { + t.Fatalf("1ère validation: %v", err) + } + if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err == nil { + t.Fatal("la 2e validation sur une commande déjà approuvée doit renvoyer une erreur") + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après double validation: got=%d want=6 (un seul crédit)", got) + } +} + +func TestValidateDeliveryAtomic_ConcurrentValidate_CreditsPointsOnlyOnce(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "validate_concurrent") + productID := newTestProduct(t, "ValidateConcurrent", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + var wg sync.WaitGroup + n := 3 + errs := make([]error, n) + ptsResults := make([]int, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ptsResults[idx], errs[idx] = testDB.ValidateDeliveryAtomic(cmdID, "admin_test") + }(i) + } + wg.Wait() + + successCount := 0 + for i := range n { + if errs[i] == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("une seule validation concurrente doit réussir: got=%d succès", successCount) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après validations concurrentes: got=%d want=6 (un seul crédit)", got) + } +} diff --git a/backend/gestion/tests/points_approval_test.go b/backend/gestion/tests/points_approval_test.go new file mode 100644 index 00000000..27d6dc14 --- /dev/null +++ b/backend/gestion/tests/points_approval_test.go @@ -0,0 +1,404 @@ +package tests + +import ( + "encoding/json" + "gestion/models" + "sync" + "testing" + + "gorm.io/gorm" +) + +// setPointsPoolsSettings remplace la configuration des pools de points pour la +// durée du test (table app_settings, clé "points_pools"), et restaure l'état +// par défaut en fin de test. +func setPointsPoolsSettings(t *testing.T, pools []models.PointsPool) { + t.Helper() + data, err := json.Marshal(pools) + if err != nil { + t.Fatalf("marshal pools: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO app_settings (key, value) VALUES ('points_pools', ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + string(data), + ).Error; err != nil { + t.Fatalf("setPointsPoolsSettings: %v", err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_pools'`) + }) +} + +// setPointsRewardSettings configure le seuil de récompense globale (déduction +// de points lors de la consommation d'un article récompense). +func setPointsRewardSettings(t *testing.T, reward models.PointsReward) { + t.Helper() + data, err := json.Marshal(reward) + if err != nil { + t.Fatalf("marshal points_reward: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO app_settings (key, value) VALUES ('points_reward', ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + string(data), + ).Error; err != nil { + t.Fatalf("setPointsRewardSettings: %v", err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_reward'`) + }) +} + +// insertRewardCommandItem ajoute directement un item récompense à une +// commande déjà créée (contourne le checkout, pour isoler le calcul de points). +func insertRewardCommandItem(t *testing.T, commandID, productID int, quantite, prix float64, poolKey string) { + t.Helper() + if err := testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status, is_reward, reward_pool_key) + VALUES (?, ?, 'item reward test', ?, ?, 'pending', true, ?)`, + commandID, productID, quantite, prix, poolKey, + ).Error; err != nil { + t.Fatalf("insertRewardCommandItem: %v", err) + } +} + +func clientPointsExtra(t *testing.T, username string) map[string]int { + t.Helper() + extra, _, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + return extra +} + +// ── CalculateAndAddPointsForCommandTx : logique bas niveau ────────────────── + +func TestCalculateAndAddPointsForCommandTx_CreditsPointsPerPoolFromTiers(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_tiers_ok") + productID := newTestProduct(t, "PointsTiersOk", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{ + {Min: 30, Max: 50, Points: 1}, + {Min: 60, Max: 0, Points: 5}, + }}, + }) + // Total commande = 60€ -> palier "60 et plus" = 5 points. + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60) + + err := testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 5 { + t.Errorf("points calculés: got=%d want=5", pts) + } + if cat != "Pool Test" { + t.Errorf("catégorie: got=%q want=%q", cat, "Pool Test") + } + return nil + }) + if err != nil { + t.Fatalf("transaction: %v", err) + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 5 { + t.Errorf("points_extra[pool_0] après crédit: got=%d want=5", got) + } +} + +func TestCalculateAndAddPointsForCommandTx_NoPoolsConfigured_ReturnsZero(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_no_pools") + productID := newTestProduct(t, "PointsNoPools", 20) + setPointsPoolsSettings(t, []models.PointsPool{}) // aucun pool configuré + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60) + + testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 0 || cat != "" { + t.Errorf("sans pool configuré: got pts=%d cat=%q, want 0/\"\"", pts, cat) + } + return nil + }) + + if got := clientPointsExtra(t, username); len(got) != 0 { + t.Errorf("points_extra ne doit pas bouger sans pool configuré: got=%v", got) + } +} + +// Un pool existe mais aucune de ses catégories ne correspond à la catégorie +// des produits commandés ("test", posée par newTestProduct) -> 0 point. +func TestCalculateAndAddPointsForCommandTx_CategoryNotInAnyPool_ReturnsZero(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_cat_mismatch") + productID := newTestProduct(t, "PointsCatMismatch", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Autre Catégorie", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{ + {Min: 30, Max: 0, Points: 5}, + }}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60) + + testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 0 { + t.Errorf("catégorie hors pool: got pts=%d want=0", pts) + } + return nil + }) + + if got := clientPointsExtra(t, username); len(got) != 0 { + t.Errorf("points_extra ne doit pas bouger si aucune catégorie ne matche: got=%v", got) + } +} + +// Deux pools indépendants : seul celui dont la catégorie correspond aux +// produits de la commande doit recevoir des points. +func TestCalculateAndAddPointsForCommandTx_MultiplePoolsIndependent(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_multi_pool") + productID := newTestProduct(t, "PointsMultiPool", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Match", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 7}}}, + {Key: "pool_1", Name: "Pool No Match", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{{Min: 30, Max: 0, Points: 99}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 2, 20) + + testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 7 { + t.Errorf("total points (seul pool_0 doit contribuer): got=%d want=7", pts) + } + return nil + }) + + extra := clientPointsExtra(t, username) + if extra["pool_0"] != 7 { + t.Errorf("pool_0: got=%d want=7", extra["pool_0"]) + } + if extra["pool_1"] != 0 { + t.Errorf("pool_1 ne doit recevoir aucun point (catégorie non matchée): got=%d want=0", extra["pool_1"]) + } +} + +// Un article récompense présent dans la commande déduit "threshold" points du +// pool correspondant, en plus des points gagnés par les articles payants de +// la même commande. +func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_reward_deduct") + paidProductID := newTestProduct(t, "PointsRewardDeductPaid", 20) + rewardProductID := newTestProduct(t, "PointsRewardDeductFree", 5) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 3}}}, + }) + setPointsRewardSettings(t, models.PointsReward{Threshold: 20, Type: "free_product"}) + setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande + + cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10) + insertRewardCommandItem(t, cmdID, rewardProductID, 1, 0, "pool_0") + + testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 3 { + t.Errorf("points gagnés sur l'article payant: got=%d want=3", pts) + } + return nil + }) + + // 25 (initial) + 3 (gagnés) - 20 (seuil déduit pour la récompense consommée) = 8. + if got := clientPointsExtra(t, username)["pool_0"]; got != 8 { + t.Errorf("points_extra[pool_0] après crédit + déduction récompense: got=%d want=8", got) + } +} + +// Propriété documentée du design : cette fonction bas niveau n'a aucune garde +// d'idempotence intégrée — appeler deux fois pour la même commande double les +// points. C'est le rôle de l'appelant (ApproveDeliveryAtomic, via son +// verrou de transition de statut livre->approved) d'empêcher un second appel. +func TestCalculateAndAddPointsForCommandTx_CalledTwice_DoublesPoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_called_twice") + productID := newTestProduct(t, "PointsCalledTwice", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 4}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + for i := 0; i < 2; i++ { + testDB.GDB.Transaction(func(tx *gorm.DB) error { + _, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("appel %d: %v", i+1, err) + } + return nil + }) + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 8 { + t.Errorf("deux appels bruts doublent les points (4+4): got=%d want=8 — ceci documente pourquoi ApproveDeliveryAtomic doit rester le seul appelant", got) + } +} + +// ── ApproveDeliveryAtomic : la vraie règle métier "points uniquement à +// l'approbation, jamais avant" ──────────────────────────────────────────── + +func TestApproveDeliveryAtomic_CreditsPointsExactlyOnApproval(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "approve_credits_points") + productID := newTestProduct(t, "ApproveCreditsPoints", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + pts, _, err := testDB.ApproveDeliveryAtomic(cmdID, username) + if err != nil { + t.Fatalf("ApproveDeliveryAtomic: %v", err) + } + if pts != 6 { + t.Errorf("points retournés par l'approbation: got=%d want=6", pts) + } + if got := commandStatus(t, cmdID); got != "approved" { + t.Errorf("statut après approbation: got=%s want=approved", got) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points_extra après approbation: got=%d want=6", got) + } +} + +// La règle centrale : tant que la commande n'est pas "livre", l'approbation +// doit être rejetée et AUCUN point ne doit être crédité. +func TestApproveDeliveryAtomic_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) { + cleanupStockTestData(t) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + + for _, status := range []string{"pending", "assigned", "en_route", "arrived"} { + t.Run(status, func(t *testing.T) { + username := newTestClient(t, "approve_reject_"+status) + productID := newTestProduct(t, "ApproveReject"+status, 20) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err == nil { + t.Fatalf("attendu un rejet pour une commande en statut %q", status) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 0 { + t.Errorf("aucun point ne doit être crédité pour une commande non 'livre' (statut=%s): got=%d want=0", status, got) + } + if got := commandStatus(t, cmdID); got != status { + t.Errorf("le statut ne doit pas changer sur une approbation rejetée: got=%s want=%s", got, status) + } + }) + } +} + +// Double approbation (retry réseau / double-tap client) : la seconde doit +// être un no-op silencieux, jamais un second crédit de points. +func TestApproveDeliveryAtomic_DoubleApprove_DoesNotDoublePoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "approve_double") + productID := newTestProduct(t, "ApproveDouble", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err != nil { + t.Fatalf("1ère approbation: %v", err) + } + pts2, _, err := testDB.ApproveDeliveryAtomic(cmdID, username) + if err != nil { + t.Fatalf("2e approbation (doit être idempotente, pas une erreur): %v", err) + } + if pts2 != 0 { + t.Errorf("2e approbation ne doit rapporter aucun point: got=%d want=0", pts2) + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après double approbation (doivent rester crédités une seule fois): got=%d want=6", got) + } +} + +func TestApproveDeliveryAtomic_ConcurrentApprove_CreditsPointsOnlyOnce(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "approve_concurrent") + productID := newTestProduct(t, "ApproveConcurrent", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + var wg sync.WaitGroup + n := 3 + errs := make([]error, n) + ptsResults := make([]int, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ptsResults[idx], _, errs[idx] = testDB.ApproveDeliveryAtomic(cmdID, username) + }(i) + } + wg.Wait() + + // ApproveDeliveryAtomic traite une commande déjà approuvée comme un no-op + // idempotent (err=nil, pts=0), pas comme une erreur — donc le critère de + // "vrai succès" est pts>0 (crédit réellement appliqué), pas err==nil. + freshCreditCount := 0 + for i := range n { + if errs[i] != nil { + t.Errorf("appel %d: erreur inattendue: %v", i, errs[i]) + continue + } + if ptsResults[i] > 0 { + freshCreditCount++ + } + } + if freshCreditCount != 1 { + t.Errorf("une seule approbation concurrente doit réellement créditer des points: got=%d", freshCreditCount) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après approbations concurrentes: got=%d want=6 (un seul crédit)", got) + } +} + +func TestApproveDeliveryAtomic_WrongOwnerRejected(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "approve_owner") + intruder := newTestClient(t, "approve_intruder") + productID := newTestProduct(t, "ApproveWrongOwner", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, owner, "livre", "", productID, 1, 10) + + if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, intruder); err == nil { + t.Fatal("un client ne doit pas pouvoir approuver la commande d'un autre client") + } + if got := clientPointsExtra(t, intruder)["pool_0"]; got != 0 { + t.Errorf("l'intrus ne doit recevoir aucun point: got=%d want=0", got) + } + if got := clientPointsExtra(t, owner)["pool_0"]; got != 0 { + t.Errorf("le propriétaire ne doit pas non plus recevoir de point tant que ce n'est pas lui qui approuve: got=%d want=0", got) + } + if got := commandStatus(t, cmdID); got != "livre" { + t.Errorf("statut ne doit pas changer sur une tentative d'un intrus: got=%s want=livre", got) + } +} diff --git a/backend/gestion/tests/referral_test.go b/backend/gestion/tests/referral_test.go new file mode 100644 index 00000000..a40cd7af --- /dev/null +++ b/backend/gestion/tests/referral_test.go @@ -0,0 +1,254 @@ +package tests + +import ( + "sync" + "testing" +) + +// setClientReferralBalance fixe directement le solde de crédit parrainage +// d'un client de test (contourne le flux normal de parrainage/checkout pour +// tester isolément le débit/crédit). +func setClientReferralBalance(t *testing.T, username string, amount float64) { + t.Helper() + if err := testDB.GDB.Exec( + `UPDATE clients SET referral_balance = ? WHERE username = ?`, amount, username, + ).Error; err != nil { + t.Fatalf("setClientReferralBalance: %v", err) + } +} + +func referralBalance(t *testing.T, username string) float64 { + t.Helper() + balance, err := testDB.GetClientReferralBalance(username) + if err != nil { + t.Fatalf("GetClientReferralBalance: %v", err) + } + return balance +} + +// ── DebitReferralBalance ───────────────────────────────────────────────────── + +func TestDebitReferralBalance_SucceedsWithSufficientBalance(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_debit_ok") + setClientReferralBalance(t, username, 50) + + if err := testDB.DebitReferralBalance(username, 30); err != nil { + t.Fatalf("DebitReferralBalance: %v", err) + } + + if got := referralBalance(t, username); got != 20 { + t.Errorf("solde après débit (50 - 30): got=%.2f want=20", got) + } +} + +func TestDebitReferralBalance_FailsWithInsufficientBalance(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_debit_insuff") + setClientReferralBalance(t, username, 10) + + if err := testDB.DebitReferralBalance(username, 30); err == nil { + t.Fatal("attendu une erreur (solde 10€ insuffisant pour débiter 30€)") + } + + if got := referralBalance(t, username); got != 10 { + t.Errorf("solde ne doit pas bouger si le débit échoue: got=%.2f want=10", got) + } +} + +// Un montant nul ou négatif est un no-op silencieux (cas "pas de crédit +// parrainage utilisé" au checkout) — ne doit jamais faire échouer ni modifier +// le solde. +func TestDebitReferralBalance_ZeroOrNegativeAmountIsNoop(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_debit_zero") + setClientReferralBalance(t, username, 15) + + if err := testDB.DebitReferralBalance(username, 0); err != nil { + t.Errorf("montant nul ne doit jamais échouer: %v", err) + } + if err := testDB.DebitReferralBalance(username, -5); err != nil { + t.Errorf("montant négatif ne doit jamais échouer: %v", err) + } + if got := referralBalance(t, username); got != 15 { + t.Errorf("solde ne doit pas bouger sur un débit nul/négatif: got=%.2f want=15", got) + } +} + +// Trois débits concurrents pour un solde qui ne permet qu'un seul d'entre eux +// ne doivent en laisser passer qu'un seul (verrou FOR UPDATE) — même classe de +// bug que le double-submit checkout, appliquée au solde de parrainage. +func TestDebitReferralBalance_ConcurrentDebitsDoNotOverspend(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_debit_concurrent") + setClientReferralBalance(t, username, 30) + + var wg sync.WaitGroup + n := 3 + errs := make([]error, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + errs[idx] = testDB.DebitReferralBalance(username, 30) + }(i) + } + wg.Wait() + + successCount := 0 + for _, err := range errs { + if err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("un seul débit concurrent de 30€ sur un solde de 30€ doit réussir: got=%d succès", successCount) + } + if got := referralBalance(t, username); got != 0 { + t.Errorf("solde final après débits concurrents: got=%.2f want=0 (un seul débit appliqué)", got) + } +} + +// ── CreditClientReferral ───────────────────────────────────────────────────── + +func TestCreditClientReferral_AddsAmount(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_credit_ok") + setClientReferralBalance(t, username, 10) + + if err := testDB.CreditClientReferral(username, 25); err != nil { + t.Fatalf("CreditClientReferral: %v", err) + } + if got := referralBalance(t, username); got != 35 { + t.Errorf("solde après crédit (10 + 25): got=%.2f want=35", got) + } +} + +func TestCreditClientReferral_RejectsNonPositiveAmount(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_credit_negative") + setClientReferralBalance(t, username, 10) + + if err := testDB.CreditClientReferral(username, 0); err == nil { + t.Error("un crédit de montant nul doit être rejeté") + } + if err := testDB.CreditClientReferral(username, -5); err == nil { + t.Error("un crédit de montant négatif doit être rejeté") + } + if got := referralBalance(t, username); got != 10 { + t.Errorf("solde ne doit pas bouger sur un crédit rejeté: got=%.2f want=10", got) + } +} + +func TestCreditClientReferral_FailsForUnknownClient(t *testing.T) { + if err := testDB.CreditClientReferral(testUserPrefix+"does_not_exist", 10); err == nil { + t.Fatal("attendu une erreur pour un client inexistant") + } +} + +// ── ResetClientReferralBalance ─────────────────────────────────────────────── + +func TestResetClientReferralBalance_SetsToZero(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_reset") + setClientReferralBalance(t, username, 42) + + if err := testDB.ResetClientReferralBalance(username); err != nil { + t.Fatalf("ResetClientReferralBalance: %v", err) + } + if got := referralBalance(t, username); got != 0 { + t.Errorf("solde après reset: got=%.2f want=0", got) + } +} + +// ── Séquence complète débit -> checkout, reproduisant exactement le flux du +// handler ValidateBasket (handlers/panier.go) : débit AVANT la création de la +// commande, puis re-crédit compensatoire si CreateCommandWithAddress échoue. +// Sans ce re-crédit, un client perdrait sèchement son crédit de parrainage +// sur un checkout qui a pourtant échoué (violation explicite de la doc métier). + +func TestReferral_DebitThenCheckoutFailure_RecreditsBalance(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_checkout_fail") + productID := newTestProduct(t, "ReferralCheckoutFail", 1) + setClientReferralBalance(t, username, 20) + + insertNormalBasketRow(t, username, productID, 5, 50) // 5 demandés pour 1 en stock -> échec garanti + + referralUsed := 20.0 + if err := testDB.DebitReferralBalance(username, referralUsed); err != nil { + t.Fatalf("DebitReferralBalance: %v", err) + } + if got := referralBalance(t, username); got != 0 { + t.Fatalf("précondition: solde débité: got=%.2f want=0", got) + } + + _, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err == nil { + t.Fatal("attendu un échec de checkout (stock insuffisant)") + } + // Reproduction exacte de la compensation faite par le handler en cas d'échec. + if err := testDB.CreditClientReferral(username, referralUsed); err != nil { + t.Fatalf("CreditClientReferral (compensation): %v", err) + } + + if got := referralBalance(t, username); got != 20 { + t.Errorf("le crédit parrainage doit être intégralement restauré après échec du checkout: got=%.2f want=20", got) + } + if got := productStock(t, productID); got != 1 { + t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=1", got) + } +} + +func TestReferral_DebitThenCheckoutSuccess_BalanceStaysDebited(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_checkout_ok") + productID := newTestProduct(t, "ReferralCheckoutOk", 10) + setClientReferralBalance(t, username, 20) + + insertNormalBasketRow(t, username, productID, 3, 30) + + if err := testDB.DebitReferralBalance(username, 20); err != nil { + t.Fatalf("DebitReferralBalance: %v", err) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + if got := referralBalance(t, username); got != 0 { + t.Errorf("le crédit parrainage reste débité après un checkout réussi: got=%.2f want=0", got) + } + if got := productStock(t, productID); got != 7 { + t.Errorf("stock après checkout réussi: got=%.2f want=7", got) + } +} + +// Combine les deux règles demandées explicitement : réclamation de +// récompense (même produit en reward + en achat normal) ET utilisation du +// crédit de parrainage sur la même commande. +func TestReferral_CombinedWithSameProductRewardAndNormal_AllInvariantsHold(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_combined_reward") + productID := newTestProduct(t, "ReferralCombinedReward", 20) + setClientReferralBalance(t, username, 15) + + insertRewardBasketRow(t, username, productID, 1) + insertNormalBasketRow(t, username, productID, 4, 40) + + if err := testDB.DebitReferralBalance(username, 15); err != nil { + t.Fatalf("DebitReferralBalance: %v", err) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + // 20 initial - 1 (reward) - 4 (normal) = 15. + if got := productStock(t, productID); got != 15 { + t.Errorf("stock après checkout combiné (reward+normal même produit + parrainage): got=%.2f want=15", got) + } + if got := referralBalance(t, username); got != 0 { + t.Errorf("crédit parrainage débité et non restauré après succès: got=%.2f want=0", got) + } +} diff --git a/backend/gestion/tests/stats_reset_test.go b/backend/gestion/tests/stats_reset_test.go new file mode 100644 index 00000000..c38fdef4 --- /dev/null +++ b/backend/gestion/tests/stats_reset_test.go @@ -0,0 +1,104 @@ +package tests + +import ( + "testing" + "time" +) + +// ResetAdminStat déplace le point de coupure temporel utilisé par toutes les +// requêtes de stats (TotalOrders, TotalRevenue, ActiveDaysLast30, ...) : les +// commandes créées AVANT le reset doivent disparaître des totaux, celles +// créées APRÈS doivent rester visibles. C'est le mécanisme central derrière +// le bouton "reset stats" de l'admin — jamais testé jusqu'ici. + +const statsResetTestKey = "stats_reset_commandes_at" + +func cleanupStatsResetKey(t *testing.T, key string) { + t.Helper() + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = ?`, key) + }) +} + +func TestResetAdminStat_TotalOrdersExcludesOrdersBeforeReset(t *testing.T) { + cleanupStockTestData(t) + cleanupStatsResetKey(t, statsResetTestKey) + username := newTestClient(t, "stat_reset_orders") + productID := newTestProduct(t, "StatResetOrders", 10) + + newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + time.Sleep(1100 * time.Millisecond) // RFC3339 stocké sans fraction de seconde : marge nécessaire + + if err := testDB.ResetAdminStat(statsResetTestKey); err != nil { + t.Fatalf("ResetAdminStat: %v", err) + } + time.Sleep(1100 * time.Millisecond) + + newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + totalWithoutFilter, err := testDB.TotalOrders(time.Time{}) + if err != nil { + t.Fatalf("TotalOrders(zero): %v", err) + } + if totalWithoutFilter != 2 { + t.Fatalf("précondition: 2 commandes doivent exister sans filtre: got=%d", totalWithoutFilter) + } + + resetAt := testDB.ReadResetAt(statsResetTestKey) + if resetAt.IsZero() { + t.Fatal("ReadResetAt ne doit pas être zero après ResetAdminStat") + } + + totalAfterReset, err := testDB.TotalOrders(resetAt) + if err != nil { + t.Fatalf("TotalOrders(resetAt): %v", err) + } + if totalAfterReset != 1 { + t.Errorf("après reset, seule la commande créée après doit être comptée: got=%d want=1", totalAfterReset) + } +} + +// Chaque section de stats a sa propre clé de reset (commandes, revenus, +// produits, heures, jours, doses) — réinitialiser l'une ne doit jamais +// affecter les autres. +func TestResetAdminStat_DifferentSectionsAreIndependent(t *testing.T) { + cleanupStatsResetKey(t, "stats_reset_commandes_at_test_indep") + cleanupStatsResetKey(t, "stats_reset_revenus_at_test_indep") + + if err := testDB.ResetAdminStat("stats_reset_commandes_at_test_indep"); err != nil { + t.Fatalf("ResetAdminStat (commandes): %v", err) + } + + if got := testDB.ReadResetAt("stats_reset_revenus_at_test_indep"); !got.IsZero() { + t.Errorf("réinitialiser la section commandes ne doit pas créer de reset pour revenus: got=%v", got) + } + if got := testDB.ReadResetAt("stats_reset_commandes_at_test_indep"); got.IsZero() { + t.Error("la section commandes doit bien avoir une date de reset") + } +} + +// ActiveDaysLast30 (stat secondaire) doit respecter le même filtre de reset +// que TotalOrders : un jour dont l'unique commande a été passée avant le +// reset ne doit plus compter comme jour actif. +func TestActiveDaysLast30_RespectsResetFilter(t *testing.T) { + cleanupStockTestData(t) + cleanupStatsResetKey(t, statsResetTestKey) + username := newTestClient(t, "stat_reset_active_days") + productID := newTestProduct(t, "StatResetActiveDays", 10) + + newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + time.Sleep(1100 * time.Millisecond) + + if err := testDB.ResetAdminStat(statsResetTestKey); err != nil { + t.Fatalf("ResetAdminStat: %v", err) + } + resetAt := testDB.ReadResetAt(statsResetTestKey) + + activeDays, err := testDB.ActiveDaysLast30(resetAt) + if err != nil { + t.Fatalf("ActiveDaysLast30: %v", err) + } + if activeDays != 0 { + t.Errorf("aucun jour actif ne doit être compté (seule commande antérieure au reset): got=%d want=0", activeDays) + } +} diff --git a/backend/gestion/tests/stock_reward_same_product_test.go b/backend/gestion/tests/stock_reward_same_product_test.go new file mode 100644 index 00000000..91996908 --- /dev/null +++ b/backend/gestion/tests/stock_reward_same_product_test.go @@ -0,0 +1,137 @@ +package tests + +import "testing" + +// Scénario métier précis : un client réclame une récompense sur un produit, +// puis ajoute EN PLUS ce même produit à son panier en achat normal (quantité +// différente). AddToBasket filtre ses recherches d'item existant sur +// "is_reward = false" (voir db_basket.go), donc les deux lignes ne sont +// jamais fusionnées : elles restent deux lignes distinctes en base +// (product_id identique, is_reward différent). Le checkout doit décrémenter +// le stock de la SOMME des deux quantités, pas juste de l'une des deux. + +func insertNormalBasketRow(t *testing.T, username string, productID int, quantity, price float64) { + t.Helper() + if err := testDB.GDB.Exec( + `INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) + VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)`, + username, productID, quantity, price, + ).Error; err != nil { + t.Fatalf("insertion panier normal: %v", err) + } +} + +func insertRewardBasketRow(t *testing.T, username string, productID int, quantity float64) { + t.Helper() + if err := testDB.GDB.Exec( + `INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at) + VALUES (?, ?, ?, 0, true, 'pool_0', CURRENT_TIMESTAMP)`, + username, productID, quantity, + ).Error; err != nil { + t.Fatalf("insertion panier récompense: %v", err) + } +} + +func TestCheckout_SameProductRewardAndNormalBothDecrementSeparately(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_same_product") + productID := newTestProduct(t, "RewardSameProduct", 20) + + // 1 unité offerte (récompense) + 5 unités achetées normalement, même produit. + insertRewardBasketRow(t, username, productID, 1) + insertNormalBasketRow(t, username, productID, 5, 50) + + var basketCount int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&basketCount) + if basketCount != 2 { + t.Fatalf("précondition: deux lignes panier distinctes attendues (reward + normal), got=%d", basketCount) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + // 20 initial - 1 (récompense) - 5 (normal) = 14, pas 19 ni 15. + if got := productStock(t, productID); got != 14 { + t.Errorf("stock après checkout (récompense + normal sur le même produit): got=%.2f want=14", got) + } + + var items []struct { + Quantite float64 `gorm:"column:quantite"` + IsReward bool `gorm:"column:is_reward"` + } + if err := testDB.GDB.Raw( + `SELECT quantite, is_reward FROM command_items WHERE product_id = ? ORDER BY is_reward`, + productID, + ).Scan(&items).Error; err != nil { + t.Fatalf("lecture command_items: %v", err) + } + if len(items) != 2 { + t.Fatalf("deux command_items distincts attendus (reward + normal), got=%d", len(items)) + } + if items[0].IsReward || items[0].Quantite != 5 { + t.Errorf("item normal: got IsReward=%v Quantite=%.2f, want IsReward=false Quantite=5", items[0].IsReward, items[0].Quantite) + } + if !items[1].IsReward || items[1].Quantite != 1 { + t.Errorf("item récompense: got IsReward=%v Quantite=%.2f, want IsReward=true Quantite=1", items[1].IsReward, items[1].Quantite) + } +} + +// Si l'article payant (même produit) fait basculer le panier sous le stock +// disponible, tout le checkout doit échouer — y compris la part récompense, +// déjà vérifié pour des produits différents dans rewards_test.go, ici avec +// le MÊME produit sur les deux lignes. +func TestCheckout_SameProductRewardAndNormalRollsBackTogetherOnInsufficientStock(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_same_product_fail") + productID := newTestProduct(t, "RewardSameProductFail", 3) + + insertRewardBasketRow(t, username, productID, 1) + insertNormalBasketRow(t, username, productID, 5, 50) // 1 + 5 = 6 > stock disponible (3) + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil { + t.Fatal("attendu un échec de checkout (1 + 5 = 6 unités demandées pour 3 en stock)") + } + + if got := productStock(t, productID); got != 3 { + t.Errorf("stock ne doit pas bouger si le total (reward+normal) dépasse le stock: got=%.2f want=3", got) + } +} + +// Une fois la commande passée (récompense + normal sur le même produit), +// l'annulation doit rembourser la somme des deux quantités, en une seule +// fois (la requête de remboursement agrège déjà par product_id sur +// l'ensemble des command_items — voir db_cancel_command.go). +func TestCancelCommandAtomic_RefundsBothRewardAndNormalLinesForSameProduct(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_same_product_cancel") + productID := newTestProduct(t, "RewardSameProductCancel", 20) + + insertRewardBasketRow(t, username, productID, 1) + insertNormalBasketRow(t, username, productID, 5, 50) + + cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + if got := productStock(t, productID); got != 14 { + t.Fatalf("précondition stock post-checkout: got=%.2f want=14", got) + } + + if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil { + t.Fatalf("CancelCommandAtomic: %v", err) + } + + // 14 + 1 (reward) + 5 (normal) = 20, retour au stock initial exact. + if got := productStock(t, productID); got != 20 { + t.Errorf("stock après annulation (remboursement des deux lignes du même produit): got=%.2f want=20", got) + } + + // Rejeu : ne doit rembourser qu'une fois, même avec deux lignes sur le même produit. + if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil { + t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois") + } + if got := productStock(t, productID); got != 20 { + t.Errorf("stock après double annulation (reward+normal même produit): got=%.2f want=20 (un seul remboursement)", got) + } +} diff --git a/backend/gestion/utils/utils.go b/backend/gestion/utils/utils.go index 87e5050f..ce42fb62 100644 --- a/backend/gestion/utils/utils.go +++ b/backend/gestion/utils/utils.go @@ -7,10 +7,6 @@ import ( "path/filepath" ) -// -------------------------------------------- -// FILES NAMES -// -------------------------------------------- - func GenerateUniqueFileName(productName string, originalFileName string) string { n, _ := rand.Int(rand.Reader, big.NewInt(1000000)) randomString := fmt.Sprintf("%d", n.Int64())