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 } // configureRewardSettings applique la récompense donnée, avec pool_0 mappé // sur la catégorie "test" — nécessaire pour que resolveCategoryRewardCandidates // (qui croise pool.Categories et reward.CategoryConfigs) considère les // produits de la catégorie comme éligibles. func configureRewardSettings(t *testing.T, reward *models.PointsReward) { t.Helper() settings := db.DefaultSettings() settings.PointsPools[0].Categories = []string{"test"} 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. Le // produit éligible et sa quantité sont désormais définis directement dans le // bloc catégorie (RewardCategoryConfig), plus de liste "reward_items" à part. 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, Description: "Un produit offert", CategoryConfigs: []models.RewardCategoryConfig{ {Category: "test", Type: "free_product", AllProducts: true, Quantity: 1}, }, }) 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) } if rows[0].Price != 0 { t.Errorf("catégorie free_product: le prix en panier doit être 0: got=%.2f", rows[0].Price) } } // Catégorie configurée en "half_price_product" avec quantité=1 : le produit // récompense doit être ajouté au panier à 50% du prix catalogue actif pour // cette quantité (palier ≤ 1), pas 0€. func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_halfprice") rewardProductID := newTestProduct(t, "RewardHTTPHalfPrice", 5) // newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go). configureRewardSettings(t, &models.PointsReward{ Threshold: 20, Description: "Un produit à moitié prix", CategoryConfigs: []models.RewardCategoryConfig{ {Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 1}, }, }) 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()) } rows := basketRewardItems(t, username) if len(rows) != 1 || rows[0].ProductID != rewardProductID { t.Fatalf("le produit récompense doit être dans le panier: %+v", rows) } if rows[0].Price != 5.0 { t.Errorf("catégorie half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", rows[0].Price) } } // La quantité configurée dans le bloc catégorie détermine le palier de prix // utilisé pour le calcul du -50% (ex: 30€ le palier quantity=1 → 15€ facturé), // pas un prix indicatif saisi ailleurs. func TestClaimMyReward_HTTPFlow_HalfPriceUsesConfiguredQuantityForPriceTier(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_halfprice_qty") rewardProductID := newTestProduct(t, "RewardHTTPHalfPriceQty", 20) // Ajoute un palier quantity=3 à 30€ (en plus du palier quantity=1 à 10€ créé par newTestProduct). if err := testDB.GDB.Exec( `INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 3, 30.00, true)`, rewardProductID, ).Error; err != nil { t.Fatalf("création palier de prix supplémentaire: %v", err) } configureRewardSettings(t, &models.PointsReward{ Threshold: 20, Description: "Un produit à moitié prix, quantité 3", CategoryConfigs: []models.RewardCategoryConfig{ {Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 3}, }, }) 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()) } rows := basketRewardItems(t, username) if len(rows) != 1 || rows[0].ProductID != rewardProductID { t.Fatalf("le produit récompense doit être dans le panier: %+v", rows) } if rows[0].Quantity != 3 { t.Errorf("la quantité en panier doit être celle configurée pour la catégorie: got=%.2f want=3", rows[0].Quantity) } if rows[0].Price != 15.0 { t.Errorf("palier quantity=3 à 30€ : prix attendu = 50%% = 15.00€: got=%.2f", rows[0].Price) } } func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_below") newTestProduct(t, "RewardHTTPBelow", 5) configureRewardSettings(t, &models.PointsReward{ Threshold: 20, CategoryConfigs: []models.RewardCategoryConfig{ {Category: "test", Type: "free_product", AllProducts: true, Quantity: 1}, }, }) 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 produit configuré par l'admin (via Products explicite) 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 dans la même // transaction via ClaimPoolRewardAndAddToBasket ; la contrainte de clé // étrangère sur baskets.product_id fait échouer l'insertion). func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_missing_product") configureRewardSettings(t, &models.PointsReward{ Threshold: 20, CategoryConfigs: []models.RewardCategoryConfig{ {Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: 999999999, Quantity: 1}}}, }, }) 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"]) } } // Si les catégories configurées sur la récompense ne correspondent à aucune // catégorie du pool réclamé (erreur de configuration admin : pool assigné à // "test", récompense configurée sur "other"), la liste de produits éligibles // est vide et la réclamation doit échouer avant de consommer un point — // sinon points_redeemed serait incrémenté sans qu'aucun produit ne soit // jamais ajouté au panier (régression couverte : la récompense était // auparavant "consommée" silencieusement sans rien livrer). func TestClaimMyReward_HTTPFlow_RejectsWhenNoEligibleItemsForPoolCategories(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_no_eligible") newTestProduct(t, "RewardHTTPNoEligible", 5) configureRewardSettings(t, &models.PointsReward{ Threshold: 20, CategoryConfigs: []models.RewardCategoryConfig{ {Category: "other", Type: "free_product", AllProducts: true, Quantity: 1}, }, }) setClientPoolPoints(t, username, "pool_0", 25) 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()) } points, 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 aucun produit n'est éligible: got redeemed=%d want=0", redeemed["pool_0"]) } if points["pool_0"] != 25 { t.Errorf("les points accumulés ne doivent pas être touchés: got=%d want=25", points["pool_0"]) } rows := basketRewardItems(t, username) if len(rows) != 0 { t.Errorf("aucun produit récompense ne doit être ajouté au panier: %+v", rows) } } // Une même catégorie peut avoir les deux types de récompense actifs en // parallèle (un lot de produits offerts + un lot de produits à -50%), chacun // avec sa propre sélection de produits et sa propre quantité. Un seul claim // doit alors ajouter les deux produits au panier, chacun tarifé selon son // propre type et sa propre quantité. func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_dual_type") freeProductID := newTestProduct(t, "RewardHTTPDualFree", 5) halfProductID := newTestProduct(t, "RewardHTTPDualHalf", 5) // newTestProduct crée les deux produits dans la catégorie "test", avec un // prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go). configureRewardSettings(t, &models.PointsReward{ Threshold: 20, Description: "Un produit offert + un produit à -50%", CategoryConfigs: []models.RewardCategoryConfig{ {Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{{ProductID: freeProductID, Quantity: 1}}}, {Category: "test", Type: "half_price_product", Products: []models.RewardProductQuantity{{ProductID: halfProductID, Quantity: 1}}}, }, }) 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()) } rows := basketRewardItems(t, username) if len(rows) != 2 { t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows) } var freeRow, halfRow *rewardBasketRow for i := range rows { switch rows[i].ProductID { case freeProductID: freeRow = &rows[i] case halfProductID: halfRow = &rows[i] } } if freeRow == nil || halfRow == nil { t.Fatalf("les deux produits attendus doivent être présents: %+v", rows) } if freeRow.Price != 0 { t.Errorf("produit de la config free_product: le prix en panier doit être 0: got=%.2f", freeRow.Price) } if halfRow.Price != 5.0 { t.Errorf("produit de la config half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", halfRow.Price) } } // Quand une catégorie n'est pas configurée en "tous les produits", chaque // produit sélectionné a sa propre quantité (ex: produit A à 2g offerts, // produit B à 1g offert, tous deux dans la même catégorie et le même type). func TestClaimMyReward_HTTPFlow_PerProductQuantityWithinSameCategoryAndType(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_per_product_qty") productA := newTestProduct(t, "RewardHTTPPerProductA", 5) productB := newTestProduct(t, "RewardHTTPPerProductB", 5) // newTestProduct crée les deux produits dans la catégorie "test". configureRewardSettings(t, &models.PointsReward{ Threshold: 20, Description: "Produit A 2g offert, produit B 1g offert", CategoryConfigs: []models.RewardCategoryConfig{ {Category: "test", Type: "free_product", Products: []models.RewardProductQuantity{ {ProductID: productA, Quantity: 2}, {ProductID: productB, Quantity: 1}, }}, }, }) 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()) } rows := basketRewardItems(t, username) if len(rows) != 2 { t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows) } var rowA, rowB *rewardBasketRow for i := range rows { switch rows[i].ProductID { case productA: rowA = &rows[i] case productB: rowB = &rows[i] } } if rowA == nil || rowB == nil { t.Fatalf("les deux produits attendus doivent être présents: %+v", rows) } if rowA.Quantity != 2 { t.Errorf("produit A: quantité attendue=2, got=%.2f", rowA.Quantity) } if rowB.Quantity != 1 { t.Errorf("produit B: quantité attendue=1, got=%.2f", rowB.Quantity) } }