chore: build
Backend - Build & Lint / build (push) Failing after 28m23s

This commit is contained in:
Nuxgrid
2026-07-12 15:20:53 +02:00
parent 2919bca86d
commit 4de42cff57
40 changed files with 2539 additions and 796 deletions
-7
View File
@@ -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
-4
View File
@@ -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 {
+59
View File
@@ -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
}
+1 -7
View File
@@ -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,
+19 -1
View File
@@ -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
+8 -2
View File
@@ -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",
})
}
@@ -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"]))
-4
View File
@@ -482,10 +482,6 @@ func StaffApproveDelivery(c *gin.Context) {
})
}
// ============================================
// APPROBATION PAR ADMIN
// ============================================
func ValidateDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
+19 -71
View File
@@ -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"}
+2 -2
View File
@@ -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,
}
+1 -15
View File
@@ -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(),
+5 -45
View File
@@ -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)
}
-7
View File
@@ -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)
+4 -16
View File
@@ -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))
+2 -3
View File
@@ -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
}
+3 -47
View File
@@ -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" {
-4
View File
@@ -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)
+2 -10
View File
@@ -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")
+7 -64
View File
@@ -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)
+22
View File
@@ -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"`
}
+5
View File
@@ -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
// ============================================
@@ -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)
@@ -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)
}
}
+1 -11
View File
@@ -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)
@@ -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)
}
})
}
}
+2 -2
View File
@@ -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,
}
+4 -4
View File
@@ -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"},
}
+1 -5
View File
@@ -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
}
@@ -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")
}
}
+273
View File
@@ -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")
}
}
@@ -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)
}
}
+188
View File
@@ -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)
}
}
+261
View File
@@ -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)
}
}
+160
View File
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
+254
View File
@@ -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)
}
}
+104
View File
@@ -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)
}
}
@@ -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)
}
}
-4
View File
@@ -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())