Files
projet_gestion_commande/backend/gestion/tests/rewards_test.go
T
Xor290 46652bb925
Backend - Build & Lint / build (push) Failing after 30m2s
Frontend Client - EAS Build / build (push) Canceled after 7m55s
Frontend Web - Build & Lint / build (push) Failing after 10m18s
chore: build
2026-08-21 13:08:56 +02:00

417 lines
15 KiB
Go

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 ──────────────────────────────
// AddRewardsToBasket ne recalcule plus le prix : elle stocke tel quel le
// RewardItem.Price fourni par l'appelant (0 pour "free_product", prix -50%
// déjà résolu par handlers/points.go pour "half_price_product") — voir
// TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice
// pour le flux complet qui résout ce prix par type de catégorie.
func TestAddRewardsToBasket_SetsRewardFlagsAndStoresGivenPrice(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 != 15.0 {
t.Errorf("le prix fourni par l'appelant doit être stocké tel quel: got=%.2f want=15.00", 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)
}
}