chore: fix bug
Backend - Build & Lint / build (push) Has been cancelled

This commit is contained in:
Nuxgrid
2026-07-11 11:45:13 +02:00
parent f531ddcda8
commit f674b6ef80
29 changed files with 3621 additions and 313 deletions
+33 -50
View File
@@ -48,27 +48,9 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
var baskets []models.Panier
err := d.GDB.Transaction(func(tx *gorm.DB) error {
// Supprimer tout article récompense existant (remplacement)
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
for _, item := range items {
if item.ProductID <= 0 || item.Quantity <= 0 {
continue
}
var productName string
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
return fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
}
var basket models.Panier
if err := tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
return err
}
baskets = append(baskets, basket)
}
return nil
var err error
baskets, err = addRewardsToBasketTx(tx, username, items, poolKey)
return err
})
if err != nil {
return nil, err
@@ -76,6 +58,36 @@ func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem
return baskets, nil
}
// addRewardsToBasketTx contient la logique de remplacement des articles
// récompense, factorisée pour être appelée soit seule (AddRewardsToBasket),
// soit dans la même transaction qu'une autre opération (voir
// ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est
// jamais consommée sans que son produit soit effectivement livré.
func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
// Supprimer tout article récompense existant (remplacement)
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
var baskets []models.Panier
for _, item := range items {
if item.ProductID <= 0 || item.Quantity <= 0 {
continue
}
var productName string
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
return nil, fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
}
var basket models.Panier
if err := tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
return nil, err
}
baskets = append(baskets, basket)
}
return baskets, nil
}
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
var counts struct {
@@ -163,35 +175,6 @@ func (d *Database) ClearBasket(username string) error {
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
}
// ClearBasketOnCheckout décrémente le stock pour chaque article du panier puis vide le panier.
// C'est ici que le stock est effectivement consommé, au moment de la validation de la commande.
func (d *Database) ClearBasketOnCheckout(username string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
var items []struct {
ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"`
}
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&items).Error; err != nil {
return fmt.Errorf("erreur lecture panier: %w", err)
}
for _, item := range items {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
}
if currentStock < item.Quantity {
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
}
if err := tx.Exec(`UPDATE products SET stock = stock - ? WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
}
}
return tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
})
}
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
var username string
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
+42 -12
View File
@@ -145,20 +145,23 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
return penalty, nil
}
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée).
// La clé command:eta:{id} est un hash (HSet) — un Redis.Get dessus renvoie
// toujours une erreur WRONGTYPE, ce qui faisait échouer cette vérification à
// chaque appel (aucune annulation n'était jamais détectée comme tardive via
// ce chemin, seul le statut en_route/arrived était pris en compte).
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result()
if err != nil {
etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result()
if err != nil || len(etaData) == 0 {
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
return false
}
var etaMinutes int
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes)
if err != nil || etaMinutes <= 0 {
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 {
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"])
return false
}
@@ -316,13 +319,40 @@ func (d *Database) AddClientPenalty(username string, points int) error {
return nil
}
func (d *Database) RestoreCommandStock(commandID int) error {
// CancelCommandByAdminAtomic transitionne une commande vers 'cancelled' depuis le
// panel admin/cabine de façon atomique (verrou FOR UPDATE sur la commande) : le
// remboursement de stock et le changement de statut se font dans la même
// transaction, conditionnés à une lecture du statut précédent faite sous verrou.
// Corrige un double remboursement possible sur double-tap/appel concurrent —
// l'ancien code (RestoreCommandStock + UpdateCommandStatus appelés séparément
// par le handler) lisait le statut puis restaurait le stock hors transaction,
// laissant une fenêtre où deux requêtes concurrentes lisaient toutes les deux
// "pas encore annulée" et remboursaient chacune le stock.
func (d *Database) CancelCommandByAdminAtomic(commandID int) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
return tx.Exec(`
UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
FROM command_items ci
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error
var prevStatus string
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; err != nil {
return err
}
if prevStatus == "" {
return fmt.Errorf("commande non trouvée")
}
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
if !slices.Contains(noRestoreStatuses, prevStatus) {
if err := tx.Exec(`
UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
FROM command_items ci
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
return fmt.Errorf("erreur remboursement stock: %w", err)
}
}
if err := tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, commandID).Error; err != nil {
return fmt.Errorf("erreur mise à jour statut: %w", err)
}
return nil
})
}
+71 -35
View File
@@ -737,52 +737,88 @@ func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[s
return pointsExtra, pointsRedeemed, nil
}
// claimPoolRewardTx vérifie l'éligibilité et consomme une récompense pour un
// pool donné, dans la transaction fournie — factorisée pour être appelée
// seule (ClaimPoolReward) ou combinée avec la livraison du produit dans la
// même transaction (ClaimPoolRewardAndAddToBasket), afin qu'une récompense
// ne soit jamais consommée sans que son produit soit effectivement livré.
func claimPoolRewardTx(tx *gorm.DB, username, poolKey string, threshold int) (remainingAvailable int, err error) {
var row struct {
Points int `gorm:"column:pts"`
Redeemed int `gorm:"column:redeemed"`
}
if err := tx.Raw(`
SELECT
COALESCE((points_extra->>?)::int, 0) as pts,
COALESCE((points_redeemed->>?)::int, 0) as redeemed
FROM clients WHERE username = ? FOR UPDATE`,
poolKey, poolKey, username).Scan(&row).Error; err != nil {
return 0, fmt.Errorf("erreur lecture: %w", err)
}
earned := row.Points / threshold
available := earned - row.Redeemed
if available <= 0 {
return 0, fmt.Errorf("pas de récompense disponible pour ce pool")
}
if err := tx.Exec(`
UPDATE clients
SET points_redeemed = jsonb_set(
COALESCE(points_redeemed, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?`,
poolKey, poolKey, username).Error; err != nil {
return 0, err
}
return earned - (row.Redeemed + 1), nil
}
// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points.
// Retourne le nombre de récompenses disponibles restantes après la réclamation.
func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) {
var points, redeemed int
err = d.GDB.Transaction(func(tx *gorm.DB) error {
var row struct {
Points int `gorm:"column:pts"`
Redeemed int `gorm:"column:redeemed"`
}
if err := tx.Raw(`
SELECT
COALESCE((points_extra->>?)::int, 0) as pts,
COALESCE((points_redeemed->>?)::int, 0) as redeemed
FROM clients WHERE username = ? FOR UPDATE`,
poolKey, poolKey, username).Scan(&row).Error; err != nil {
return fmt.Errorf("erreur lecture: %w", err)
}
points = row.Points
redeemed = row.Redeemed
earned := points / threshold
available := earned - redeemed
if available <= 0 {
return fmt.Errorf("pas de récompense disponible pour ce pool")
}
return tx.Exec(`
UPDATE clients
SET points_redeemed = jsonb_set(
COALESCE(points_redeemed, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?`,
poolKey, poolKey, username).Error
var err error
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
return err
})
if err != nil {
return 0, err
}
earned := points / threshold
remainingAvailable = earned - (redeemed + 1)
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
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
if err != nil {
return err
}
if len(items) > 0 {
added, err = addRewardsToBasketTx(tx, username, items, poolKey)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, nil, err
}
return remainingAvailable, added, nil
}
// ResetClientRedeemed remet à zéro les récompenses réclamées (admin).
func (d *Database) ResetClientRedeemed(username, poolKey string) error {
if poolKey != "" {
+54 -52
View File
@@ -6,6 +6,8 @@ import (
"slices"
"strings"
"time"
"gorm.io/gorm"
)
// commandItemFull mappe toutes les colonnes de command_items pour les insertions batch avec infos client.
@@ -429,6 +431,12 @@ func ptrStr(s *string) string {
return *s
}
// DeleteCommandItem supprime un item d'une commande et restaure son stock si
// la commande n'est pas déjà dans un état terminal. Le statut de la commande
// est verrouillé (FOR UPDATE) avant toute décision, dans la même transaction
// que la suppression et le remboursement, pour éviter une course avec une
// annulation concurrente de la commande entière (qui rembourserait déjà cet
// item) — même classe de bug que celle corrigée sur UpdateCommandStatusAdmin.
func (d *Database) DeleteCommandItem(commandID, itemID int) error {
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
@@ -439,61 +447,55 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
return err
}
var result struct {
Prix float64 `gorm:"column:prix"`
Quantite float64 `gorm:"column:quantite"`
ProductID int `gorm:"column:product_id"`
}
if err := d.GDB.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
return fmt.Errorf("erreur vérification item: %w", err)
}
if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
}
var cmdStatus string
d.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&cmdStatus)
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
tx := d.GDB.Begin()
if tx.Error != nil {
return fmt.Errorf("erreur démarrage transaction: %w", tx.Error)
}
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
tx.Rollback()
log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err)
}
if err := tx.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID,
).Error; err != nil {
tx.Rollback()
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
return fmt.Errorf("erreur mise à jour total commande: %w", err)
}
if restoreStock {
if err := tx.Exec(
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
result.Quantite, result.ProductID,
).Error; err != nil {
tx.Rollback()
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
return fmt.Errorf("erreur restauration stock: %w", err)
return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdStatus string
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
return fmt.Errorf("erreur vérification commande: %w", err)
}
if cmdStatus == "" {
return fmt.Errorf("commande %d non trouvée", commandID)
}
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("erreur commit transaction: %w", err)
}
var result struct {
Prix float64 `gorm:"column:prix"`
Quantite float64 `gorm:"column:quantite"`
ProductID int `gorm:"column:product_id"`
}
if err := tx.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
return fmt.Errorf("erreur vérification item: %w", err)
}
if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
}
return nil
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err)
}
if err := tx.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID,
).Error; err != nil {
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
return fmt.Errorf("erreur mise à jour total commande: %w", err)
}
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
if restoreStock {
if err := tx.Exec(
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
result.Quantite, result.ProductID,
).Error; err != nil {
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
return fmt.Errorf("erreur restauration stock: %w", err)
}
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
}
return nil
})
}
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
-97
View File
@@ -77,103 +77,6 @@ func validateCommandStatus(status string) error {
return nil
}
func (d *Database) CreateCommand(username string) (*models.Command, error) {
adresse := "Adresse non spécifiée"
var clientCheck models.Client
if err := d.GDB.Select("username").Where("username = ?", username).First(&clientCheck).Error; err == nil && clientCheck.Username != "" {
adresse = clientCheck.Username
}
var command *models.Command
err := d.GDB.Transaction(func(tx *gorm.DB) error {
// Verrou sur le panier : un double-submit concurrent du même client se
// bloque ici puis échoue proprement ("panier vide") une fois le premier
// passage terminé, au lieu de créer une commande fantôme.
var basketItems []basketItem
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
return fmt.Errorf("erreur récupération panier: %w", err)
}
if len(basketItems) == 0 {
return fmt.Errorf("le panier est vide")
}
totalPrix := 0.0
for _, item := range basketItems {
totalPrix += item.Price
}
var cmdResult struct {
ID int `gorm:"column:id"`
ClientOrderID int `gorm:"column:client_order_id"`
}
if err := tx.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, client_order_id`,
username, "pending", adresse, totalPrix, username).Scan(&cmdResult).Error; err != nil {
return fmt.Errorf("erreur lors de la création de la commande: %w", err)
}
commandID := cmdResult.ID
productIDs := make([]int, 0, len(basketItems))
for _, item := range basketItems {
productIDs = append(productIDs, item.ProductID)
}
productNames, _ := d.GetProductNamesByIDs(productIDs)
cmdItems := make([]models.CommandItem, 0, len(basketItems))
for _, item := range basketItems {
productName := productNames[item.ProductID]
if productName == "" {
productName = "Produit inconnu"
}
cmdItems = append(cmdItems, models.CommandItem{
CommandID: commandID,
Produit: productName,
ProductID: item.ProductID,
Quantity: item.Quantity,
Price: item.Price,
IsReward: item.IsReward,
RewardPoolKey: item.RewardPoolKey,
})
}
if err := tx.Create(&cmdItems).Error; err != nil {
return fmt.Errorf("erreur lors de l'insertion des items: %w", err)
}
// Les articles récompense (payés en points) restent des produits physiques
// réellement distribués : le stock doit être décrémenté comme pour un
// article payant.
for _, item := range basketItems {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
}
if currentStock < item.Quantity {
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
}
if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
}
}
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
return err
}
command = &models.Command{
ID: commandID,
ClientOrderID: cmdResult.ClientOrderID,
Status: "pending",
Total: totalPrix,
}
return nil
})
if err != nil {
return nil, err
}
return command, nil
}
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
if err := validateUsername(username); err != nil {
return nil, err
+4 -4
View File
@@ -36,7 +36,7 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
pipe := Redis.Pipeline()
pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
pipe.Expire(RedisCtx, notifKey, time.Hour)
pipe.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
@@ -69,7 +69,7 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
pipe2 := Redis.Pipeline()
pipe2.LPush(RedisCtx, notifKey, notifJSON)
pipe2.LTrim(RedisCtx, notifKey, 0, 199)
pipe2.Expire(RedisCtx, notifKey, 7*24*time.Hour)
pipe2.Expire(RedisCtx, notifKey, time.Hour)
pipe2.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
@@ -112,7 +112,7 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
notifKey := fmt.Sprintf("notifications:%s", u.Username)
pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
pipe.Expire(RedisCtx, notifKey, time.Hour)
}
pipe.Exec(RedisCtx) //nolint
@@ -153,7 +153,7 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
notifKey := fmt.Sprintf("notifications:%s", u.Username)
pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
pipe.Expire(RedisCtx, notifKey, time.Hour)
}
pipe.Exec(RedisCtx) //nolint
+12 -4
View File
@@ -223,7 +223,9 @@ func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time,
ci.produit AS name,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved' THEN ci.prix ELSE 0 END) AS revenue,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue,
COALESCE(p.category, '') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
@@ -251,7 +253,9 @@ func (d *Database) QuantityBreakdown(qtyRows *[]models.QuantityBreakdownRow, res
ci.quantite AS quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(ci.quantite) AS total_sold,
SUM(CASE WHEN c.status = 'approved' THEN ci.prix ELSE 0 END) AS revenue,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
@@ -277,7 +281,9 @@ func (d *Database) DailyProductDetail(dailyRows *[]models.DailyProductRow) error
COALESCE(cat.color, '#7c3aed') AS category_color,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved' THEN ci.prix ELSE 0 END) AS revenue
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
@@ -302,7 +308,9 @@ func (d *Database) DailyProductDetailForDate(dailyRows *[]models.DailyProductRow
COALESCE(cat.color, '#7c3aed') AS category_color,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved' THEN ci.prix ELSE 0 END) AS revenue
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
+8 -1
View File
@@ -163,8 +163,15 @@ func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition i
arrivalTime := now.Add(time.Duration(totalETA) * time.Minute)
eta := map[string]any{
"command_id": commandID,
"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".
"total_eta_minutes": totalETA,
"eta_minutes": totalETA,
"queue_position": queuePosition,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
+4 -12
View File
@@ -1145,19 +1145,11 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
}
if req.Status == "cancelled" {
current, errCmd := database.GetCommandByID(commandID)
if errCmd == nil {
currentStatus, _ := current["status"].(string)
alreadyDone := currentStatus == "cancelled" || currentStatus == "approved" || currentStatus == "livre"
if !alreadyDone {
if err := database.RestoreCommandStock(commandID); err != nil {
log.Printf("⚠️ [STATUS_ADMIN] Erreur restauration stock cmd %d: %v", commandID, err)
}
}
if err := database.CancelCommandByAdminAtomic(commandID); err != nil {
utils.ServerErr(c, "Impossible d'annuler la commande", err)
return
}
}
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
return
}
+30 -28
View File
@@ -49,12 +49,12 @@ func GetMyPointsRewards(c *gin.Context) {
}
type PoolInfo struct {
Key string `json:"key"`
Name string `json:"name"`
Points int `json:"points"`
RewardsEarned int `json:"rewards_earned"`
RewardsClaimed int `json:"rewards_claimed"`
RewardsAvailable int `json:"rewards_available"`
Key string `json:"key"`
Name string `json:"name"`
Points int `json:"points"`
RewardsEarned int `json:"rewards_earned"`
RewardsClaimed int `json:"rewards_claimed"`
RewardsAvailable int `json:"rewards_available"`
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
}
@@ -207,16 +207,6 @@ func ClaimMyReward(c *gin.Context) {
return
}
remaining, err := database.ClaimPoolReward(username, req.PoolKey, reward.Threshold)
if err != nil {
if strings.Contains(err.Error(), "pas de récompense disponible") {
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
return
}
utils.ServerErr(c, "Erreur réclamation récompense", err)
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 {
@@ -228,19 +218,31 @@ func ClaimMyReward(c *gin.Context) {
}
}
// Ajouter les produits récompense au panier si configurés
productAdded := false
var productNames []string
if len(itemsToAdd) > 0 {
if added, addErr := database.AddRewardsToBasket(username, itemsToAdd, req.PoolKey); addErr == nil && len(added) > 0 {
productAdded = true
for _, item := range added {
productNames = append(productNames, item.ProductName)
}
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
} else if addErr != nil {
log.Printf("⚠️ [CLAIM] Impossible d'ajouter produits récompense: %v", addErr)
// 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") {
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
return
}
if strings.Contains(err.Error(), "produit récompense introuvable") {
log.Printf("❌ [CLAIM] Configuration récompense invalide pour %s: %v", username, err)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
utils.ServerErr(c, "Erreur réclamation récompense", err)
return
}
productAdded := len(added) > 0
var productNames []string
for _, item := range added {
productNames = append(productNames, item.ProductName)
}
if productAdded {
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
}
c.JSON(http.StatusOK, gin.H{
+22 -14
View File
@@ -307,19 +307,22 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
}
// ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible)
// La clé est un hash (HSet), jamais une simple valeur — Redis.Get renvoie
// une erreur WRONGTYPE dessus, silencieusement ignorée ici auparavant,
// ce qui faisait toujours renvoyer etaMinutes=0.
etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, _ := db.Redis.Get(db.RedisCtx, etaKey).Result()
eta, _ := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
var etaMinutes int = 0
var etaSetAt int64 = 0
if etaData != "" {
var eta map[string]interface{}
json.Unmarshal([]byte(etaData), &eta)
if minutes, ok := eta["minutes"].(float64); ok {
etaMinutes = int(minutes)
if minutesStr, ok := eta["eta_minutes"]; ok {
if minutes, err := strconv.Atoi(minutesStr); err == nil {
etaMinutes = minutes
}
if timestamp, ok := eta["set_at"].(float64); ok {
etaSetAt = int64(timestamp)
}
if updatedAtStr, ok := eta["updated_at"]; ok {
if timestamp, err := strconv.ParseInt(updatedAtStr, 10, 64); err == nil {
etaSetAt = timestamp
}
}
@@ -1017,12 +1020,17 @@ func refreshETAForActivDelivery(username string, lat, lon float64) {
etaKey := fmt.Sprintf("command:eta:%d", commandID)
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
"command_id": commandID,
"eta_minutes": etaMinutes,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"distance_km": distanceKm,
"with_traffic": err == nil,
"command_id": commandID,
// eta_minutes ET total_eta_minutes doivent tous les deux être présents
// (voir le commentaire de SetCommandETAWithDetails) — sans quoi les
// lecteurs qui attendent l'un ou l'autre nom de champ ne trouvent rien.
"eta_minutes": etaMinutes,
"total_eta_minutes": etaMinutes,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"estimated_arrival": arrivalTime.Format(time.RFC3339),
"distance_km": distanceKm,
"with_traffic": err == nil,
})
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
}
@@ -81,8 +81,24 @@ func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*Address
return nil, fmt.Errorf("adresse vide")
}
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
// ── É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,
CorrectedAddress: rawAddress,
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
Confidence: 1.0,
CorrectionApplied: false,
Source: "exact",
}, nil
}
if loc, err := acs.geoService.fetchFromNominatim(rawAddress); err == nil {
acs.geoService.saveToCache(rawAddress, loc)
return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
@@ -0,0 +1,291 @@
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)
}
}
@@ -0,0 +1,142 @@
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)
}
})
}
}
@@ -0,0 +1,126 @@
package tests
import (
"gestion/db"
"gestion/services"
"testing"
"time"
)
// Ces tests appellent le vrai service Nominatim (réseau réel, rate-limité à
// 1 req/s — voir services/adresses_correction.go). Contrairement aux tests
// purs de services/adresses_correction_test.go (normalisation, décomposition,
// scoring — sans réseau), ceux-ci vérifient le comportement de bout en bout
// de ResolveAddress sur de vraies adresses nantaises mal écrites.
//
// Chaque cas a été vérifié manuellement au préalable (curl vers l'API
// Nominatim) pour confirmer ce que la recherche directe résout déjà seule
// (Nominatim tolère nativement la casse, les accents et certaines
// abréviations sans point) et ce qui nécessite réellement la logique de
// correction (variantes, décomposition structurée, repli ville+code postal).
//
// Un délai explicite sépare chaque cas, en plus du throttle déjà appliqué à
// chaque requête HTTP interne (1.1s dans queryNominatim), par courtoisie
// envers le service public.
const (
nantesLatMin, nantesLatMax = 47.15, 47.28
nantesLonMin, nantesLonMax = -1.65, -1.45
)
func isWithinNantes(lat, lon float64) bool {
return lat >= nantesLatMin && lat <= nantesLatMax && lon >= nantesLonMin && lon <= nantesLonMax
}
func TestResolveAddress_RealNantesAddresses(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
correction := services.NewAddressCorrectionService(geoService)
cases := []struct {
name string
input string
minConfidence float64
maxConfidence float64
}{
{
// Nominatim tolère nativement la casse et l'absence d'accent :
// résolution directe (étape 1 de ResolveAddress), confiance max.
name: "tout minuscule sans accent",
input: "12 rue crebillon 44000 nantes",
minConfidence: 0.90,
maxConfidence: 1.0,
},
{
// Abréviation sans point ("Pl" au lieu de "Place") — également
// tolérée nativement par Nominatim, résolution directe.
name: "abréviation sans point",
input: "3 Pl Royale 44000 Nantes",
minConfidence: 0.90,
maxConfidence: 1.0,
},
{
// Faute de frappe réaliste sur un nom de rue réel (Gambetta ->
// Gambeta) : vérifié que la recherche Nominatim directe ET
// toutes les variantes générées par l'algorithme (accents,
// abréviations, décomposition structurée essais 1 et 2)
// échouent — seul le repli ville+code postal (essai 3,
// confiance fixe 0.30) aboutit. Documente une vraie limite :
// l'algorithme ne corrige pas les fautes de frappe arbitraires
// dans un nom de rue, il retombe sur "quelque part dans la
// bonne ville".
name: "faute de frappe non corrigible sur le nom de rue",
input: "15 Rue Gambeta 44000 Nantes",
minConfidence: 0.25,
maxConfidence: 0.35,
},
}
for i, c := range cases {
t.Run(c.name, func(t *testing.T) {
if i > 0 {
time.Sleep(1200 * time.Millisecond)
}
suggestion, err := correction.ResolveAddress(c.input)
if err != nil {
t.Fatalf("ResolveAddress(%q): %v", c.input, err)
}
if !isWithinNantes(suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) {
t.Errorf("coordonnées hors de Nantes pour %q: lat=%.4f lon=%.4f",
c.input, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
}
if suggestion.Confidence < c.minConfidence || suggestion.Confidence > c.maxConfidence {
t.Errorf("confiance hors intervalle attendu pour %q: got=%.2f want=[%.2f,%.2f]",
c.input, suggestion.Confidence, c.minConfidence, c.maxConfidence)
}
if suggestion.CorrectedAddress == "" {
t.Errorf("adresse corrigée vide pour %q", c.input)
}
t.Logf("%q -> %q (confiance=%.2f, source=%s, correction_appliquée=%v, lat=%.4f lon=%.4f)",
c.input, suggestion.CorrectedAddress, suggestion.Confidence, suggestion.Source,
suggestion.CorrectionApplied, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
})
}
}
// Une adresse totalement absurde (aucun rapport avec un lieu réel) doit
// échouer proprement plutôt que renvoyer une coordonnée aléatoire.
func TestResolveAddress_NonsenseAddressFailsCleanly(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
correction := services.NewAddressCorrectionService(geoService)
_, err := correction.ResolveAddress("Xyzzyplonk Zorbaxx 00000 Nullepart")
if err == nil {
t.Fatal("attendu une erreur pour une adresse sans aucun rapport avec un lieu réel")
}
}
@@ -0,0 +1,227 @@
package tests
import (
"fmt"
"strconv"
"testing"
)
// Adresses réalistes de Nantes (44000) utilisées pour vérifier que l'adresse
// de livraison survit intacte à la création puis à toutes les voies de
// récupération d'une commande (client, admin, livreur, historique).
var nantesAddresses = []string{
"12 Rue Crébillon, 44000 Nantes",
"3 Place Royale, 44000 Nantes",
"5 Cours des 50 Otages, 44000 Nantes",
"8 Rue de Verdun, 44000 Nantes",
}
// newTestCommandWithAddress crée directement une commande avec une adresse et
// un statut contrôlés (en contournant le checkout), pour tester isolément la
// récupération de l'adresse par les différentes fonctions de listing.
func newTestCommandWithAddress(t *testing.T, username, status, address, livreurAssign string, productID int, quantite, prix float64) int {
t.Helper()
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, adresse, livreur_assign, total_prix, created_at, updated_at)
VALUES (?, ?, ?, NULLIF(?, ''), ?, NOW(), NOW()) RETURNING id`,
username, status, address, livreurAssign, prix,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande test avec adresse: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
cmdID, productID, quantite, prix,
).Error; err != nil {
t.Fatalf("création item test: %v", err)
}
return cmdID
}
// Le checkout réel (panier -> CreateCommandWithAddress) doit stocker l'adresse
// telle quelle, et GetCommandByID doit la restituer à l'identique.
func TestCreateCommandWithAddress_RoundTripsRealNantesAddress(t *testing.T) {
for _, address := range nantesAddresses {
t.Run(address, func(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_checkout")
productID := newTestProduct(t, "AddrCheckout", 10)
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, address)
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
command, err := testDB.GetCommandByID(cmd.ID)
if err != nil {
t.Fatalf("GetCommandByID: %v", err)
}
got, _ := command["adresse"].(string)
if got != address {
t.Errorf("adresse récupérée: got=%q want=%q", got, address)
}
if got == "" {
t.Error("l'adresse ne doit jamais être vide")
}
})
}
}
// Une adresse vide ou uniquement composée d'espaces doit être rejetée au
// checkout — pas de commande créée avec une adresse de livraison absente.
func TestCreateCommandWithAddress_RejectsEmptyOrBlankAddress(t *testing.T) {
for _, address := range []string{"", " ", "\t\n"} {
cleanupStockTestData(t)
username := newTestClient(t, "addr_blank")
productID := newTestProduct(t, "AddrBlank", 10)
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, address); err == nil {
t.Errorf("adresse %q aurait dû être rejetée", address)
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&count)
if count != 0 {
t.Errorf("aucune commande ne doit être créée avec une adresse %q: got=%d", address, count)
}
}
}
// GetAllCommands (vue admin) doit toujours renvoyer l'adresse de chaque
// commande, quel que soit son statut.
func TestGetAllCommands_AlwaysIncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_admin_list")
productID := newTestProduct(t, "AddrAdminList", 10)
want := map[int]string{}
for i, address := range nantesAddresses {
status := []string{"pending", "assigned", "en_route", "livre"}[i%4]
cmdID := newTestCommandWithAddress(t, username, status, address, "", productID, 1, 10)
want[cmdID] = address
}
commands, err := testDB.GetAllCommands("", username)
if err != nil {
t.Fatalf("GetAllCommands: %v", err)
}
if len(commands) != len(want) {
t.Fatalf("nombre de commandes: got=%d want=%d", len(commands), len(want))
}
for _, c := range commands {
id, _ := c["id"].(int)
address, _ := c["adresse"].(string)
if address == "" {
t.Errorf("commande %d: adresse vide", id)
}
if want[id] != address {
t.Errorf("commande %d: adresse=%q want=%q", id, address, want[id])
}
}
}
// GetDeliveryPersonCommands (vue livreur) doit inclure l'adresse de chaque
// commande qui lui est assignée.
func TestGetDeliveryPersonCommands_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_livreur_client")
productID := newTestProduct(t, "AddrLivreur", 10)
livreurUsername := testUserPrefix + "addr_livreur"
address := nantesAddresses[0]
cmdID := newTestCommandWithAddress(t, username, "assigned", address, livreurUsername, productID, 1, 10)
commands, err := testDB.GetDeliveryPersonCommands(livreurUsername, "")
if err != nil {
t.Fatalf("GetDeliveryPersonCommands: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes assignées: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
// Le type Go concret de la colonne "id" issue d'un Raw(...).Scan(&[]map[string]any)
// n'est pas garanti (int64 selon le driver) — même précaution que le code
// de production (ex: handlers/deleviry.go, fmt.Sprintf + strconv.Atoi).
id, _ := strconv.Atoi(fmt.Sprintf("%v", commands[0]["id"]))
if id != cmdID {
t.Errorf("id de commande inattendu: got=%d want=%d", id, cmdID)
}
}
// GetCancelledCommands doit inclure l'adresse même pour une commande annulée.
func TestGetCancelledCommands_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_cancelled")
productID := newTestProduct(t, "AddrCancelled", 10)
address := nantesAddresses[1]
newTestCommandWithAddress(t, username, "cancelled", address, "", productID, 1, 10)
commands, err := testDB.GetCancelledCommands(username, 10)
if err != nil {
t.Fatalf("GetCancelledCommands: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes annulées: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
}
// GetCompletedCommandsByUsername (historique client) doit inclure l'adresse
// des commandes terminées (approved).
func TestGetCompletedCommandsByUsername_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_history")
productID := newTestProduct(t, "AddrHistory", 10)
address := nantesAddresses[2]
newTestCommandWithAddress(t, username, "approved", address, "", productID, 1, 10)
commands, err := testDB.GetCompletedCommandsByUsername(username)
if err != nil {
t.Fatalf("GetCompletedCommandsByUsername: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes terminées: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
}
// GetAllCommandsOldestFirst (vue cabine/admin triée) doit également inclure
// l'adresse de chaque commande.
func TestGetAllCommandsOldestFirst_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_oldest_first")
productID := newTestProduct(t, "AddrOldestFirst", 10)
address := nantesAddresses[3]
newTestCommandWithAddress(t, username, "pending", address, "", productID, 1, 10)
commands, err := testDB.GetAllCommandsOldestFirst("", username)
if err != nil {
t.Fatalf("GetAllCommandsOldestFirst: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
}
@@ -0,0 +1,124 @@
package tests
import (
"bytes"
"encoding/json"
"gestion/handlers"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
// Ce fichier teste UpdateCommandStatusAdmin (annulation par admin/cabine).
// Ce chemin utilisait auparavant deux appels séparés (lecture du statut, puis
// restauration du stock hors transaction) — un double-tap ou appel concurrent
// pouvait alors rembourser le stock deux fois. Il délègue maintenant à
// db.CancelCommandByAdminAtomic, qui verrouille la commande (FOR UPDATE) et
// fait remboursement + changement de statut dans une seule transaction,
// comme CancelCommandAtomic (client) et CancelDeliveryByLivreurAtomic (livreur).
func adminCancelContext(commandID int) (*gin.Context, *httptest.ResponseRecorder) {
body, _ := json.Marshal(map[string]string{"status": "cancelled"})
req := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/orders/x/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("role", "admin")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
return c, rec
}
func TestUpdateCommandStatusAdmin_RefundsStockOnCancel(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_single")
productID := newTestProduct(t, "AdminCancelSingle", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
c, rec := adminCancelContext(cmdID)
handlers.UpdateCommandStatusAdmin(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après annulation admin (5 initial + 3 remboursés): got=%.2f want=8", got)
}
}
// Statut déjà terminal (livré) : la commande peut toujours être basculée en
// "cancelled" par l'admin (correction), mais le stock ne doit pas être
// remboursé une seconde fois puisqu'il a déjà quitté l'entrepôt.
func TestUpdateCommandStatusAdmin_DoesNotRefundAlreadyDeliveredOrder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_livre")
productID := newTestProduct(t, "AdminCancelLivre", 5)
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 3, 30)
c, rec := adminCancelContext(cmdID)
handlers.UpdateCommandStatusAdmin(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas être remboursé pour une commande déjà livrée: got=%.2f want=5", got)
}
}
// Double-tap / retry réseau sur le bouton "annuler" côté admin : régression
// du bug corrigé (double remboursement). Barrière pour maximiser le
// recouvrement réel entre goroutines.
func TestUpdateCommandStatusAdmin_ConcurrentCancelDoesNotDoubleRefundStock(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_concurrent")
productID := newTestProduct(t, "AdminCancelConcurrent", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
n := 10
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
c, _ := adminCancelContext(cmdID)
handlers.UpdateCommandStatusAdmin(c)
}()
}
close(start)
wg.Wait()
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après %d annulations admin concurrentes de la même commande (5 initial + 3 remboursés une seule fois attendu): got=%.2f", n, got)
}
}
// Reproduction déterministe de l'ancienne fenêtre de course : deux appels
// annulent la même commande l'un juste après l'autre (simulation d'un
// double-tap sans dépendre du timing du scheduler). Avec CancelCommandByAdmin
// Atomic, le second appel voit la commande déjà 'cancelled' sous verrou et ne
// rembourse pas une seconde fois.
func TestUpdateCommandStatusAdmin_SequentialDoubleCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_sequential")
productID := newTestProduct(t, "AdminCancelSequential", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
t.Fatalf("1er appel: %v", err)
}
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err)
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après double annulation admin: got=%.2f want=8 (un seul remboursement)", got)
}
}
@@ -0,0 +1,179 @@
package tests
import (
"fmt"
"gestion/db"
"strings"
"testing"
"time"
)
const notificationsScheduledKey = "notifications:scheduled"
// ScheduleETANotifications programme un rappel 5min et 3min avant l'arrivée
// estimée — mais seulement si l'ETA le justifie (voir bornes ci-dessous).
func TestScheduleETANotifications_SchedulesBothForLongETA(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_long")
productID := newTestProduct(t, "EtaNotifLong", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
before := time.Now()
if err := testDB.ScheduleETANotifications(cmdID, 10); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
score5, err := getScheduledScore(t, cmdID, "5min")
if err != nil {
t.Fatalf("notification 5min absente: %v", err)
}
score3, err := getScheduledScore(t, cmdID, "3min")
if err != nil {
t.Fatalf("notification 3min absente: %v", err)
}
wantAt5 := before.Add(5 * time.Minute).Unix() // arrivée dans 10min - 5min = dans 5min
wantAt3 := before.Add(7 * time.Minute).Unix() // arrivée dans 10min - 3min = dans 7min
if diff := abs64(score5 - wantAt5); diff > 2 {
t.Errorf("score notification 5min: got=%d want≈%d (écart %ds)", score5, wantAt5, diff)
}
if diff := abs64(score3 - wantAt3); diff > 2 {
t.Errorf("score notification 3min: got=%d want≈%d (écart %ds)", score3, wantAt3, diff)
}
if score3 <= score5 {
t.Errorf("la notification 3min doit être programmée après la 5min (plus proche de l'arrivée): score5=%d score3=%d", score5, score3)
}
}
// À la limite exacte (ETA = 5 min), un rappel "5 minutes avant l'arrivée"
// se déclencherait immédiatement (redondant) — il n'est donc volontairement
// pas programmé (condition stricte ">5", pas ">=5"). Seul le rappel 3min
// reste pertinent.
func TestScheduleETANotifications_ExactlyFiveMinutes_SkipsFiveMinReminder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_five")
productID := newTestProduct(t, "EtaNotifFive", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.ScheduleETANotifications(cmdID, 5); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
t.Error("aucune notification 5min ne doit être programmée quand ETA=5min exactement")
}
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
t.Errorf("la notification 3min doit être programmée quand ETA=5min: %v", err)
}
}
// À ETA = 3 min, même le rappel "3 minutes avant" serait immédiat : aucune
// notification ne doit être programmée.
func TestScheduleETANotifications_ExactlyThreeMinutes_SchedulesNothing(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_three")
productID := newTestProduct(t, "EtaNotifThree", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.ScheduleETANotifications(cmdID, 3); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
t.Error("aucune notification 5min ne doit être programmée pour ETA=3min")
}
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
t.Error("aucune notification 3min ne doit être programmée pour ETA=3min (serait immédiate)")
}
}
// Une ETA très courte (MinETA=3min, plancher de tout le système) ne doit
// jamais programmer de notification de rappel — cohérent avec le cas ci-dessus.
func TestScheduleETANotifications_VeryShortETASchedulesNothing(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_short")
productID := newTestProduct(t, "EtaNotifShort", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.ScheduleETANotifications(cmdID, 1); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
}
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
}
}
// SetCommandETA (appelée par UpdateDeliveryStatus au passage en "en_route")
// déclenche automatiquement ScheduleETANotifications — vérifie l'intégration
// complète, pas seulement la fonction isolée.
func TestSetCommandETA_TriggersScheduledNotifications(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_integration")
productID := newTestProduct(t, "EtaNotifIntegration", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 15); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err != nil {
t.Errorf("SetCommandETA doit programmer une notification 5min: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
t.Errorf("SetCommandETA doit programmer une notification 3min: %v", err)
}
}
// Le message envoyé au client doit contenir une indication de temps lisible,
// pas juste le statut brut.
func TestSendETANotification_MessageContainsReadableTime(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_message")
productID := newTestProduct(t, "EtaNotifMessage", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
channel := fmt.Sprintf("notifications:command:%d", cmdID)
pubsub := db.Redis.Subscribe(db.RedisCtx, channel)
defer pubsub.Close()
// Consommer le message de confirmation d'abonnement avant de publier.
if _, err := pubsub.Receive(db.RedisCtx); err != nil {
t.Fatalf("abonnement pubsub: %v", err)
}
testDB.SendETANotification(cmdID, "5min")
select {
case msg := <-pubsub.Channel():
if msg.Payload == "" {
t.Fatal("message de notification vide")
}
if !strings.Contains(msg.Payload, "5min") {
t.Errorf("le message doit indiquer le temps restant (%q): %q", "5min", msg.Payload)
}
t.Logf("message reçu: %q", msg.Payload)
case <-time.After(3 * time.Second):
t.Fatal("aucun message reçu sur le canal de notification dans le délai imparti")
}
}
func abs64(n int64) int64 {
if n < 0 {
return -n
}
return n
}
// getScheduledScore lit le score (timestamp Unix) d'une notification
// programmée pour "{commandID}:{suffix}" dans le sorted set Redis.
func getScheduledScore(t *testing.T, commandID int, suffix string) (int64, error) {
t.Helper()
member := fmt.Sprintf("%d:%s", commandID, suffix)
score, err := db.Redis.ZScore(db.RedisCtx, notificationsScheduledKey, member).Result()
if err != nil {
return 0, err
}
return int64(score), nil
}
+208
View File
@@ -0,0 +1,208 @@
package tests
import (
"encoding/json"
"gestion/handlers"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
)
// Les clients ont signalé ne jamais voir le temps de livraison (notifications,
// app mobile, site web). Ces tests reproduisent le chemin réel : une commande
// est assignée automatiquement (le worker de queue appelle
// SetCommandETAWithDetails, PAS SetCommandETA), puis un client consulte le
// suivi de sa commande — exactement ce que fait l'app mobile / le site web.
func etaTestContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/x/tracking", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", username)
c.Set("role", "client")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
return c, rec
}
// SetCommandETAWithDetails est le chemin utilisé par le worker
// d'auto-assignation/réoptimisation de queue (db/redis_queue_optimization.go,
// db/redis_queue_assignment.go) — de loin le plus emprunté en production.
// Il doit remplir "eta_minutes", pas seulement "total_eta_minutes", car
// c'est le champ que l'app mobile et le site web lisent (confirmé dans
// mobile/src/screens/client/OrderTrackingScreen.tsx et api.ts des deux
// frontends).
func TestSetCommandETAWithDetails_WritesEtaMinutesField(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_details_field")
productID := newTestProduct(t, "EtaDetailsField", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA", productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 22, 1); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
etaData, err := testDB.GetCommandETA(cmdID)
if err != nil {
t.Fatalf("GetCommandETA: %v", err)
}
got, ok := etaData["eta_minutes"]
if !ok || got == "" {
t.Errorf(`champ "eta_minutes" absent après SetCommandETAWithDetails (contenu: %v) — `+
`c'est le champ lu par le mobile et le site web, d'où l'absence de temps affiché`, etaData)
} else if got != "22" {
t.Errorf(`"eta_minutes" = %q, want "22"`, got)
}
}
// Reproduction bout-en-bout du symptôme signalé : après une assignation
// automatique (SetCommandETAWithDetails), le client consulte le suivi de sa
// commande (GetCommandTracking, l'endpoint utilisé par l'app mobile et le
// site web) — la réponse doit exposer eta.eta_minutes.
func TestGetCommandTracking_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_tracking_client")
productID := newTestProduct(t, "EtaTrackingClient", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA2", productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 17, 2); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
c, rec := etaTestContext(username, cmdID)
handlers.GetCommandTracking(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
ETA map[string]any `json:"eta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
etaMinutesRaw, ok := resp.ETA["eta_minutes"]
if !ok {
t.Fatalf(`la réponse de /tracking n'expose pas "eta.eta_minutes" (contenu eta: %v) — `+
`reproduit exactement le bug signalé par les clients`, resp.ETA)
}
etaMinutesStr, _ := etaMinutesRaw.(string)
if got, _ := strconv.Atoi(etaMinutesStr); got != 17 {
t.Errorf("eta.eta_minutes: got=%v want=17", etaMinutesRaw)
}
}
// Même reproduction via GetCommandStatus (autre endpoint de suivi, utilisé
// par l'app mobile pour le statut temps réel).
func TestGetCommandStatus_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_status_client")
productID := newTestProduct(t, "EtaStatusClient", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA3", productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 9, 1); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
c, rec := etaTestContext(username, cmdID)
handlers.GetCommandStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
ETA map[string]any `json:"eta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v", err)
}
if _, ok := resp.ETA["eta_minutes"]; !ok {
t.Fatalf(`GetCommandStatus n'expose pas "eta.eta_minutes" (contenu: %v)`, resp.ETA)
}
}
// SetCommandETA (chemin utilisé par UpdateDeliveryStatus côté livreur) doit
// lui aussi rester lisible par les lecteurs qui attendent "total_eta_minutes"
// (handlers/deleviry.go, validation_deleviry.go, geoloca.go).
func TestSetCommandETA_AlsoWritesTotalEtaMinutesField(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_simple_field")
productID := newTestProduct(t, "EtaSimpleField", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA4", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
etaData, err := testDB.GetCommandETA(cmdID)
if err != nil {
t.Fatalf("GetCommandETA: %v", err)
}
if got, ok := etaData["total_eta_minutes"]; !ok || got != "14" {
t.Errorf(`"total_eta_minutes" = %q (présent=%v), want "14"`, got, ok)
}
if got, ok := etaData["eta_minutes"]; !ok || got != "14" {
t.Errorf(`"eta_minutes" = %q (présent=%v), want "14"`, got, ok)
}
}
// GetDeliverymanLocationForCommand (vue admin/cabine) lisait l'ETA via
// Redis.Get sur une clé qui est en réalité un hash (HSet) — l'erreur
// WRONGTYPE était silencieusement ignorée et etaMinutes restait toujours à 0.
func TestGetDeliverymanLocationForCommand_ExposesEtaMinutes(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_admin_view_client")
productID := newTestProduct(t, "EtaAdminView", 10)
livreurUsername := testUserPrefix + "eta_admin_view_livreur"
cmdID := newTestCommandWithItem(t, username, "en_route", livreurUsername, productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 12, 1); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
// Position GPS du livreur, requise par le handler avant de lire l'ETA.
if err := testDB.UpdateDeliveryPersonLocation(livreurUsername, 47.2148, -1.5584); err != nil {
t.Fatalf("UpdateDeliveryPersonLocation: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", "admin_test")
c.Set("role", "admin")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(cmdID)}}
handlers.GetDeliverymanLocationForCommand(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Data struct {
ETA struct {
Minutes float64 `json:"minutes"`
HasETA bool `json:"has_eta"`
} `json:"eta"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if !resp.Data.ETA.HasETA {
t.Errorf("has_eta devrait être true, ETA pourtant définie via SetCommandETAWithDetails")
}
if resp.Data.ETA.Minutes != 12 {
t.Errorf("data.eta.minutes: got=%.0f want=12", resp.Data.ETA.Minutes)
}
}
+137
View File
@@ -0,0 +1,137 @@
package tests
import (
"encoding/json"
"gestion/handlers"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// newTestDeliveredOrder crée une commande livrée/approuvée assignée à un
// livreur avec une date de mise à jour contrôlée (GetMyDeliveryStats groupe
// par updated_at, pas created_at).
func newTestDeliveredOrder(t *testing.T, livreurUsername, clientUsername, status string, productID int, quantite, prix, referralUsed float64, updatedAt time.Time) {
t.Helper()
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, referral_used, created_at, updated_at)
VALUES (?, ?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`,
clientUsername, status, livreurUsername, prix, referralUsed, updatedAt, updatedAt,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande livrée test: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item test', ?, ?, 'delivered')`,
cmdID, productID, quantite, prix,
).Error; err != nil {
t.Fatalf("création item commande livrée test: %v", err)
}
}
func livreurStatsContext(username string) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/livreur/stats", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", username)
c.Set("role", "livreur")
return c, rec
}
type deliveryStatsResponse struct {
Success bool `json:"success"`
TodayCount int `json:"today_count"`
TodayRevenue float64 `json:"today_revenue"`
}
// today_count/today_revenue ne doivent compter que les livraisons du jour
// courant (livre/approved), nettes du crédit de parrainage — pas les jours
// précédents, même récents (voir by_day/by_week qui eux les agrègent).
func TestGetMyDeliveryStats_TodayCountAndRevenue(t *testing.T) {
cleanupStockTestData(t)
livreurUsername := newTestClient(t, "stats_livreur_today")
clientUsername := newTestClient(t, "stats_livreur_today_client")
productID := newTestProduct(t, "StatsLivreurToday", 100)
now := time.Now()
yesterday := now.AddDate(0, 0, -1)
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 2, 40, 10, now) // net 30, aujourd'hui
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 1, 25, 0, yesterday) // hier, ne doit pas compter dans "today"
c, rec := livreurStatsContext(livreurUsername)
handlers.GetMyDeliveryStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp deliveryStatsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if !resp.Success {
t.Fatalf("success=false, body=%s", rec.Body.String())
}
if resp.TodayCount != 1 {
t.Errorf("today_count: got=%d want=1 (la commande d'hier ne doit pas compter)", resp.TodayCount)
}
if resp.TodayRevenue != 30 {
t.Errorf("today_revenue: got=%.2f want=30 (40 - 10 de parrainage)", resp.TodayRevenue)
}
}
// Aucune livraison aujourd'hui : today_count/today_revenue doivent être 0,
// pas une absence de champ (voir le bug initial où le seul indicateur de
// "livraisons du jour" était l'absence de ligne dans by_day).
func TestGetMyDeliveryStats_TodayCountZeroWhenNoDeliveryToday(t *testing.T) {
cleanupStockTestData(t)
livreurUsername := newTestClient(t, "stats_livreur_none")
c, rec := livreurStatsContext(livreurUsername)
handlers.GetMyDeliveryStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp deliveryStatsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if resp.TodayCount != 0 {
t.Errorf("today_count: got=%d want=0", resp.TodayCount)
}
if resp.TodayRevenue != 0 {
t.Errorf("today_revenue: got=%.2f want=0", resp.TodayRevenue)
}
}
// Seules les commandes assignées à CE livreur doivent être comptées.
func TestGetMyDeliveryStats_OnlyCountsOwnDeliveries(t *testing.T) {
cleanupStockTestData(t)
livreurA := newTestClient(t, "stats_livreur_a")
livreurB := newTestClient(t, "stats_livreur_b")
client := newTestClient(t, "stats_livreur_shared_client")
productID := newTestProduct(t, "StatsLivreurIsolation", 100)
now := time.Now()
newTestDeliveredOrder(t, livreurA, client, "approved", productID, 1, 20, 0, now)
newTestDeliveredOrder(t, livreurB, client, "approved", productID, 1, 999, 0, now)
c, rec := livreurStatsContext(livreurA)
handlers.GetMyDeliveryStats(c)
var resp deliveryStatsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v", err)
}
if resp.TodayCount != 1 || resp.TodayRevenue != 20 {
t.Errorf("stats livreur A ne doivent refléter que ses propres livraisons: got count=%d revenue=%.2f want count=1 revenue=20", resp.TodayCount, resp.TodayRevenue)
}
}
+140
View File
@@ -0,0 +1,140 @@
// Package tests regroupe les tests de bout en bout de la gestion de stock,
// écrits contre l'API publique des paquets db/ et handlers/ (aucun accès à
// leurs symboles non exportés) — voir docker-compose.yml pour la base de
// test locale nécessaire pour les exécuter (`go test ./tests/...`).
package tests
import (
"fmt"
"gestion/db"
"os"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
)
// testDB est l'instance partagée par tous les tests de ce paquet. Toutes les
// données créées utilisent un préfixe dédié (testUserPrefix / testProductPrefix)
// et sont nettoyées avant et après chaque test, ce qui rend la suite sans
// danger même si elle tourne contre une base partagée.
var testDB *db.Database
const (
testUserPrefix = "stocktest_"
testProductPrefix = "TESTSTOCK_"
)
// testPhoneCounter garantit un numéro de téléphone unique par client de test
// (colonne UNIQUE sur clients.telephone).
var testPhoneCounter int64
func nextTestPhone() string {
n := atomic.AddInt64(&testPhoneCounter, 1)
return fmt.Sprintf("+3361%09d", n)
}
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
testDB = db.InitDB()
db.InitRedis()
os.Exit(m.Run())
}
// cleanupStockTestData supprime toutes les données créées par les tests de
// gestion de stock (identifiées par leur préfixe), dans le bon ordre pour
// respecter les contraintes de clé étrangère.
func cleanupStockTestData(t *testing.T) {
t.Helper()
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username LIKE ?)`, testUserPrefix+"%")
testDB.GDB.Exec(`DELETE FROM commandes WHERE username LIKE ?`, testUserPrefix+"%")
testDB.GDB.Exec(`DELETE FROM baskets WHERE username LIKE ?`, testUserPrefix+"%")
testDB.GDB.Exec(`DELETE FROM clients WHERE username LIKE ?`, testUserPrefix+"%")
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name LIKE ?)`, testProductPrefix+"%")
testDB.GDB.Exec(`DELETE FROM products WHERE name LIKE ?`, testProductPrefix+"%")
}
// newTestProduct crée un produit de test avec un stock initial donné et un
// prix actif pour quantity=1, et programme son nettoyage en fin de test.
func newTestProduct(t *testing.T, name 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 (?, 'test', '', ?) RETURNING id`,
fullName, 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
}
// productStock relit le stock courant d'un produit directement en base.
func productStock(t *testing.T, productID int) float64 {
t.Helper()
var stock float64
if err := testDB.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, productID).Scan(&stock).Error; err != nil {
t.Fatalf("lecture stock produit %d: %v", productID, err)
}
return stock
}
// newTestClient crée un client de test et programme son nettoyage en fin de test.
func newTestClient(t *testing.T, name string) string {
t.Helper()
username := testUserPrefix + name
if err := testDB.GDB.Exec(
`INSERT INTO clients (username, password, nom, prenom, telephone) VALUES (?, 'x', 'T', 'C', ?)
ON CONFLICT (username) DO NOTHING`,
username, nextTestPhone(),
).Error; err != nil {
t.Fatalf("création client test %q: %v", username, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username = ?)`, username)
testDB.GDB.Exec(`DELETE FROM commandes WHERE username = ?`, username)
testDB.GDB.Exec(`DELETE FROM clients WHERE username = ?`, username)
})
return username
}
// newTestCommandWithItem crée directement une commande avec un item (en
// contournant le checkout), pour tester isolément les chemins d'annulation
// et de remboursement de stock. Retourne l'ID de la commande créée.
func newTestCommandWithItem(t *testing.T, username, status, livreurAssign string, productID int, quantite float64, prix float64) int {
t.Helper()
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, created_at, updated_at)
VALUES (?, ?, NULLIF(?, ''), 'Adresse test', ?, NOW(), NOW()) RETURNING id`,
username, status, livreurAssign, prix,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande test: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
cmdID, productID, quantite, prix,
).Error; err != nil {
t.Fatalf("création item test: %v", err)
}
return cmdID
}
func commandStatus(t *testing.T, commandID int) string {
t.Helper()
var status string
testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&status)
return status
}
+405
View File
@@ -0,0 +1,405 @@
package tests
import (
"bytes"
"gestion/handlers"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
func clientAmendeAndCount(t *testing.T, username string) (amende float64, count int) {
t.Helper()
var row struct {
Amende float64 `gorm:"column:amende"`
CancellationsCount int `gorm:"column:cancellations_count"`
}
if err := testDB.GDB.Raw(
`SELECT COALESCE(amende, 0) AS amende, COALESCE(cancellations_count, 0) AS cancellations_count FROM clients WHERE username = ?`,
username,
).Scan(&row).Error; err != nil {
t.Fatalf("lecture amende/cancellations_count: %v", err)
}
return row.Amende, row.CancellationsCount
}
// ── CalculateCancellationPenalty : barème par défaut ────────────────────────
// (0->20, 1->50, 2->100, 3 et plus->150 — voir DefaultSettings)
func TestCalculateCancellationPenalty_MatchesDefaultTiers(t *testing.T) {
cases := []struct {
cancellationsCount int
wantPenalty int
}{
{0, 20},
{1, 50},
{2, 100},
{3, 150},
{10, 150}, // palier "4e et plus", plafonné
}
for _, c := range cases {
cleanupStockTestData(t)
username := newTestClient(t, "penalty_tier")
testDB.GDB.Exec(`UPDATE clients SET cancellations_count = ? WHERE username = ?`, c.cancellationsCount, username)
got, err := testDB.CalculateCancellationPenalty(username)
if err != nil {
t.Fatalf("CalculateCancellationPenalty (count=%d): %v", c.cancellationsCount, err)
}
if got != c.wantPenalty {
t.Errorf("count=%d: penalty=%d want=%d", c.cancellationsCount, got, c.wantPenalty)
}
}
}
// ── ApplyCancellationPenalty ("client absent" livreur) : cumul + concurrence ─
func TestApplyCancellationPenalty_AccumulatesAcrossSequentialCalls(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "penalty_sequential")
wantPenalties := []int{20, 50, 100, 150}
wantCumulative := []float64{20, 70, 170, 320}
for i, want := range wantPenalties {
penalty, err := testDB.ApplyCancellationPenalty(username)
if err != nil {
t.Fatalf("appel %d: %v", i+1, err)
}
if penalty != want {
t.Errorf("appel %d: penalty=%d want=%d", i+1, penalty, want)
}
amende, count := clientAmendeAndCount(t, username)
if amende != wantCumulative[i] {
t.Errorf("appel %d: amende cumulée=%.2f want=%.2f", i+1, amende, wantCumulative[i])
}
if count != i+1 {
t.Errorf("appel %d: cancellations_count=%d want=%d", i+1, count, i+1)
}
}
}
// Plusieurs "client absent" quasi simultanés sur le même client (deux
// livreurs différents annulant chacun une commande de ce client au même
// moment) ne doivent pas se marcher dessus (verrou FOR UPDATE).
func TestApplyCancellationPenalty_ConcurrentCallsDoNotLoseUpdates(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "penalty_concurrent")
n := 4
var wg sync.WaitGroup
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, errs[idx] = testDB.ApplyCancellationPenalty(username)
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Errorf("appel concurrent %d: erreur inattendue: %v", i, err)
}
}
amende, count := clientAmendeAndCount(t, username)
if count != n {
t.Errorf("cancellations_count après %d appels concurrents: got=%d want=%d", n, count, n)
}
if amende != 320 { // 20+50+100+150, un seul palier consommé par appel
t.Errorf("amende après %d appels concurrents: got=%.2f want=320.00 (pas de mise à jour perdue)", n, amende)
}
}
// ── CheckCommandETAExistsAndValid (bug corrigé : lisait la clé avec Get au lieu de HGetAll) ─
func TestCheckCommandETAExistsAndValid_FalseWhenNoETA(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_check_none")
productID := newTestProduct(t, "EtaCheckNone", 5)
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurCheckETA", productID, 1, 10)
if testDB.CheckCommandETAExistsAndValid(cmdID) {
t.Error("aucune ETA définie: attendu false")
}
}
func TestCheckCommandETAExistsAndValid_TrueWhenETASet(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_check_valid")
productID := newTestProduct(t, "EtaCheckValid", 5)
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurCheckETA2", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 15); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
if !testDB.CheckCommandETAExistsAndValid(cmdID) {
t.Error("ETA valide définie: attendu true")
}
}
// ── CancelCommandAtomic : détection d'annulation tardive et pénalité ────────
func TestCancelCommandAtomic_NoPenaltyWithoutLivreur(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_no_livreur")
productID := newTestProduct(t, "CancelPenaltyNoLivreur", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err != nil {
t.Fatalf("CancelCommandAtomic: %v", err)
}
if penalty != 0 {
t.Errorf("aucune pénalité attendue sans livreur assigné: got=%d", penalty)
}
amende, count := clientAmendeAndCount(t, username)
if amende != 0 {
t.Errorf("amende doit rester à 0: got=%.2f", amende)
}
if count != 1 {
t.Errorf("cancellations_count doit tout de même être incrémenté: got=%d want=1", count)
}
}
func TestCancelCommandAtomic_LateCancel_EnRoute_RequiresForceConfirmation(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_enroute_noforce")
productID := newTestProduct(t, "CancelPenaltyEnrouteNoforce", 5)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurLate1", productID, 1, 10)
_, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err == nil || err.Error() != "confirmation requise" {
t.Fatalf(`attendu l'erreur "confirmation requise", got=%v`, err)
}
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("le statut ne doit pas changer sans confirmation: got=%s want=en_route", got)
}
if got := productStock(t, productID); got != 5 {
t.Errorf("le stock ne doit pas être remboursé sans confirmation: got=%.2f want=5", got)
}
amende, _ := clientAmendeAndCount(t, username)
if amende != 0 {
t.Errorf("aucune amende ne doit être appliquée sans confirmation: got=%.2f", amende)
}
}
func TestCancelCommandAtomic_LateCancel_Arrived_RequiresForceConfirmation(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_arrived_noforce")
productID := newTestProduct(t, "CancelPenaltyArrivedNoforce", 5)
cmdID := newTestCommandWithItem(t, username, "arrived", testUserPrefix+"livreurLate2", productID, 1, 10)
_, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err == nil || err.Error() != "confirmation requise" {
t.Fatalf(`attendu l'erreur "confirmation requise" pour status=arrived, got=%v`, err)
}
}
func TestCancelCommandAtomic_LateCancelWithForce_AppliesPenalty(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_enroute_force")
productID := newTestProduct(t, "CancelPenaltyEnrouteForce", 5)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurLate3", productID, 2, 20)
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true)
if err != nil {
t.Fatalf("CancelCommandAtomic avec force: %v", err)
}
if penalty != 20 { // 1ère annulation de ce client
t.Errorf("penalty: got=%d want=20", penalty)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut: got=%s want=cancelled", got)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après annulation confirmée (5 initial + 2 remboursés): got=%.2f want=7", got)
}
amende, count := clientAmendeAndCount(t, username)
if amende != 20 {
t.Errorf("amende: got=%.2f want=20", amende)
}
if count != 1 {
t.Errorf("cancellations_count: got=%d want=1", count)
}
}
// Un livreur assigné mais sans statut avancé (assigned) et sans ETA connue
// n'est pas considéré comme une annulation tardive : pas besoin de force.
func TestCancelCommandAtomic_AssignedWithoutETA_NoForceNeeded(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_assigned_noeta")
productID := newTestProduct(t, "CancelPenaltyAssignedNoeta", 5)
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurLate4", productID, 1, 10)
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err != nil {
t.Fatalf("CancelCommandAtomic: %v", err)
}
if penalty != 0 {
t.Errorf("aucune pénalité attendue (pas d'ETA, statut non avancé): got=%d", penalty)
}
}
// Un livreur assigné avec une ETA valide en cache doit être traité comme une
// annulation tardive, même si le statut est encore "assigned" (régression du
// bug CheckCommandETAExistsAndValid corrigé ci-dessus).
func TestCancelCommandAtomic_AssignedWithValidETA_RequiresForce(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_assigned_eta")
productID := newTestProduct(t, "CancelPenaltyAssignedEta", 5)
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurLate5", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 12); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
_, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err == nil || err.Error() != "confirmation requise" {
t.Fatalf(`attendu "confirmation requise" (ETA valide définie): got=%v`, err)
}
// Avec force=true, la pénalité doit maintenant s'appliquer.
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true)
if err != nil {
t.Fatalf("CancelCommandAtomic avec force: %v", err)
}
if penalty != 20 {
t.Errorf("penalty: got=%d want=20", penalty)
}
}
func TestCancelCommandAtomic_PenaltyProgressesAcrossMultipleLateCancellations(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_progression")
productID := newTestProduct(t, "CancelPenaltyProgression", 20)
wantPenalties := []int{20, 50, 100, 150}
for i, want := range wantPenalties {
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurProg", productID, 1, 10)
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true)
if err != nil {
t.Fatalf("annulation %d: %v", i+1, err)
}
if penalty != want {
t.Errorf("annulation %d: penalty=%d want=%d", i+1, penalty, want)
}
}
amende, count := clientAmendeAndCount(t, username)
if count != 4 {
t.Errorf("cancellations_count: got=%d want=4", count)
}
if amende != 320 { // 20+50+100+150
t.Errorf("amende cumulée: got=%.2f want=320.00", amende)
}
}
func TestCancelCommandAtomic_NonCancellableStatusRejected(t *testing.T) {
for _, status := range []string{"livre", "approved", "cancelled", "disabled"} {
t.Run(status, func(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_terminal_"+status)
productID := newTestProduct(t, "CancelPenaltyTerminal"+status, 5)
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", true); err == nil {
t.Errorf("statut %q devrait être rejeté même avec force=true", status)
}
})
}
}
func TestCancelCommandAtomic_WrongOwnerRejected(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "cancel_penalty_owner")
intruder := newTestClient(t, "cancel_penalty_intruder")
productID := newTestProduct(t, "CancelPenaltyOwner", 5)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
if _, err := testDB.CancelCommandAtomic(cmdID, intruder, "test", false); err == nil {
t.Error("un client ne doit pas pouvoir annuler la commande d'un autre client")
}
}
// ── Flux "client absent" (livreur annule depuis 'arrived') via le handler ───
func deliveryStatusContext(livreurUsername string, commandID int, status, notes string) (*gin.Context, *httptest.ResponseRecorder) {
body := []byte(`{"status":"` + status + `","notes":"` + notes + `"}`)
req := httptest.NewRequest(http.MethodPut, "/x", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", livreurUsername)
c.Set("role", "livreur")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
return c, rec
}
// Quand le livreur marque le client absent (annulation depuis "arrived"),
// l'amende doit être appliquée au CLIENT, jamais au livreur — règle métier
// explicite (comprehension-metier).
func TestUpdateDeliveryStatus_ClientAbsent_AppliesPenaltyToClientNotLivreur(t *testing.T) {
cleanupStockTestData(t)
clientUsername := newTestClient(t, "penalty_absent_client")
livreurUsername := testUserPrefix + "penalty_absent_livreur"
productID := newTestProduct(t, "PenaltyAbsent", 5)
cmdID := newTestCommandWithItem(t, clientUsername, "arrived", livreurUsername, productID, 1, 10)
c, rec := deliveryStatusContext(livreurUsername, cmdID, "cancelled", "Client absent")
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
clientAmende, clientCount := clientAmendeAndCount(t, clientUsername)
if clientAmende != 20 {
t.Errorf("amende client après 'client absent': got=%.2f want=20", clientAmende)
}
if clientCount != 1 {
t.Errorf("cancellations_count client: got=%d want=1", clientCount)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut commande: got=%s want=cancelled", got)
}
if got := productStock(t, productID); got != 6 {
t.Errorf("stock après remboursement (5 initial + 1 remboursé): got=%.2f want=6", got)
}
}
// Annuler depuis un statut autre que "arrived"/"livre" (ex: "en_route") ne
// doit PAS déclencher la pénalité "client absent" — ce n'est pas le même
// motif d'annulation.
func TestUpdateDeliveryStatus_CancelFromEnRoute_DoesNotApplyClientAbsentPenalty(t *testing.T) {
cleanupStockTestData(t)
clientUsername := newTestClient(t, "penalty_enroute_client")
livreurUsername := testUserPrefix + "penalty_enroute_livreur"
productID := newTestProduct(t, "PenaltyEnrouteCancel", 5)
cmdID := newTestCommandWithItem(t, clientUsername, "en_route", livreurUsername, productID, 1, 10)
c, rec := deliveryStatusContext(livreurUsername, cmdID, "cancelled", "Problème livraison")
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
clientAmende, _ := clientAmendeAndCount(t, clientUsername)
if clientAmende != 0 {
t.Errorf("aucune amende ne doit être appliquée depuis en_route: got=%.2f", clientAmende)
}
}
@@ -0,0 +1,132 @@
package tests
import (
"bytes"
"encoding/json"
"gestion/db"
"gestion/handlers"
"gestion/models"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/points/claim", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", username)
return c, rec
}
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
t.Helper()
settings := db.DefaultSettings()
settings.PointsReward = reward
if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
}
// Flux complet réel : POST /points/claim avec un seuil atteint doit ajouter
// le produit récompense configuré au panier et décompter la récompense.
func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_flow")
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Type: "free_product",
Description: "Un produit offert",
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
})
setClientPoolPoints(t, username, "pool_0", 20)
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
c, rec := claimRewardContext(username, body)
handlers.ClaimMyReward(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
RemainingRewards int `json:"remaining_rewards"`
ProductAdded bool `json:"product_added"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
if !resp.Success || !resp.ProductAdded {
t.Fatalf("réclamation devrait réussir avec produit ajouté: %+v", resp)
}
if resp.RemainingRewards != 0 {
t.Errorf("remaining_rewards: got=%d want=0", resp.RemainingRewards)
}
rows := basketRewardItems(t, username)
if len(rows) != 1 || rows[0].ProductID != rewardProductID {
t.Errorf("le produit récompense doit être dans le panier: %+v", rows)
}
}
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_below")
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Type: "free_product",
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
})
setClientPoolPoints(t, username, "pool_0", 5)
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
c, rec := claimRewardContext(username, body)
handlers.ClaimMyReward(c)
if rec.Code != http.StatusConflict {
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusConflict, rec.Body.String())
}
}
// Si un item récompense configuré par l'admin pointe vers un produit
// supprimé/inexistant, la réclamation entière doit échouer — la récompense
// ne doit pas être consommée sans qu'aucun produit ne soit livré au client
// (ClaimPoolReward + AddRewardsToBasket sont maintenant dans la même
// transaction via ClaimPoolRewardAndAddToBasket).
func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_missing_product")
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Type: "free_product",
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
})
setClientPoolPoints(t, username, "pool_0", 20)
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
c, rec := claimRewardContext(username, body)
handlers.ClaimMyReward(c)
if rec.Code == http.StatusOK {
t.Fatalf("la réclamation ne doit pas réussir avec un produit récompense invalide, body=%s", rec.Body.String())
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 0 {
t.Errorf("la récompense ne doit PAS être consommée si le produit est introuvable: got redeemed=%d want=0", redeemed["pool_0"])
}
}
+411
View File
@@ -0,0 +1,411 @@
package tests
import (
"gestion/models"
"strings"
"sync"
"testing"
)
// setClientPoolPoints fixe directement les points cumulés d'un client pour un
// pool donné (contourne le flux normal d'accumulation pour tester isolément
// la réclamation de récompense).
func setClientPoolPoints(t *testing.T, username, poolKey string, points int) {
t.Helper()
if err := testDB.GDB.Exec(
`UPDATE clients SET points_extra = jsonb_set(COALESCE(points_extra, '{}'::jsonb), ARRAY[?], to_jsonb(?::int)) WHERE username = ?`,
poolKey, points, username,
).Error; err != nil {
t.Fatalf("setClientPoolPoints: %v", err)
}
}
type rewardBasketRow struct {
ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
}
func basketRewardItems(t *testing.T, username string) []rewardBasketRow {
t.Helper()
var rows []rewardBasketRow
if err := testDB.GDB.Raw(
`SELECT product_id, quantity, price, is_reward, reward_pool_key
FROM baskets WHERE username = ? AND is_reward = true`, username,
).Scan(&rows).Error; err != nil {
t.Fatalf("lecture panier récompense: %v", err)
}
return rows
}
// ── ClaimPoolReward : seuil, atomicité, épuisement ──────────────────────────
func TestClaimPoolReward_BelowThresholdFails(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_below_threshold")
setClientPoolPoints(t, username, "pool_0", 19)
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
t.Fatal("attendu une erreur : 19 points < seuil 20")
} else if !strings.Contains(err.Error(), "pas de récompense disponible") {
t.Errorf("message d'erreur inattendu: %v", err)
}
}
func TestClaimPoolReward_ExactlyAtThresholdSucceeds(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_exact_threshold")
setClientPoolPoints(t, username, "pool_0", 20)
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
if err != nil {
t.Fatalf("ClaimPoolReward: %v", err)
}
if remaining != 0 {
t.Errorf("remaining: got=%d want=0 (1 récompense gagnée, 1 réclamée)", remaining)
}
}
func TestClaimPoolReward_MultipleRewardsEarned(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_multiple")
setClientPoolPoints(t, username, "pool_0", 45) // 45/20 = 2 récompenses gagnées
remaining1, err := testDB.ClaimPoolReward(username, "pool_0", 20)
if err != nil {
t.Fatalf("1er claim: %v", err)
}
if remaining1 != 1 {
t.Errorf("après 1er claim: got=%d want=1", remaining1)
}
remaining2, err := testDB.ClaimPoolReward(username, "pool_0", 20)
if err != nil {
t.Fatalf("2e claim: %v", err)
}
if remaining2 != 0 {
t.Errorf("après 2e claim: got=%d want=0", remaining2)
}
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
t.Fatal("3e claim: attendu une erreur (récompenses épuisées)")
}
}
// Trois réclamations concurrentes pour un client n'ayant droit qu'à UNE seule
// récompense ne doivent en laisser passer qu'une seule (verrou FOR UPDATE).
func TestClaimPoolReward_ConcurrentClaimsDoNotOverclaim(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_concurrent")
setClientPoolPoints(t, username, "pool_0", 20) // 1 seule récompense disponible
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.ClaimPoolReward(username, "pool_0", 20)
}(i)
}
wg.Wait()
successCount := 0
for _, err := range errs {
if err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("un seul claim concurrent doit réussir: got=%d succès", successCount)
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 1 {
t.Errorf("compteur redeemed après claims concurrents: got=%d want=1", redeemed["pool_0"])
}
}
// Les pools sont indépendants : les points d'un pool ne doivent pas permettre
// de réclamer une récompense sur un autre pool.
func TestClaimPoolReward_PoolsAreIndependent(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_pool_isolation")
setClientPoolPoints(t, username, "pool_0", 20)
// pool_1 n'a aucun point.
if _, err := testDB.ClaimPoolReward(username, "pool_1", 20); err == nil {
t.Fatal("attendu une erreur : aucun point sur pool_1")
}
if remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
t.Errorf("pool_0 devrait rester réclamable: %v", err)
} else if remaining != 0 {
t.Errorf("remaining pool_0: got=%d want=0", remaining)
}
}
// ── ClaimPoolRewardAndAddToBasket : atomicité réclamation + livraison ───────
func TestClaimPoolRewardAndAddToBasket_SucceedsAndDecrementsAvailable(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_combined_ok")
productID := newTestProduct(t, "RewardCombinedOk", 5)
setClientPoolPoints(t, username, "pool_0", 20)
remaining, added, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
[]models.RewardItem{{ProductID: productID, Quantity: 1, Price: 10}})
if err != nil {
t.Fatalf("ClaimPoolRewardAndAddToBasket: %v", err)
}
if remaining != 0 {
t.Errorf("remaining: got=%d want=0", remaining)
}
if len(added) != 1 {
t.Fatalf("articles ajoutés: got=%d want=1", len(added))
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 1 {
t.Errorf("redeemed: got=%d want=1", redeemed["pool_0"])
}
}
// Si le produit récompense est introuvable, ni la récompense ni le panier ne
// doivent être modifiés (rollback complet de la transaction combinée).
func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_combined_rollback")
setClientPoolPoints(t, username, "pool_0", 20)
_, _, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
[]models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}})
if err == nil {
t.Fatal("attendu une erreur pour un produit récompense inexistant")
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 0 {
t.Errorf("la récompense ne doit pas être consommée si l'ajout au panier échoue: got redeemed=%d want=0", redeemed["pool_0"])
}
if rows := basketRewardItems(t, username); len(rows) != 0 {
t.Errorf("aucun article récompense ne doit rester en panier: got=%d", len(rows))
}
}
// ── AddRewardsToBasket : flags et remplacement ──────────────────────────────
func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_basket_flags")
productID := newTestProduct(t, "RewardBasketFlags", 20)
items := []models.RewardItem{{ProductID: productID, Quantity: 2, Price: 15.0}}
added, err := testDB.AddRewardsToBasket(username, items, "pool_0")
if err != nil {
t.Fatalf("AddRewardsToBasket: %v", err)
}
if len(added) != 1 {
t.Fatalf("nombre d'articles ajoutés: got=%d want=1", len(added))
}
rows := basketRewardItems(t, username)
if len(rows) != 1 {
t.Fatalf("articles récompense en base: got=%d want=1", len(rows))
}
row := rows[0]
if !row.IsReward {
t.Error("is_reward doit être true")
}
if row.RewardPoolKey != "pool_0" {
t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0")
}
if row.Price != 0 {
t.Errorf("prix affiché doit être 0 (gratuit): got=%.2f", row.Price)
}
if row.Quantity != 2 {
t.Errorf("quantité: got=%.2f want=2", row.Quantity)
}
}
// Réclamer une nouvelle récompense doit remplacer les articles récompense
// précédents, pas les cumuler (évite d'accumuler indéfiniment des articles
// gratuits si le client reclique plusieurs fois).
func TestAddRewardsToBasket_ReplacesPreviousRewardItems(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_basket_replace")
productA := newTestProduct(t, "RewardReplaceA", 20)
productB := newTestProduct(t, "RewardReplaceB", 20)
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productA, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
t.Fatalf("1er AddRewardsToBasket: %v", err)
}
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productB, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
t.Fatalf("2e AddRewardsToBasket: %v", err)
}
rows := basketRewardItems(t, username)
if len(rows) != 1 {
t.Fatalf("un seul article récompense doit rester après remplacement: got=%d", len(rows))
}
if rows[0].ProductID != productB {
t.Errorf("l'article récompense restant doit être le dernier réclamé: got=%d want=%d", rows[0].ProductID, productB)
}
}
// Un article récompense pointant vers un produit inexistant doit faire
// échouer l'ajout, sans rien insérer du tout (transaction).
func TestAddRewardsToBasket_FailsOnUnknownProduct(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_basket_unknown")
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}}, "pool_0"); err == nil {
t.Fatal("attendu une erreur pour un produit inexistant")
}
rows := basketRewardItems(t, username)
if len(rows) != 0 {
t.Errorf("aucun article ne doit être ajouté si le produit est introuvable: got=%d", len(rows))
}
}
// ── Chaîne complète : réclamation -> panier -> checkout -> stock ────────────
// C'est le scénario demandé explicitement : vérifier que le stock est bien
// déduit pour un article obtenu par récompense, exactement comme un article
// payant (règle métier explicite : les récompenses ne sont jamais exclues du
// décompte de stock).
func TestRewardClaim_FullChain_DecrementsStockAtCheckout(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_full_chain")
rewardProductID := newTestProduct(t, "RewardFullChainFree", 5)
paidProductID := newTestProduct(t, "RewardFullChainPaid", 10)
setClientPoolPoints(t, username, "pool_0", 20)
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
if err != nil {
t.Fatalf("ClaimPoolReward: %v", err)
}
if remaining != 0 {
t.Errorf("remaining: got=%d want=0", remaining)
}
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
t.Fatalf("AddRewardsToBasket: %v", err)
}
if _, err := testDB.AddToBasket(username, paidProductID, 3); err != nil {
t.Fatalf("AddToBasket (article payant): %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
if got := productStock(t, rewardProductID); got != 3 {
t.Errorf("stock article récompense après checkout (5 initial - 2 offerts): got=%.2f want=3", got)
}
if got := productStock(t, paidProductID); got != 7 {
t.Errorf("stock article payant après checkout (10 initial - 3 achetés): got=%.2f want=7", got)
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 1 {
t.Errorf("compteur de récompenses réclamées après checkout: got=%d want=1", redeemed["pool_0"])
}
}
// Si le checkout échoue (stock insuffisant sur l'article payant du même
// panier), l'article récompense ne doit pas non plus voir son stock décrémenté
// (rollback complet, cohérent avec le comportement déjà vérifié pour les
// articles payants).
func TestRewardClaim_CheckoutFailure_DoesNotDecrementRewardStock(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_checkout_fail")
rewardProductID := newTestProduct(t, "RewardCheckoutFailFree", 5)
shortProductID := newTestProduct(t, "RewardCheckoutFailShort", 1)
setClientPoolPoints(t, username, "pool_0", 20)
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
t.Fatalf("ClaimPoolReward: %v", err)
}
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
t.Fatalf("AddRewardsToBasket: %v", err)
}
// Article payant en rupture pour forcer l'échec du checkout.
if err := testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) VALUES (?, ?, 5, 50, false, CURRENT_TIMESTAMP)`,
username, shortProductID,
).Error; err != nil {
t.Fatalf("insertion panier insuffisant: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
t.Fatal("attendu un échec de checkout (stock insuffisant sur l'article payant)")
}
if got := productStock(t, rewardProductID); got != 5 {
t.Errorf("stock article récompense ne doit pas bouger si le checkout échoue: got=%.2f want=5", got)
}
}
// ── ResetClientRedeemed (admin) ──────────────────────────────────────────────
func TestResetClientRedeemed_SpecificPool(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_reset_specific")
setClientPoolPoints(t, username, "pool_0", 20)
setClientPoolPoints(t, username, "pool_1", 20)
testDB.ClaimPoolReward(username, "pool_0", 20)
testDB.ClaimPoolReward(username, "pool_1", 20)
if err := testDB.ResetClientRedeemed(username, "pool_0"); err != nil {
t.Fatalf("ResetClientRedeemed: %v", err)
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 0 {
t.Errorf("pool_0 doit être remis à zéro: got=%d", redeemed["pool_0"])
}
if redeemed["pool_1"] != 1 {
t.Errorf("pool_1 ne doit pas être affecté: got=%d want=1", redeemed["pool_1"])
}
}
func TestResetClientRedeemed_AllPools(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_reset_all")
setClientPoolPoints(t, username, "pool_0", 20)
setClientPoolPoints(t, username, "pool_1", 20)
testDB.ClaimPoolReward(username, "pool_0", 20)
testDB.ClaimPoolReward(username, "pool_1", 20)
if err := testDB.ResetClientRedeemed(username, ""); err != nil {
t.Fatalf("ResetClientRedeemed: %v", err)
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if len(redeemed) != 0 {
t.Errorf("tous les pools doivent être remis à zéro: got=%v", redeemed)
}
}
+208
View File
@@ -0,0 +1,208 @@
package tests
import (
"gestion/models"
"testing"
"time"
)
// newTestOrderForStats crée directement une commande (+ un item) avec un
// statut, un total, un crédit de parrainage utilisé et une date de création
// contrôlés, pour tester isolément les calculs de stats admin.
func newTestOrderForStats(t *testing.T, username, status string, productID int, quantite, prix, referralUsed float64, createdAt time.Time) int {
t.Helper()
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, adresse, total_prix, referral_used, created_at, updated_at)
VALUES (?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`,
username, status, prix, referralUsed, createdAt, createdAt,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande stats test: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
cmdID, productID, quantite, prix,
).Error; err != nil {
t.Fatalf("création item stats test: %v", err)
}
return cmdID
}
// TotalRevenue ne compte que les commandes approuvées, nettes du crédit de
// parrainage utilisé — les commandes annulées ou encore en cours sont exclues.
func TestTotalRevenue_NetsReferralAndExcludesNonApproved(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_total_revenue")
productID := newTestProduct(t, "StatsTotalRevenue", 100)
now := time.Now()
newTestOrderForStats(t, username, "approved", productID, 5, 100, 30, now) // net 70
newTestOrderForStats(t, username, "approved", productID, 2, 50, 0, now) // net 50
newTestOrderForStats(t, username, "cancelled", productID, 9, 999, 0, now) // exclue
newTestOrderForStats(t, username, "pending", productID, 9, 999, 0, now) // exclue (pas encore approuvée)
total, err := testDB.TotalRevenue(time.Time{})
if err != nil {
t.Fatalf("TotalRevenue: %v", err)
}
if total != 120 {
t.Errorf("TotalRevenue: got=%.2f want=120 (70+50, parrainage déduit, annulée/pending exclues)", total)
}
}
// TotalOrders exclut uniquement les commandes annulées (contrairement à
// TotalRevenue, il compte aussi les commandes non encore approuvées).
func TestTotalOrders_ExcludesOnlyCancelled(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_total_orders")
productID := newTestProduct(t, "StatsTotalOrders", 100)
now := time.Now()
newTestOrderForStats(t, username, "pending", productID, 1, 10, 0, now)
newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, now)
newTestOrderForStats(t, username, "cancelled", productID, 1, 10, 0, now)
total, err := testDB.TotalOrders(time.Time{})
if err != nil {
t.Fatalf("TotalOrders: %v", err)
}
if total != 2 {
t.Errorf("TotalOrders: got=%d want=2 (pending+approved, cancelled exclue)", total)
}
}
// Régression du bug corrigé : le revenu par produit/catégorie (TopProducts,
// QuantityBreakdown, DailyProductDetailForDate) doit rester cohérent avec
// TotalRevenue même quand une commande approuvée a utilisé du crédit de
// parrainage — avant le fix, ces trois vues sommaient ci.prix brut sans
// déduire referral_used, produisant un total supérieur au résumé global.
func TestProductBreakdowns_RevenueMatchesTotalRevenue_WithReferralUsed(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_breakdown_referral")
productA := newTestProduct(t, "StatsBreakdownA", 100)
productB := newTestProduct(t, "StatsBreakdownB", 100)
now := time.Now()
newTestOrderForStats(t, username, "approved", productA, 5, 100, 30, now) // net 70
newTestOrderForStats(t, username, "approved", productB, 2, 50, 0, now) // net 50
totalRevenue, err := testDB.TotalRevenue(time.Time{})
if err != nil {
t.Fatalf("TotalRevenue: %v", err)
}
if totalRevenue != 120 {
t.Fatalf("précondition TotalRevenue: got=%.2f want=120", totalRevenue)
}
var prodRows []models.ProductRow
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
t.Fatalf("TopProducts: %v", err)
}
sumTop := 0.0
for _, r := range prodRows {
sumTop += r.Revenue
}
if sumTop != totalRevenue {
t.Errorf("somme TopProducts.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumTop, totalRevenue)
}
var qtyRows []models.QuantityBreakdownRow
if err := testDB.QuantityBreakdown(&qtyRows, time.Time{}); err != nil {
t.Fatalf("QuantityBreakdown: %v", err)
}
sumQty := 0.0
for _, r := range qtyRows {
sumQty += r.Revenue
}
if sumQty != totalRevenue {
t.Errorf("somme QuantityBreakdown.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumQty, totalRevenue)
}
var dailyRows []models.DailyProductRow
if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil {
t.Fatalf("DailyProductDetailForDate: %v", err)
}
sumDaily := 0.0
for _, r := range dailyRows {
sumDaily += r.Revenue
}
if sumDaily != totalRevenue {
t.Errorf("somme DailyProductDetailForDate.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumDaily, totalRevenue)
}
}
// Cas limite : commande entièrement couverte par le crédit de parrainage
// (total_prix == referral_used) — la part de revenu attribuée à l'article
// doit être 0, sans division par zéro ni erreur SQL.
func TestProductBreakdowns_FullyCoveredByReferralYieldsZeroRevenue(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_breakdown_full_referral")
productID := newTestProduct(t, "StatsBreakdownFullReferral", 100)
now := time.Now()
newTestOrderForStats(t, username, "approved", productID, 4, 40, 40, now)
var prodRows []models.ProductRow
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
t.Fatalf("TopProducts: %v", err)
}
if len(prodRows) != 1 {
t.Fatalf("attendu 1 produit, got=%d", len(prodRows))
}
if prodRows[0].Revenue != 0 {
t.Errorf("revenu attendu à 0 pour une commande entièrement couverte par le parrainage: got=%.2f", prodRows[0].Revenue)
}
}
// RevenueByDayLast30 doit rester cohérent avec TotalRevenue pour des
// commandes créées aujourd'hui (dans la fenêtre des 30 derniers jours).
func TestRevenueByDayLast30_MatchesTotalRevenue(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_revenue_by_day")
productID := newTestProduct(t, "StatsRevenueByDay", 100)
now := time.Now()
newTestOrderForStats(t, username, "approved", productID, 3, 90, 20, now) // net 70
totalRevenue, err := testDB.TotalRevenue(time.Time{})
if err != nil {
t.Fatalf("TotalRevenue: %v", err)
}
var dayRevRows []models.DayRevenueRow
if err := testDB.RevenueByDayLast30(&dayRevRows, time.Time{}); err != nil {
t.Fatalf("RevenueByDayLast30: %v", err)
}
sum := 0.0
for _, r := range dayRevRows {
sum += r.Revenue
}
if sum != totalRevenue {
t.Errorf("somme RevenueByDayLast30 = %.2f, doit correspondre à TotalRevenue = %.2f", sum, totalRevenue)
}
}
// DailyProductDetailForDate ne doit inclure que les commandes du jour demandé.
func TestDailyProductDetailForDate_OnlyIncludesGivenDate(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_daily_date_filter")
productID := newTestProduct(t, "StatsDailyDateFilter", 100)
today := time.Now()
yesterday := today.AddDate(0, 0, -1)
newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, today)
newTestOrderForStats(t, username, "approved", productID, 1, 20, 0, yesterday)
var rows []models.DailyProductRow
if err := testDB.DailyProductDetailForDate(&rows, today); err != nil {
t.Fatalf("DailyProductDetailForDate: %v", err)
}
sum := 0.0
for _, r := range rows {
sum += r.Revenue
}
if sum != 10 {
t.Errorf("revenu du jour ne doit inclure que la commande d'aujourd'hui: got=%.2f want=10", sum)
}
}
+257
View File
@@ -0,0 +1,257 @@
package tests
import (
"sync"
"testing"
)
// ── Client (CancelCommandAtomic) ────────────────────────────────────────────
func TestCancelCommandAtomic_RefundsStockExactly(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_client_ok")
productID := newTestProduct(t, "CancelClientOk", 5)
// pending, sans livreur assigné -> annulation sans pénalité ni confirmation requise
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err != nil {
t.Fatalf("CancelCommandAtomic: %v", err)
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après annulation client (5 initial + 3 remboursés): got=%.2f want=8", got)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation: got=%s want=cancelled", got)
}
}
func TestCancelCommandAtomic_DoubleCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_client_dbl")
productID := newTestProduct(t, "CancelClientDbl", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err != nil {
t.Fatalf("1er CancelCommandAtomic: %v", err)
}
// Rejeu (double-tap / retry réseau) : doit échouer proprement, pas de second remboursement.
if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err == nil {
t.Fatal("le second appel sur une commande déjà annulée doit renvoyer une erreur")
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après double annulation (doit rester remboursé une seule fois): got=%.2f want=8", got)
}
}
func TestCancelCommandAtomic_ConcurrentCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_client_concurrent")
productID := newTestProduct(t, "CancelClientConcurrent", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
var wg sync.WaitGroup
results := make([]error, 3)
for i := range 3 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, results[idx] = testDB.CancelCommandAtomic(cmdID, username, "test", false)
}(i)
}
wg.Wait()
successCount := 0
for _, err := range results {
if err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("un seul appel concurrent doit réussir l'annulation: got=%d succès", successCount)
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après 3 annulations concurrentes de la même commande: got=%.2f want=8 (un seul remboursement)", got)
}
}
// ── Livreur (CancelDeliveryByLivreurAtomic) ─────────────────────────────────
func TestCancelDeliveryByLivreurAtomic_RefundsStockExactly(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_livreur_ok")
productID := newTestProduct(t, "CancelLivreurOk", 5)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurX", productID, 2, 20)
alreadyCancelled, prevStatus, err := testDB.CancelDeliveryByLivreurAtomic(cmdID)
if err != nil {
t.Fatalf("CancelDeliveryByLivreurAtomic: %v", err)
}
if alreadyCancelled {
t.Error("alreadyCancelled ne doit pas être true au premier appel")
}
if prevStatus != "en_route" {
t.Errorf("prevStatus: got=%s want=en_route", prevStatus)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après annulation livreur (5 initial + 2 remboursés): got=%.2f want=7", got)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation livreur: got=%s want=cancelled", got)
}
}
func TestCancelDeliveryByLivreurAtomic_DoubleCancelIsIdempotent(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_livreur_dbl")
productID := newTestProduct(t, "CancelLivreurDbl", 5)
cmdID := newTestCommandWithItem(t, username, "arrived", testUserPrefix+"livreurX", productID, 2, 20)
if _, _, err := testDB.CancelDeliveryByLivreurAtomic(cmdID); err != nil {
t.Fatalf("1er appel: %v", err)
}
alreadyCancelled, _, err := testDB.CancelDeliveryByLivreurAtomic(cmdID)
if err != nil {
t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err)
}
if !alreadyCancelled {
t.Error("le 2e appel doit renvoyer alreadyCancelled=true")
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après double annulation livreur (doit rester remboursé une seule fois): got=%.2f want=7", got)
}
}
func TestCancelDeliveryByLivreurAtomic_ConcurrentCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_livreur_concurrent")
productID := newTestProduct(t, "CancelLivreurConcurrent", 5)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurX", productID, 2, 20)
var wg sync.WaitGroup
n := 3
alreadyFlags := make([]bool, n)
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
alreadyFlags[idx], _, errs[idx] = testDB.CancelDeliveryByLivreurAtomic(cmdID)
}(i)
}
wg.Wait()
freshCancelCount := 0
for i := range n {
if errs[i] != nil {
t.Errorf("appel %d: erreur inattendue: %v", i, errs[i])
continue
}
if !alreadyFlags[i] {
freshCancelCount++
}
}
if freshCancelCount != 1 {
t.Errorf("un seul appel concurrent doit effectuer l'annulation réelle: got=%d", freshCancelCount)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après annulations concurrentes livreur: got=%.2f want=7 (un seul remboursement)", got)
}
}
// ── Admin/Cabine (DeleteCommandAtomic) ──────────────────────────────────────
func TestDeleteCommandAtomic_RefundsStockAndRemovesCommand(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delete_admin_ok")
productID := newTestProduct(t, "DeleteAdminOk", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 4, 40)
if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil {
t.Fatalf("DeleteCommandAtomic: %v", err)
}
if got := productStock(t, productID); got != 9 {
t.Errorf("stock après suppression admin (5 initial + 4 remboursés): got=%.2f want=9", got)
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE id = ?`, cmdID).Scan(&count)
if count != 0 {
t.Errorf("la commande doit être supprimée de la base: got=%d lignes restantes", count)
}
}
// Si la commande est déjà annulée ou approuvée, le stock a déjà été traité
// par le chemin correspondant — DeleteCommandAtomic ne doit pas rembourser
// une seconde fois lors d'une suppression a posteriori.
func TestDeleteCommandAtomic_DoesNotRefundAlreadyCancelledOrApprovedOrder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delete_admin_already")
productID := newTestProduct(t, "DeleteAdminAlready", 5)
cmdID := newTestCommandWithItem(t, username, "cancelled", "", productID, 4, 40)
if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil {
t.Fatalf("DeleteCommandAtomic: %v", err)
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas être remboursé pour une commande déjà annulée: got=%.2f want=5", got)
}
}
// ── Crypto (CancelCryptoCommand) ────────────────────────────────────────────
func TestCancelCryptoCommand_RefundsStockOnPendingPayment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_crypto_ok")
productID := newTestProduct(t, "CancelCryptoOk", 5)
cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20)
if err := testDB.CancelCryptoCommand(cmdID); err != nil {
t.Fatalf("CancelCryptoCommand: %v", err)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après annulation crypto (5 initial + 2 remboursés): got=%.2f want=7", got)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation crypto: got=%s want=cancelled", got)
}
}
func TestCancelCryptoCommand_RejectsIfNotPendingPayment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_crypto_wrong")
productID := newTestProduct(t, "CancelCryptoWrong", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
if err := testDB.CancelCryptoCommand(cmdID); err == nil {
t.Fatal("attendu une erreur : seule une commande pending_payment est annulable via ce chemin")
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas bouger si le statut n'est pas pending_payment: got=%.2f want=5", got)
}
}
func TestCancelCryptoCommand_DoubleCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_crypto_dbl")
productID := newTestProduct(t, "CancelCryptoDbl", 5)
cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20)
if err := testDB.CancelCryptoCommand(cmdID); err != nil {
t.Fatalf("1er appel: %v", err)
}
if err := testDB.CancelCryptoCommand(cmdID); err == nil {
t.Fatal("le 2e appel (webhook rejoué) doit échouer, pas rembourser une seconde fois")
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après webhook rejoué: got=%.2f want=7 (un seul remboursement)", got)
}
}
@@ -0,0 +1,200 @@
package tests
import (
"sync"
"testing"
)
// AddToBasket vérifie le stock disponible mais ne le décrémente jamais —
// le stock réel n'est consommé qu'au checkout (voir comprehension-metier).
func TestAddToBasket_RejectsInsufficientStock(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addbasket_insuff")
productID := newTestProduct(t, "AddBasketInsuff", 2)
_, err := testDB.AddToBasket(username, productID, 3)
if err == nil {
t.Fatal("attendu une erreur (stock insuffisant), reçu nil")
}
if got := productStock(t, productID); got != 2 {
t.Errorf("stock ne doit pas bouger sur un ajout panier refusé: got=%.2f want=2", got)
}
}
func TestAddToBasket_DoesNotDecrementStock(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addbasket_ok")
productID := newTestProduct(t, "AddBasketOk", 10)
if _, err := testDB.AddToBasket(username, productID, 4); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if got := productStock(t, productID); got != 10 {
t.Errorf("le stock ne doit être décrémenté qu'au checkout, pas à l'ajout panier: got=%.2f want=10", got)
}
}
// Le checkout (CreateCommandWithAddress) doit décrémenter le stock exactement
// de la quantité commandée, dans la même transaction que la création de la
// commande et le vidage du panier.
func TestCheckout_DecrementsStockExactly(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_ok")
productID := newTestProduct(t, "CheckoutOk", 10)
if _, err := testDB.AddToBasket(username, productID, 3); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après checkout de 3 unités sur 10: got=%.2f want=7", got)
}
var basketCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount)
if basketCount != 0 {
t.Errorf("le panier doit être vidé après checkout, reste %d article(s)", basketCount)
}
var status string
testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, cmd.ID).Scan(&status)
if status != "pending" {
t.Errorf("statut de la commande créée: got=%s want=pending", status)
}
}
// Règle métier : les articles récompense (is_reward=true) sont des produits
// physiques réellement distribués et doivent décrémenter le stock exactement
// comme un article payant — jamais exclus du décompte.
func TestCheckout_RewardItemDecrementsStockLikeAPaidItem(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_reward")
paidProductID := newTestProduct(t, "CheckoutRewardPaid", 10)
rewardProductID := newTestProduct(t, "CheckoutRewardFree", 5)
if _, err := testDB.AddToBasket(username, paidProductID, 2); err != nil {
t.Fatalf("AddToBasket (payant): %v", err)
}
// Article récompense : inséré directement (produit par ClaimMyReward en
// production), prix affiché 0€, mais le stock doit être traité pareil.
if err := testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
VALUES (?, ?, 2, 0, true, 'pool_0', CURRENT_TIMESTAMP)`,
username, rewardProductID,
).Error; err != nil {
t.Fatalf("insertion article récompense: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
if got := productStock(t, paidProductID); got != 8 {
t.Errorf("stock produit payant après checkout: got=%.2f want=8", got)
}
if got := productStock(t, rewardProductID); got != 3 {
t.Errorf("stock produit récompense après checkout (doit décrémenter comme un article payant): got=%.2f want=3", got)
}
}
// Un stock insuffisant sur un seul article du panier doit faire échouer tout
// le checkout, sans décrémenter partiellement les autres articles ni créer de
// commande fantôme (le tout est dans une seule transaction).
func TestCheckout_InsufficientStockOnOneItemRollsBackEverything(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_partial")
okProductID := newTestProduct(t, "CheckoutPartialOk", 10)
shortProductID := newTestProduct(t, "CheckoutPartialShort", 1)
if _, err := testDB.AddToBasket(username, okProductID, 5); err != nil {
t.Fatalf("AddToBasket (ok): %v", err)
}
// Second article : on force un panier dont la quantité dépasse le stock
// disponible au moment du checkout (simulation d'une désynchronisation,
// par ex. deux clients ayant chacun ajouté le dernier article en stock).
if err := testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
VALUES (?, ?, 5, 50, false, CURRENT_TIMESTAMP)`,
username, shortProductID,
).Error; err != nil {
t.Fatalf("insertion panier insuffisant: %v", err)
}
before := productStock(t, okProductID)
_, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err == nil {
t.Fatal("attendu un échec de checkout (stock insuffisant), reçu nil")
}
if got := productStock(t, okProductID); got != before {
t.Errorf("le stock du 1er article ne doit pas être décrémenté si le 2e échoue (pas de décrément partiel): got=%.2f want=%.2f", got, before)
}
if got := productStock(t, shortProductID); got != 1 {
t.Errorf("stock du produit en rupture ne doit pas bouger: got=%.2f want=1", got)
}
var basketCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount)
if basketCount != 2 {
t.Errorf("le panier ne doit pas être vidé si le checkout échoue: got=%d want=2", basketCount)
}
var cmdCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount)
if cmdCount != 0 {
t.Errorf("aucune commande fantôme ne doit être créée si le checkout échoue: got=%d want=0", cmdCount)
}
}
// Deux checkouts quasi simultanés pour le même client (double-tap / retry
// réseau) sur un stock tout juste suffisant pour une seule commande ne
// doivent décrémenter le stock qu'une seule fois — le verrou FOR UPDATE sur
// le panier sérialise les deux tentatives.
func TestCheckout_ConcurrentDoubleSubmitDoesNotOversell(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_concurrent")
productID := newTestProduct(t, "CheckoutConcurrent", 2)
if _, err := testDB.AddToBasket(username, productID, 2); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
var wg sync.WaitGroup
results := make([]error, 2)
for i := range 2 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, results[idx] = testDB.CreateCommandWithAddress(username, "1 rue de test")
}(i)
}
wg.Wait()
successCount := 0
for _, err := range results {
if err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("exactement 1 des 2 checkouts concurrents doit réussir (panier vidé par le premier): got=%d succès", successCount)
}
if got := productStock(t, productID); got != 0 {
t.Errorf("stock final après un seul checkout réussi de 2 unités sur 2: got=%.2f want=0", got)
}
var cmdCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount)
if cmdCount != 1 {
t.Errorf("une seule commande doit avoir été créée: got=%d want=1", cmdCount)
}
}
+116
View File
@@ -0,0 +1,116 @@
package tests
import (
"sync"
"testing"
)
func firstItemID(t *testing.T, commandID int) int {
t.Helper()
var itemID int
if err := testDB.GDB.Raw(`SELECT id FROM command_items WHERE command_id = ? LIMIT 1`, commandID).Scan(&itemID).Error; err != nil {
t.Fatalf("lecture item de la commande %d: %v", commandID, err)
}
return itemID
}
// Supprimer un item d'une commande encore active doit restituer le stock de
// cet item.
func TestDeleteCommandItem_RestoresStockOnActiveOrder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delitem_active")
productID := newTestProduct(t, "DelItemActive", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
itemID := firstItemID(t, cmdID)
if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil {
t.Fatalf("DeleteCommandItem: %v", err)
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après suppression d'item sur commande active (5 initial + 3 remboursés): got=%.2f want=8", got)
}
var remaining int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM command_items WHERE id = ?`, itemID).Scan(&remaining)
if remaining != 0 {
t.Errorf("l'item doit être supprimé: got=%d lignes restantes", remaining)
}
}
// Sur une commande déjà terminale (approuvée/livrée/annulée), le stock a
// déjà été traité par le chemin correspondant — supprimer un item a
// posteriori ne doit pas rembourser une seconde fois.
func TestDeleteCommandItem_DoesNotRestoreStockOnTerminalOrder(t *testing.T) {
for _, status := range []string{"approved", "livre", "cancelled"} {
t.Run(status, func(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delitem_"+status)
productID := newTestProduct(t, "DelItem"+status, 5)
cmdID := newTestCommandWithItem(t, username, status, "", productID, 3, 30)
itemID := firstItemID(t, cmdID)
if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil {
t.Fatalf("DeleteCommandItem: %v", err)
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas bouger pour une commande %q: got=%.2f want=5", status, got)
}
})
}
}
// DeleteCommandItem verrouille désormais le statut de la commande (FOR
// UPDATE) avant de décider de rembourser, dans la même transaction — ce test
// vérifie qu'une suppression d'item et une annulation complète de la même
// commande, déclenchées en concurrence, ne remboursent le stock qu'une
// seule fois (peu importe laquelle des deux "gagne" la course).
func TestDeleteCommandItem_ConcurrentWithFullCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delitem_concurrent")
productID := newTestProduct(t, "DelItemConcurrent", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
itemID := firstItemID(t, cmdID)
var wg sync.WaitGroup
start := make(chan struct{})
wg.Add(2)
go func() {
defer wg.Done()
<-start
_ = testDB.DeleteCommandItem(cmdID, itemID)
}()
go func() {
defer wg.Done()
<-start
_, _ = testDB.CancelCommandAtomic(cmdID, username, "test", false)
}()
close(start)
wg.Wait()
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après suppression d'item + annulation complète concurrentes (5 initial + 3 remboursés une seule fois attendu): got=%.2f want=8", got)
}
}
// ── SetProductStock (override direct admin) ─────────────────────────────────
func TestSetProductStock_SetsExactValue(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "SetStockDirect", 5)
if err := testDB.SetProductStock(productID, 42); err != nil {
t.Fatalf("SetProductStock: %v", err)
}
if got := productStock(t, productID); got != 42 {
t.Errorf("stock après SetProductStock: got=%.2f want=42", got)
}
}
func TestSetProductStock_RejectsUnknownProduct(t *testing.T) {
cleanupStockTestData(t)
if err := testDB.SetProductStock(-1, 10); err == nil {
t.Fatal("attendu une erreur pour un produit inexistant")
}
}