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

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