package tests import ( "gestion/db" "gestion/models" "testing" ) // ── Persistance des settings (save→reload) ────────────────────────────────── func TestUpdateSettings_FreeGiftsRoundTrip(t *testing.T) { resetSettingsAfterTest(t) s := db.DefaultSettings() s.FreeGiftsEnabled = true s.FreeGifts = []models.CategoryFreeGiftConfig{ { Category: "test", AllProducts: false, Products: []models.FreeGiftProductQuantity{ {ProductID: 111, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, {BuyQuantity: 20, FreeQuantity: 3}, }}, }, }, } if err := testDB.UpdateSettings(s); err != nil { t.Fatalf("UpdateSettings: %v", err) } loaded, err := testDB.GetSettings() if err != nil { t.Fatalf("GetSettings: %v", err) } if !loaded.FreeGiftsEnabled { t.Fatal("free_gifts_enabled devrait être true après reload") } if len(loaded.FreeGifts) != 1 { t.Fatalf("free_gifts: got=%d want=1: %+v", len(loaded.FreeGifts), loaded.FreeGifts) } gift := loaded.FreeGifts[0] if gift.Category != "test" || len(gift.Products) != 1 { t.Fatalf("free gift mal persistée: got=%+v", gift) } if len(gift.Products[0].Tiers) != 2 || gift.Products[0].Tiers[1].BuyQuantity != 20 || gift.Products[0].Tiers[1].FreeQuantity != 3 { t.Errorf("tiers mal persistés: got=%+v", gift.Products[0].Tiers) } // Désactivation : doit persister à false, pas de résurrection (même // classe de bug que TestUpdateSettings_DisablingPointsRewardPersistsAsNil). s.FreeGiftsEnabled = false if err := testDB.UpdateSettings(s); err != nil { t.Fatalf("UpdateSettings (désactivation): %v", err) } loaded, err = testDB.GetSettings() if err != nil { t.Fatalf("GetSettings (désactivation): %v", err) } if loaded.FreeGiftsEnabled { t.Error("free_gifts_enabled devrait rester false après désactivation") } } // ── Résolution de la quantité offerte (logique pure) ──────────────────────── func TestResolveFreeGift_AllProductsAtOrAboveThreshold(t *testing.T) { settings := &models.AppSettings{ FreeGiftsEnabled: true, FreeGifts: []models.CategoryFreeGiftConfig{ {Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, }}, }, } if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 1 { t.Errorf("quantité offerte: got=%.2f want=1", got) } if got := db.ResolveFreeGift(settings, 42, "fleurs", 15); got != 1 { t.Errorf("au-dessus du seuil, le cadeau reste dû: got=%.2f want=1", got) } if got := db.ResolveFreeGift(settings, 42, "fleurs", 9); got != 0 { t.Errorf("sous le seuil, aucun cadeau: got=%.2f want=0", got) } } func TestResolveFreeGift_DisabledReturnsZero(t *testing.T) { settings := &models.AppSettings{ FreeGiftsEnabled: false, FreeGifts: []models.CategoryFreeGiftConfig{ {Category: "fleurs", AllProducts: true, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, }}, }, } if got := db.ResolveFreeGift(settings, 42, "fleurs", 10); got != 0 { t.Errorf("offres désactivées: aucun cadeau attendu: got=%.2f", got) } } func TestResolveFreeGift_PerProductHighestTierApplies(t *testing.T) { settings := &models.AppSettings{ FreeGiftsEnabled: true, FreeGifts: []models.CategoryFreeGiftConfig{ { Category: "fleurs", AllProducts: false, Products: []models.FreeGiftProductQuantity{ {ProductID: 111, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, {BuyQuantity: 20, FreeQuantity: 3}, }}, }, }, }, } if got := db.ResolveFreeGift(settings, 111, "fleurs", 10); got != 1 { t.Errorf("seuil 10g: got=%.2f want=1", got) } // 25g dépasse les deux seuils : le plus élevé (20g→3g) doit être retenu, // pas le premier de la liste (10g→1g). if got := db.ResolveFreeGift(settings, 111, "fleurs", 25); got != 3 { t.Errorf("seuil le plus élevé atteint (20g→3g): got=%.2f want=3", got) } // Produit non listé dans cette config : aucun cadeau. if got := db.ResolveFreeGift(settings, 222, "fleurs", 25); got != 0 { t.Errorf("produit non couvert: got=%.2f want=0", got) } } // ── Intégration AddToBasket : la quantité livrée inclut le cadeau, au même prix ── func TestAddToBasket_AppliesFreeGiftQuantityAtSamePrice(t *testing.T) { cleanupStockTestData(t) resetSettingsAfterTest(t) username := newTestClient(t, "freegift_basket_applies") productID := newTestProduct(t, "FreeGiftBasketApplies", 50) // newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test". s := db.DefaultSettings() s.FreeGiftsEnabled = true s.FreeGifts = []models.CategoryFreeGiftConfig{ {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, }}, } if err := testDB.UpdateSettings(s); err != nil { t.Fatalf("UpdateSettings: %v", err) } basket, err := testDB.AddToBasket(username, productID, 10) if err != nil { t.Fatalf("AddToBasket: %v", err) } if basket.Quantity != 11 { t.Errorf("quantité livrée attendue = 10 + 1 offert = 11: got=%.2f", basket.Quantity) } if basket.Price != 10.0 { t.Errorf("le prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", basket.Price) } } func TestAddToBasket_NoFreeGiftBelowThreshold(t *testing.T) { cleanupStockTestData(t) resetSettingsAfterTest(t) username := newTestClient(t, "freegift_basket_below") productID := newTestProduct(t, "FreeGiftBasketBelow", 50) s := db.DefaultSettings() s.FreeGiftsEnabled = true s.FreeGifts = []models.CategoryFreeGiftConfig{ {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, }}, } if err := testDB.UpdateSettings(s); err != nil { t.Fatalf("UpdateSettings: %v", err) } basket, err := testDB.AddToBasket(username, productID, 5) if err != nil { t.Fatalf("AddToBasket: %v", err) } if basket.Quantity != 5 { t.Errorf("sous le seuil, aucune quantité offerte: got=%.2f want=5", basket.Quantity) } } // La quantité réellement décomptée du stock doit inclure le cadeau : un stock // suffisant pour la quantité demandée mais pas pour demandée+offerte doit // faire échouer l'ajout, pas livrer un cadeau partiel. func TestAddToBasket_FreeGiftRejectedWhenStockInsufficientForBonus(t *testing.T) { cleanupStockTestData(t) resetSettingsAfterTest(t) username := newTestClient(t, "freegift_basket_stock") productID := newTestProduct(t, "FreeGiftBasketStock", 10) // stock = 10, pile la quantité demandée s := db.DefaultSettings() s.FreeGiftsEnabled = true s.FreeGifts = []models.CategoryFreeGiftConfig{ {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, }}, } if err := testDB.UpdateSettings(s); err != nil { t.Fatalf("UpdateSettings: %v", err) } if _, err := testDB.AddToBasket(username, productID, 10); err == nil { t.Fatal("stock=10 ne doit pas suffire pour livrer 10g + 1g offert") } } // ── Intégration checkout : le bonus offert est bien décompté du stock ────── // // AddToBasket stocke déjà quantity = demandée + offerte (voir tests // ci-dessus) ; CreateCommandWithAddress ne relit ni ne recalcule cette // quantité — elle est copiée telle quelle dans command_items.quantite et // utilisée telle quelle pour décrémenter products.stock (db_commands.go). // Ces tests vérifient ce chemin de bout en bout, pas juste AddToBasket isolé. func TestCheckout_FreeGiftBonusQuantityDecrementsStock(t *testing.T) { cleanupStockTestData(t) resetSettingsAfterTest(t) username := newTestClient(t, "freegift_checkout_stock") productID := newTestProduct(t, "FreeGiftCheckoutStock", 50) // newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test". s := db.DefaultSettings() s.FreeGiftsEnabled = true s.FreeGifts = []models.CategoryFreeGiftConfig{ {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, }}, } if err := testDB.UpdateSettings(s); err != nil { t.Fatalf("UpdateSettings: %v", err) } if _, err := testDB.AddToBasket(username, productID, 10); err != nil { t.Fatalf("AddToBasket: %v", err) } cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") if err != nil { t.Fatalf("CreateCommandWithAddress: %v", err) } // 50 initial - (10 demandés + 1 offert) = 39, pas 40. if got := productStock(t, productID); got != 39 { t.Errorf("stock après checkout avec cadeau: got=%.2f want=39 (50 - 11)", got) } var item struct { Quantite float64 `gorm:"column:quantite"` Prix float64 `gorm:"column:prix"` } if err := testDB.GDB.Raw( `SELECT quantite, prix FROM command_items WHERE command_id = ? AND product_id = ?`, cmd.ID, productID, ).Scan(&item).Error; err != nil { t.Fatalf("lecture command_items: %v", err) } if item.Quantite != 11 { t.Errorf("command_items.quantite doit inclure le cadeau: got=%.2f want=11", item.Quantite) } if item.Prix != 10.0 { t.Errorf("command_items.prix ne doit pas changer (facturé sur les 10g demandés): got=%.2f want=10.00", item.Prix) } } func TestCheckout_FreeGiftRollsBackWhenStockInsufficientForBonus(t *testing.T) { cleanupStockTestData(t) resetSettingsAfterTest(t) username := newTestClient(t, "freegift_checkout_rollback") productID := newTestProduct(t, "FreeGiftCheckoutRollback", 50) s := db.DefaultSettings() s.FreeGiftsEnabled = true s.FreeGifts = []models.CategoryFreeGiftConfig{ {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, }}, } if err := testDB.UpdateSettings(s); err != nil { t.Fatalf("UpdateSettings: %v", err) } if _, err := testDB.AddToBasket(username, productID, 10); err != nil { t.Fatalf("AddToBasket: %v", err) } // Le stock chute sous 11 (10 demandés + 1 offert) après l'ajout au panier, // simulant une vente concurrente qui vide le stock entre AddToBasket et // checkout — le checkout doit échouer et ne rien décrémenter. if err := testDB.GDB.Exec(`UPDATE products SET stock = 10 WHERE id = ?`, productID).Error; err != nil { t.Fatalf("réduction stock: %v", err) } if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil { t.Fatal("checkout attendu en échec: stock=10 insuffisant pour 10 demandés + 1 offert") } if got := productStock(t, productID); got != 10 { t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=10", got) } } // ── Intégration annulation : le remboursement inclut le bonus offert ─────── func TestCancelCommandAtomic_RefundsFreeGiftBonusQuantity(t *testing.T) { cleanupStockTestData(t) resetSettingsAfterTest(t) username := newTestClient(t, "freegift_cancel_refund") productID := newTestProduct(t, "FreeGiftCancelRefund", 50) s := db.DefaultSettings() s.FreeGiftsEnabled = true s.FreeGifts = []models.CategoryFreeGiftConfig{ {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, }}, } if err := testDB.UpdateSettings(s); err != nil { t.Fatalf("UpdateSettings: %v", err) } if _, err := testDB.AddToBasket(username, productID, 10); 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 != 39 { t.Fatalf("précondition stock post-checkout: got=%.2f want=39", got) } if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil { t.Fatalf("CancelCommandAtomic: %v", err) } // 39 + 11 (10 demandés + 1 offert) = 50, retour exact au stock initial. if got := productStock(t, productID); got != 50 { t.Errorf("stock après annulation (bonus offert inclus dans le remboursement): got=%.2f want=50", got) } // Rejeu : ne doit rembourser qu'une fois. if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil { t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois") } if got := productStock(t, productID); got != 50 { t.Errorf("stock après double annulation: got=%.2f want=50 (un seul remboursement)", got) } } // ── Cumul avec les promotions de prix ─────────────────────────────────────── // // Une offre "achetez X, Y offert" et une promotion de réduction (%) sur le // même produit doivent pouvoir s'appliquer ensemble : la promotion réduit le // prix facturé sur la quantité demandée, le cadeau ajoute de la quantité // livrée sans toucher au prix — les deux mécanismes sont indépendants dans // AddToBasket (voir db_basket.go) mais rien ne garantissait jusqu'ici qu'ils // ne s'écrasent pas mutuellement une fois combinés. func TestAddToBasket_FreeGiftAndPromotionBothApplyTogether(t *testing.T) { cleanupStockTestData(t) resetSettingsAfterTest(t) username := newTestClient(t, "freegift_promo_combo") productID := newTestProduct(t, "FreeGiftPromoCombo", 50) // newTestProduct crée un palier quantity=1 à 10.00€ dans la catégorie "test". s := db.DefaultSettings() s.PromotionsEnabled = true s.Promotions = []models.CategoryPromotionConfig{ {Category: "test", AllProducts: true, Quantity: 10, DiscountPercent: 20}, } s.FreeGiftsEnabled = true s.FreeGifts = []models.CategoryFreeGiftConfig{ {Category: "test", AllProducts: true, Tiers: []models.FreeGiftTier{ {BuyQuantity: 10, FreeQuantity: 1}, }}, } if err := testDB.UpdateSettings(s); err != nil { t.Fatalf("UpdateSettings: %v", err) } basket, err := testDB.AddToBasket(username, productID, 10) if err != nil { t.Fatalf("AddToBasket: %v", err) } if basket.Quantity != 11 { t.Errorf("le cadeau doit s'appliquer malgré la promo active: got quantity=%.2f want=11", basket.Quantity) } if basket.Price != 8.0 { t.Errorf("la promo doit s'appliquer malgré le cadeau actif: got price=%.2f want=8.00 (10€ - 20%%)", basket.Price) } }