From d8073bdc4674cc2fd3f87390198af57fb985f86f Mon Sep 17 00:00:00 2001 From: Xor290 Date: Tue, 25 Aug 2026 21:26:02 +0200 Subject: [PATCH] chore: build --- backend/gestion/handlers/points.go | 216 ++++++++---------- backend/gestion/models/settings.go | 28 ++- backend/gestion/tests/rewards_handler_test.go | 117 +++++++--- frontend-prep/src/api/api.ts | 2 +- 4 files changed, 196 insertions(+), 167 deletions(-) diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go index a97aa39b..685a2f54 100644 --- a/backend/gestion/handlers/points.go +++ b/backend/gestion/handlers/points.go @@ -23,72 +23,75 @@ func normalizeRewardCategoryType(t string) string { return "free_product" } -// eligibleRewardProducts détermine, pour un pool donné, quels product_id de -// reward.RewardItems sont éligibles et avec quel type de récompense -// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs) -// doit faire partie des catégories du pool, soit par whitelist explicite -// (ProductIDs) soit par correspondance de catégorie produit (AllProducts). -func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string { - eligible := make(map[int]string) +// categoryRewardCandidate représente un produit éligible à la récompense pour +// une config de catégorie donnée : son type ("free_product" | +// "half_price_product") et la quantité configurée pour cette catégorie. +type categoryRewardCandidate struct { + Category string + Type string + ProductID int + Name string + Quantity float64 +} + +// resolveCategoryRewardCandidates dérive, pour chaque config de catégorie de +// la récompense, la liste des produits éligibles — tous ceux du catalogue si +// AllProducts, sinon la sélection explicite — avec le type et la quantité +// configurés directement dans le bloc catégorie (RewardCategoryConfig). +// Il n'existe plus de liste "reward_items" saisie à part : la catégorie est +// l'unique source de vérité (type + produits + quantité). +func resolveCategoryRewardCandidates(database *db.Database, reward *models.PointsReward) ([]categoryRewardCandidate, error) { + candidates := make([]categoryRewardCandidate, 0) if reward == nil { - return eligible + return candidates, nil } + + catalogCache := make(map[string][]models.Product) for _, cfg := range reward.CategoryConfigs { - if !poolCategories[cfg.Category] { - continue - } rewardType := normalizeRewardCategoryType(cfg.Type) if cfg.AllProducts { - for pid, cat := range productCategories { - if cat == cfg.Category { - eligible[pid] = rewardType + products, ok := catalogCache[cfg.Category] + if !ok { + var err error + products, err = database.GetProductsByCategory(cfg.Category) + if err != nil { + return nil, fmt.Errorf("produits catégorie %q: %w", cfg.Category, err) } + catalogCache[cfg.Category] = products + } + for _, p := range products { + candidates = append(candidates, categoryRewardCandidate{ + Category: cfg.Category, Type: rewardType, ProductID: p.ID, Name: p.Name, Quantity: cfg.Quantity, + }) + } + } else if len(cfg.ProductIDs) > 0 { + names, err := database.GetProductNamesByIDs(cfg.ProductIDs) + if err != nil { + return nil, fmt.Errorf("noms produits catégorie %q: %w", cfg.Category, err) } - } else { for _, pid := range cfg.ProductIDs { - eligible[pid] = rewardType + candidates = append(candidates, categoryRewardCandidate{ + Category: cfg.Category, Type: rewardType, ProductID: pid, Name: names[pid], Quantity: cfg.Quantity, + }) } } } - return eligible + return candidates, nil } -// categoryConfigTypeForProduct détermine le type de récompense applicable à un -// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé -// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis. -func categoryConfigTypeForProduct(reward *models.PointsReward, productID int, productCategory string) string { - for _, cfg := range reward.CategoryConfigs { - matches := false - if cfg.AllProducts { - matches = cfg.Category == productCategory - } else { - for _, pid := range cfg.ProductIDs { - if pid == productID { - matches = true - break - } - } - } - if matches { - return normalizeRewardCategoryType(cfg.Type) - } - } - return "free_product" -} - -// effectiveRewardPrice calcule le prix réellement facturé pour un item -// récompense selon le type de sa catégorie : 0€ pour "free_product", 50% du -// prix catalogue actif (palier correspondant à la quantité) pour +// effectiveRewardPrice calcule le prix réellement facturé pour une quantité +// donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour +// "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour // "half_price_product". Erreur si le prix catalogue est introuvable (produit // désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas // être proposée/réclamée plutôt que de facturer un montant incorrect. -func effectiveRewardPrice(database *db.Database, item models.RewardItem, rewardType string) (float64, error) { +func effectiveRewardPrice(database *db.Database, productID int, quantity float64, rewardType string) (float64, error) { if rewardType != "half_price_product" { return 0, nil } - catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity) + catalogPrice, err := database.GetActiveProductPrice(productID, quantity) if err != nil { - return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err) + return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", productID, err) } return math.Round(catalogPrice/2*100) / 100, nil } @@ -129,6 +132,7 @@ func GetMyPointsRewards(c *gin.Context) { AllProducts bool `json:"all_products"` ProductIDs []int `json:"product_ids"` ProductNames []string `json:"product_names"` + Quantity float64 `json:"quantity"` } type RewardItemResponse struct { @@ -150,22 +154,10 @@ func GetMyPointsRewards(c *gin.Context) { EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"` } - // Collecter tous les product_ids nécessaires en un seul passage - allProductIDs := make([]int, 0) - if reward != nil { - for _, cfg := range reward.CategoryConfigs { - if !cfg.AllProducts { - allProductIDs = append(allProductIDs, cfg.ProductIDs...) - } - } - for _, item := range reward.RewardItems { - if item.ProductID > 0 { - allProductIDs = append(allProductIDs, item.ProductID) - } - } + candidates, err := resolveCategoryRewardCandidates(database, reward) + if err != nil { + log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err) } - productNames, _ := database.GetProductNamesByIDs(allProductIDs) - productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs) pools := make([]PoolInfo, 0, len(settings.PointsPools)) for _, pool := range settings.PointsPools { @@ -191,8 +183,11 @@ func GetMyPointsRewards(c *gin.Context) { } names := make([]string, 0, len(cfg.ProductIDs)) for _, pid := range cfg.ProductIDs { - if n, ok := productNames[pid]; ok { - names = append(names, n) + for _, cand := range candidates { + if cand.ProductID == pid && cand.Category == cfg.Category { + names = append(names, cand.Name) + break + } } } eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{ @@ -201,31 +196,28 @@ func GetMyPointsRewards(c *gin.Context) { AllProducts: cfg.AllProducts, ProductIDs: cfg.ProductIDs, ProductNames: names, + Quantity: cfg.Quantity, }) } } - eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories) eligibleRewardItems := make([]RewardItemResponse, 0) - if reward != nil { - for _, item := range reward.RewardItems { - rewardType, ok := eligibleProducts[item.ProductID] - if !ok { - continue - } - price, err := effectiveRewardPrice(database, item, rewardType) - if err != nil { - log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err) - continue - } - eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{ - ProductID: item.ProductID, - ProductName: productNames[item.ProductID], - Quantity: item.Quantity, - Price: price, - Type: rewardType, - }) + for _, cand := range candidates { + if !poolCats[cand.Category] { + continue } + price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type) + if err != nil { + log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err) + continue + } + eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{ + ProductID: cand.ProductID, + ProductName: cand.Name, + Quantity: cand.Quantity, + Price: price, + Type: cand.Type, + }) } pools = append(pools, PoolInfo{ @@ -240,28 +232,22 @@ func GetMyPointsRewards(c *gin.Context) { }) } - // Construire la liste des produits récompense avec leurs noms (aperçu - // global, indépendant d'un pool précis — le type/prix effectif par pool - // est celui exposé dans pools[].eligible_reward_items). + // Aperçu global des produits récompense, indépendant d'un pool précis — le + // type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items. var rewardMeta gin.H if reward != nil { - rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems)) - for _, item := range reward.RewardItems { - if item.ProductID <= 0 { + rewardItems := make([]RewardItemResponse, 0, len(candidates)) + for _, cand := range candidates { + price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type) + if err != nil { continue } - rewardType := categoryConfigTypeForProduct(reward, item.ProductID, productCategories[item.ProductID]) - price, err := effectiveRewardPrice(database, item, rewardType) - if err != nil { - price = item.Price // fallback indicatif si le prix catalogue est momentanément indisponible - } - name := productNames[item.ProductID] rewardItems = append(rewardItems, RewardItemResponse{ - ProductID: item.ProductID, - ProductName: name, - Quantity: item.Quantity, + ProductID: cand.ProductID, + ProductName: cand.Name, + Quantity: cand.Quantity, Price: price, - Type: rewardType, + Type: cand.Type, }) } rewardMeta = gin.H{ @@ -324,46 +310,40 @@ func ClaimMyReward(c *gin.Context) { } // Un produit récompense n'est éligible pour ce pool que si sa catégorie - // fait partie des catégories du pool (via CategoryConfigs) — sans ce - // filtre, un client pourrait réclamer n'importe quel produit récompense - // (toutes catégories confondues) avec les points d'un pool quelconque. + // fait partie des catégories du pool — sans ce filtre, un client pourrait + // réclamer n'importe quel produit récompense (toutes catégories + // confondues) avec les points d'un pool quelconque. poolCategories := make(map[string]bool, len(selectedPool.Categories)) for _, cat := range selectedPool.Categories { poolCategories[cat] = true } - rewardProductIDs := make([]int, 0, len(reward.RewardItems)) - for _, item := range reward.RewardItems { - if item.ProductID > 0 { - rewardProductIDs = append(rewardProductIDs, item.ProductID) - } - } - productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs) + candidates, err := resolveCategoryRewardCandidates(database, reward) if err != nil { - utils.ServerErr(c, "Erreur lecture catégories produits", err) + utils.ServerErr(c, "Erreur résolution produits récompense", err) return } - eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories) - // Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici, // avant toute écriture — si un item ne peut pas être tarifé (produit sans // palier de prix actif), la réclamation entière échoue proprement, avant // même de démarrer la transaction de consommation de points. - eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems)) - for _, item := range reward.RewardItems { - rewardType, ok := eligibleProducts[item.ProductID] - if !ok { + eligibleItems := make([]models.RewardItem, 0, len(candidates)) + for _, cand := range candidates { + if !poolCategories[cand.Category] { continue } - price, err := effectiveRewardPrice(database, item, rewardType) + price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type) if err != nil { log.Printf("❌ [CLAIM] %s: %v", username, err) c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"}) return } - item.Price = price - eligibleItems = append(eligibleItems, item) + eligibleItems = append(eligibleItems, models.RewardItem{ + ProductID: cand.ProductID, + Quantity: cand.Quantity, + Price: price, + }) } itemsToAdd := eligibleItems diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index be603ce4..112e7594 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -15,29 +15,35 @@ type PointsTier struct { } // RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense, -// ainsi que le type de récompense appliqué pour cette catégorie précise. +// le type de récompense appliqué pour cette catégorie précise, et la quantité +// concernée (ex: 1g offert, ou 2g à -50%) — la quantité correspond au palier +// de prix catalogue du produit (voir GetActiveProductPrice), pas une valeur +// libre : ex. "30€ offert = 1g" si le produit a un palier quantity=1 à 30€. type RewardCategoryConfig struct { - Category string `json:"category"` // nom de la catégorie - Type string `json:"type"` // "free_product" (défaut) | "half_price_product" - AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie - ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false + Category string `json:"category"` // nom de la catégorie + Type string `json:"type"` // "free_product" (défaut) | "half_price_product" + AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie + ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false + Quantity float64 `json:"quantity"` // quantité offerte / à -50% pour ce type dans cette catégorie } -// RewardItem représente un produit offert lors d'une récompense, avec sa quantité et son prix associé +// RewardItem représente un produit résolu à ajouter au panier lors d'un +// claim (ProductID + Quantity + Price effectif) — construit dynamiquement à +// partir des CategoryConfigs au moment du claim, plus une liste saisie à part. type RewardItem struct { ProductID int `json:"product_id"` // ID du produit ajouté au panier Quantity float64 `json:"quantity"` // quantité offerte - Price float64 `json:"price"` // valeur indicative affichée au client + Price float64 `json:"price"` // prix effectif facturé (0 si offert, 50% du prix catalogue si -50%) } // PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés. -// Le type de récompense (gratuit ou -50%) n'est plus global : il est défini par catégorie -// dans CategoryConfigs (voir RewardCategoryConfig.Type). +// Le type de récompense (gratuit ou -50%) et la quantité concernée sont +// définis par catégorie dans CategoryConfigs (voir RewardCategoryConfig) — +// les produits éligibles et leur quantité ne sont plus saisis à part. type PointsReward struct { Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20) Description string `json:"description"` // description libre affichée au client - CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type par catégorie - RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim + CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie } // DaySchedule représente les horaires de livraison pour un jour de la semaine diff --git a/backend/gestion/tests/rewards_handler_test.go b/backend/gestion/tests/rewards_handler_test.go index 5fbecc37..3c7ab4fb 100644 --- a/backend/gestion/tests/rewards_handler_test.go +++ b/backend/gestion/tests/rewards_handler_test.go @@ -25,9 +25,9 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R } // configureRewardSettings applique la récompense donnée, avec pool_0 mappé -// sur la catégorie "test" — nécessaire pour que eligibleRewardProducts +// sur la catégorie "test" — nécessaire pour que resolveCategoryRewardCandidates // (qui croise pool.Categories et reward.CategoryConfigs) considère les -// reward_items comme éligibles. +// produits de la catégorie comme éligibles. func configureRewardSettings(t *testing.T, reward *models.PointsReward) { t.Helper() settings := db.DefaultSettings() @@ -39,17 +39,20 @@ func configureRewardSettings(t *testing.T, reward *models.PointsReward) { } // 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 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}}, - RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, + Threshold: 20, + Description: "Un produit offert", + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", AllProducts: true, Quantity: 1}, + }, }) setClientPoolPoints(t, username, "pool_0", 20) @@ -85,9 +88,9 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test } } -// Catégorie configurée en "half_price_product" : le produit récompense doit -// être ajouté au panier à 50% du prix catalogue actif (pas 0€, pas le prix -// indicatif RewardItem.Price saisi par l'admin). +// 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") @@ -95,10 +98,11 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri // 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}}, - RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré + 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) @@ -119,15 +123,60 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri } } +// 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") - rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5) + newTestProduct(t, "RewardHTTPBelow", 5) configureRewardSettings(t, &models.PointsReward{ - Threshold: 20, - CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}}, - RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, + Threshold: 20, + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", AllProducts: true, Quantity: 1}, + }, }) setClientPoolPoints(t, username, "pool_0", 5) @@ -140,22 +189,21 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) { } } -// 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). +// Si un produit configuré par l'admin (via ProductIDs 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, - // ProductIDs explicite (pas AllProducts) : le produit n'existe pas en - // base, donc il n'apparaîtrait jamais dans productCategories et ne - // serait jamais éligible via une correspondance AllProducts. - CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", ProductIDs: []int{999999999}}}, - RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", ProductIDs: []int{999999999}, Quantity: 1}, + }, }) setClientPoolPoints(t, username, "pool_0", 20) @@ -178,10 +226,9 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) // 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 (voir eligibleRewardProducts, qui -// n'impose aucune unicité de catégorie dans CategoryConfigs). Un seul claim +// 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. +// propre type et sa propre quantité. func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "reward_http_dual_type") @@ -194,12 +241,8 @@ func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T Threshold: 20, Description: "Un produit offert + un produit à -50%", CategoryConfigs: []models.RewardCategoryConfig{ - {Category: "test", Type: "free_product", ProductIDs: []int{freeProductID}}, - {Category: "test", Type: "half_price_product", ProductIDs: []int{halfProductID}}, - }, - RewardItems: []models.RewardItem{ - {ProductID: freeProductID, Quantity: 1, Price: 12}, - {ProductID: halfProductID, Quantity: 1, Price: 12}, + {Category: "test", Type: "free_product", ProductIDs: []int{freeProductID}, Quantity: 1}, + {Category: "test", Type: "half_price_product", ProductIDs: []int{halfProductID}, Quantity: 1}, }, }) setClientPoolPoints(t, username, "pool_0", 20) diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts index 6920a774..496adb0d 100644 --- a/frontend-prep/src/api/api.ts +++ b/frontend-prep/src/api/api.ts @@ -2172,7 +2172,7 @@ export type RewardCategoryConfig = { all_products: boolean; product_ids: number[]; product_names: string[]; - amount: number; + quantity: number; }; export type RewardItemConfig = {