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