This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -482,10 +482,6 @@ func StaffApproveDelivery(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPROBATION PAR ADMIN
|
||||
// ============================================
|
||||
|
||||
func ValidateDelivery(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user