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") + } +}