diff --git a/README.md b/README.md
index 1206e965..c69a2329 100644
--- a/README.md
+++ b/README.md
@@ -641,6 +641,10 @@ graph LR
C3["product:cache:{id}
TTL: 1h"]
end
+ subgraph Notifs["đ Notifications"]
+ N1["notifications:{username}
TTL: 1h"]
+ end
+
subgraph PubSub["ïżœïżœ Pub/Sub Channels"]
PS1["channel:position_updates"]
PS2["channel:order_status"]
@@ -652,6 +656,7 @@ graph LR
REDIS --- Positions
REDIS --- Queues
REDIS --- Cache
+ REDIS --- Notifs
REDIS --- PubSub
```
@@ -3101,6 +3106,19 @@ Le systÚme tente d'abord toutes les clés disponibles en rotation, puis bascule
## đ Changelog
+### v5.6.0 â 2026-07-11
+
+- **Fix â ETA introuvable pour l'annulation tardive (`CheckCommandETAExistsAndValid`)** : la fonction lisait la clĂ© Redis `command:eta:{id}` (un *hash*) avec `Redis.Get` (string), ce qui provoquait systĂ©matiquement une erreur `WRONGTYPE` silencieuse. Une ETA valide n'Ă©tait donc jamais dĂ©tectĂ©e par ce chemin, et un client pouvait annuler sans pĂ©nalitĂ© juste aprĂšs l'assignation d'un livreur (avant le passage au statut `en_route`). CorrigĂ© en `Redis.HGetAll`.
+- **Fix â position livreur introuvable (`GetDeliverymanLocationForCommand`)** : mĂȘme bug `WRONGTYPE` (`Redis.Get` sur un hash) empĂȘchant l'affichage de la position temps rĂ©el sur certains suivis de commande. CorrigĂ© en `Redis.HGetAll`.
+- **Fix â ETA absente des notifications/app mobile/site web** : trois fonctions qui Ă©crivent l'ETA dans Redis utilisaient des noms de champ incohĂ©rents (`eta_minutes` vs `total_eta_minutes`) alors que les clients ne lisent que `eta_minutes`. Les trois writers Ă©crivent dĂ©sormais les deux champs de façon cohĂ©rente.
+- **Fix â boucle infinie de gĂ©ocodage (`ResolveAddress` â `GeocodeAddress`)** : la correction d'adresse mal Ă©crite et le gĂ©ocodage se rappelaient mutuellement sans condition de sortie pour toute adresse Ă©chouant au gĂ©ocodage direct (le cas d'usage mĂȘme de la correction), provoquant un blocage. `ResolveAddress` appelle dĂ©sormais directement le cache/Nominatim sans repasser par `GeocodeAddress`.
+- **Fix â rĂ©compenses par points non atomiques** : `ClaimPoolReward` (consommation des points) et `AddRewardsToBasket` (ajout du produit au panier) Ă©taient deux Ă©tapes sĂ©parĂ©es ; un produit rĂ©compense supprimĂ©/introuvable faisait perdre la rĂ©compense au client sans qu'il reçoive rien. FusionnĂ©es dans `ClaimPoolRewardAndAddToBasket`, exĂ©cutĂ©e dans une seule transaction.
+- **Fix â annulation admin non atomique** : `UpdateCommandStatusAdmin` pouvait rembourser deux fois le stock en cas d'appels concurrents. Bascule sur `CancelCommandByAdminAtomic` (transaction + verrou `FOR UPDATE`).
+- **Fix â proratisation du chiffre d'affaires (stats)** : les commandes avec `referral_used` n'Ă©taient pas correctement proratisĂ©es dans les statistiques de revenu.
+- **Notifications â durĂ©e de rĂ©tention rĂ©duite Ă 1h** : les notifications stockĂ©es dans Redis passent d'un TTL de 7 jours Ă 1 heure (cohĂ©rent avec leur usage temps rĂ©el, Ă©vite l'accumulation inutile).
+- **Suppression de code mort supplémentaire** : nettoyage dans `CreateCommand` et `DeleteCommandItem` (rendu atomique).
+- **Tests unitaires** : ajout de suites complÚtes couvrant la gestion de stock (checkout/annulation/items), les statistiques, le géocodage et la correction d'adresses mal écrites (algorithme pur + intégration réseau réelle rate-limitée), les calculs de temps/ETA de commande, les récompenses par points (déduction de stock, atomicité), et l'annulation de commande avec pénalité de retard (barÚme, cumul concurrentiel, détection via statut ou ETA, flux "client absent").
+
### v5.5.0 â 2026-07-08
- **Fix â amende annulation livreur (`ApplyCancellationPenalty`)** : l'amende appliquĂ©e quand un livreur marque le client absent Ă©crasait le montant existant au lieu de l'additionner, et n'Ă©tait pas protĂ©gĂ©e par un verrou (`FOR UPDATE`). Elle est dĂ©sormais cumulative et transactionnelle, cohĂ©rente avec le chemin d'annulation client (`CancelAtomic`).
@@ -3134,8 +3152,8 @@ Le systÚme tente d'abord toutes les clés disponibles en rotation, puis bascule
---
-**Documentation mise Ă jour le :** 2026-07-08
-**Version API :** 5.5.0
+**Documentation mise Ă jour le :** 2026-07-11
+**Version API :** 5.6.0
**Technologies :** Go 1.24, Gin, PostgreSQL 16, Redis 7, React 19, Expo 54, TomTom API, ModSecurity WAF
**Déploiement :** Docker Compose · Nginx + ModSecurity OWASP CRS · TLS 1.2/1.3
**Base URL prod :** `https://mln-uber.club`
diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go
index 0954efce..30b80975 100644
--- a/backend/gestion/db/db_basket.go
+++ b/backend/gestion/db/db_basket.go
@@ -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(¤tStock).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
diff --git a/backend/gestion/db/db_cancel_command.go b/backend/gestion/db/db_cancel_command.go
index 317788af..14db8a86 100644
--- a/backend/gestion/db/db_cancel_command.go
+++ b/backend/gestion/db/db_cancel_command.go
@@ -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
})
}
diff --git a/backend/gestion/db/db_clients.go b/backend/gestion/db/db_clients.go
index 1337524b..d0a4df0d 100644
--- a/backend/gestion/db/db_clients.go
+++ b/backend/gestion/db/db_clients.go
@@ -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 != "" {
diff --git a/backend/gestion/db/db_command_items.go b/backend/gestion/db/db_command_items.go
index 379848b4..87a4db3d 100644
--- a/backend/gestion/db/db_command_items.go
+++ b/backend/gestion/db/db_command_items.go
@@ -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 {
diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go
index a9eb0ebb..a1be038e 100644
--- a/backend/gestion/db/db_commands.go
+++ b/backend/gestion/db/db_commands.go
@@ -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(¤tStock).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
diff --git a/backend/gestion/db/db_notifications.go b/backend/gestion/db/db_notifications.go
index 2048d0d9..c887a777 100644
--- a/backend/gestion/db/db_notifications.go
+++ b/backend/gestion/db/db_notifications.go
@@ -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
diff --git a/backend/gestion/db/db_stat.go b/backend/gestion/db/db_stat.go
index 8813e3e9..f042a2d7 100644
--- a/backend/gestion/db/db_stat.go
+++ b/backend/gestion/db/db_stat.go
@@ -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
diff --git a/backend/gestion/db/redis_eta_management.go b/backend/gestion/db/redis_eta_management.go
index fc76f158..3aaf39d1 100644
--- a/backend/gestion/db/redis_eta_management.go
+++ b/backend/gestion/db/redis_eta_management.go
@@ -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(),
diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go
index 5287fb75..bfe11b2f 100644
--- a/backend/gestion/handlers/commands.go
+++ b/backend/gestion/handlers/commands.go
@@ -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
}
diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go
index aa5678e3..4ff3ab77 100644
--- a/backend/gestion/handlers/points.go
+++ b/backend/gestion/handlers/points.go
@@ -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{
diff --git a/backend/gestion/handlers/redis_services.go b/backend/gestion/handlers/redis_services.go
index 0bc2d4c8..5cdcd949 100644
--- a/backend/gestion/handlers/redis_services.go
+++ b/backend/gestion/handlers/redis_services.go
@@ -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)
}
diff --git a/backend/gestion/services/adresses_correction.go b/backend/gestion/services/adresses_correction.go
index ec5f8dc5..1d0e1ce7 100644
--- a/backend/gestion/services/adresses_correction.go
+++ b/backend/gestion/services/adresses_correction.go
@@ -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,
diff --git a/backend/gestion/services/adresses_correction_test.go b/backend/gestion/services/adresses_correction_test.go
new file mode 100644
index 00000000..43cde2fd
--- /dev/null
+++ b/backend/gestion/services/adresses_correction_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/services/geo_services_test.go b/backend/gestion/services/geo_services_test.go
new file mode 100644
index 00000000..d1d45bf5
--- /dev/null
+++ b/backend/gestion/services/geo_services_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/backend/gestion/tests/address_correction_integration_test.go b/backend/gestion/tests/address_correction_integration_test.go
new file mode 100644
index 00000000..caa332d3
--- /dev/null
+++ b/backend/gestion/tests/address_correction_integration_test.go
@@ -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")
+ }
+}
diff --git a/backend/gestion/tests/commands_address_test.go b/backend/gestion/tests/commands_address_test.go
new file mode 100644
index 00000000..dba6bbc0
--- /dev/null
+++ b/backend/gestion/tests/commands_address_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/tests/commands_admin_cancel_test.go b/backend/gestion/tests/commands_admin_cancel_test.go
new file mode 100644
index 00000000..897e2dd1
--- /dev/null
+++ b/backend/gestion/tests/commands_admin_cancel_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/tests/eta_notifications_test.go b/backend/gestion/tests/eta_notifications_test.go
new file mode 100644
index 00000000..d3ea486e
--- /dev/null
+++ b/backend/gestion/tests/eta_notifications_test.go
@@ -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
+}
diff --git a/backend/gestion/tests/eta_test.go b/backend/gestion/tests/eta_test.go
new file mode 100644
index 00000000..96a8f01b
--- /dev/null
+++ b/backend/gestion/tests/eta_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/tests/livreur_stats_test.go b/backend/gestion/tests/livreur_stats_test.go
new file mode 100644
index 00000000..60d3acbe
--- /dev/null
+++ b/backend/gestion/tests/livreur_stats_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/tests/main_test.go b/backend/gestion/tests/main_test.go
new file mode 100644
index 00000000..1b47c95b
--- /dev/null
+++ b/backend/gestion/tests/main_test.go
@@ -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
+}
diff --git a/backend/gestion/tests/penalty_test.go b/backend/gestion/tests/penalty_test.go
new file mode 100644
index 00000000..ba41deec
--- /dev/null
+++ b/backend/gestion/tests/penalty_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/tests/rewards_handler_test.go b/backend/gestion/tests/rewards_handler_test.go
new file mode 100644
index 00000000..e5494a44
--- /dev/null
+++ b/backend/gestion/tests/rewards_handler_test.go
@@ -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"])
+ }
+}
diff --git a/backend/gestion/tests/rewards_test.go b/backend/gestion/tests/rewards_test.go
new file mode 100644
index 00000000..b60b535f
--- /dev/null
+++ b/backend/gestion/tests/rewards_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/tests/stats_test.go b/backend/gestion/tests/stats_test.go
new file mode 100644
index 00000000..e3df2d4b
--- /dev/null
+++ b/backend/gestion/tests/stats_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/tests/stock_cancel_test.go b/backend/gestion/tests/stock_cancel_test.go
new file mode 100644
index 00000000..1d29d54a
--- /dev/null
+++ b/backend/gestion/tests/stock_cancel_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/tests/stock_checkout_test.go b/backend/gestion/tests/stock_checkout_test.go
new file mode 100644
index 00000000..3e8fd72a
--- /dev/null
+++ b/backend/gestion/tests/stock_checkout_test.go
@@ -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)
+ }
+}
diff --git a/backend/gestion/tests/stock_item_test.go b/backend/gestion/tests/stock_item_test.go
new file mode 100644
index 00000000..9b1e0cd2
--- /dev/null
+++ b/backend/gestion/tests/stock_item_test.go
@@ -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")
+ }
+}