This commit is contained in:
@@ -48,27 +48,9 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
|
||||
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||
var baskets []models.Panier
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
// Supprimer tout article récompense existant (remplacement)
|
||||
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
|
||||
for _, item := range items {
|
||||
if item.ProductID <= 0 || item.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
var productName string
|
||||
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
|
||||
return fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
|
||||
}
|
||||
var basket models.Panier
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
||||
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
baskets = append(baskets, basket)
|
||||
}
|
||||
return nil
|
||||
var err error
|
||||
baskets, err = addRewardsToBasketTx(tx, username, items, poolKey)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -76,6 +58,36 @@ func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// addRewardsToBasketTx contient la logique de remplacement des articles
|
||||
// récompense, factorisée pour être appelée soit seule (AddRewardsToBasket),
|
||||
// soit dans la même transaction qu'une autre opération (voir
|
||||
// ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est
|
||||
// jamais consommée sans que son produit soit effectivement livré.
|
||||
func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||
// Supprimer tout article récompense existant (remplacement)
|
||||
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
|
||||
var baskets []models.Panier
|
||||
for _, item := range items {
|
||||
if item.ProductID <= 0 || item.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
var productName string
|
||||
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
|
||||
return nil, fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
|
||||
}
|
||||
var basket models.Panier
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
||||
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baskets = append(baskets, basket)
|
||||
}
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
|
||||
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
|
||||
var counts struct {
|
||||
@@ -163,35 +175,6 @@ func (d *Database) ClearBasket(username string) error {
|
||||
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||
}
|
||||
|
||||
// ClearBasketOnCheckout décrémente le stock pour chaque article du panier puis vide le panier.
|
||||
// C'est ici que le stock est effectivement consommé, au moment de la validation de la commande.
|
||||
func (d *Database) ClearBasketOnCheckout(username string) error {
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var items []struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
}
|
||||
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&items).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture panier: %w", err)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
var currentStock float64
|
||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
if currentStock < item.Quantity {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
|
||||
}
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock - ? WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
|
||||
var username string
|
||||
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -77,103 +77,6 @@ func validateCommandStatus(status string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCommand(username string) (*models.Command, error) {
|
||||
adresse := "Adresse non spécifiée"
|
||||
var clientCheck models.Client
|
||||
if err := d.GDB.Select("username").Where("username = ?", username).First(&clientCheck).Error; err == nil && clientCheck.Username != "" {
|
||||
adresse = clientCheck.Username
|
||||
}
|
||||
|
||||
var command *models.Command
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
// Verrou sur le panier : un double-submit concurrent du même client se
|
||||
// bloque ici puis échoue proprement ("panier vide") une fois le premier
|
||||
// passage terminé, au lieu de créer une commande fantôme.
|
||||
var basketItems []basketItem
|
||||
if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil {
|
||||
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
if len(basketItems) == 0 {
|
||||
return fmt.Errorf("le panier est vide")
|
||||
}
|
||||
totalPrix := 0.0
|
||||
for _, item := range basketItems {
|
||||
totalPrix += item.Price
|
||||
}
|
||||
|
||||
var cmdResult struct {
|
||||
ID int `gorm:"column:id"`
|
||||
ClientOrderID int `gorm:"column:client_order_id"`
|
||||
}
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, client_order_id`,
|
||||
username, "pending", adresse, totalPrix, username).Scan(&cmdResult).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors de la création de la commande: %w", err)
|
||||
}
|
||||
commandID := cmdResult.ID
|
||||
|
||||
productIDs := make([]int, 0, len(basketItems))
|
||||
for _, item := range basketItems {
|
||||
productIDs = append(productIDs, item.ProductID)
|
||||
}
|
||||
productNames, _ := d.GetProductNamesByIDs(productIDs)
|
||||
|
||||
cmdItems := make([]models.CommandItem, 0, len(basketItems))
|
||||
for _, item := range basketItems {
|
||||
productName := productNames[item.ProductID]
|
||||
if productName == "" {
|
||||
productName = "Produit inconnu"
|
||||
}
|
||||
cmdItems = append(cmdItems, models.CommandItem{
|
||||
CommandID: commandID,
|
||||
Produit: productName,
|
||||
ProductID: item.ProductID,
|
||||
Quantity: item.Quantity,
|
||||
Price: item.Price,
|
||||
IsReward: item.IsReward,
|
||||
RewardPoolKey: item.RewardPoolKey,
|
||||
})
|
||||
}
|
||||
if err := tx.Create(&cmdItems).Error; err != nil {
|
||||
return fmt.Errorf("erreur lors de l'insertion des items: %w", err)
|
||||
}
|
||||
|
||||
// Les articles récompense (payés en points) restent des produits physiques
|
||||
// réellement distribués : le stock doit être décrémenté comme pour un
|
||||
// article payant.
|
||||
for _, item := range basketItems {
|
||||
var currentStock float64
|
||||
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil {
|
||||
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
if currentStock < item.Quantity {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
|
||||
}
|
||||
if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
|
||||
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
command = &models.Command{
|
||||
ID: commandID,
|
||||
ClientOrderID: cmdResult.ClientOrderID,
|
||||
Status: "pending",
|
||||
Total: totalPrix,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user