chore: build
Backend - Build & Lint / build (push) Successful in 33m53s
Frontend Web - Build & Lint / build (push) Successful in 15m41s

This commit is contained in:
Xor290
2026-08-25 21:26:02 +02:00
parent 5ee979fcc3
commit d8073bdc46
4 changed files with 196 additions and 167 deletions
+98 -118
View File
@@ -23,72 +23,75 @@ func normalizeRewardCategoryType(t string) string {
return "free_product" return "free_product"
} }
// eligibleRewardProducts détermine, pour un pool donné, quels product_id de // categoryRewardCandidate représente un produit éligible à la récompense pour
// reward.RewardItems sont éligibles et avec quel type de récompense // une config de catégorie donnée : son type ("free_product" |
// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs) // "half_price_product") et la quantité configurée pour cette catégorie.
// doit faire partie des catégories du pool, soit par whitelist explicite type categoryRewardCandidate struct {
// (ProductIDs) soit par correspondance de catégorie produit (AllProducts). Category string
func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string { Type string
eligible := make(map[int]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 { if reward == nil {
return eligible return candidates, nil
} }
catalogCache := make(map[string][]models.Product)
for _, cfg := range reward.CategoryConfigs { for _, cfg := range reward.CategoryConfigs {
if !poolCategories[cfg.Category] {
continue
}
rewardType := normalizeRewardCategoryType(cfg.Type) rewardType := normalizeRewardCategoryType(cfg.Type)
if cfg.AllProducts { if cfg.AllProducts {
for pid, cat := range productCategories { products, ok := catalogCache[cfg.Category]
if cat == cfg.Category { if !ok {
eligible[pid] = rewardType 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 { 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 // effectiveRewardPrice calcule le prix réellement facturé pour une quantité
// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé // donnée d'un produit récompense, selon le type de sa catégorie : 0€ pour
// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis. // "free_product", 50% du prix catalogue actif (palier ≤ quantity) pour
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
// "half_price_product". Erreur si le prix catalogue est introuvable (produit // "half_price_product". Erreur si le prix catalogue est introuvable (produit
// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas // 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. // ê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" { if rewardType != "half_price_product" {
return 0, nil return 0, nil
} }
catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity) catalogPrice, err := database.GetActiveProductPrice(productID, quantity)
if err != nil { 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 return math.Round(catalogPrice/2*100) / 100, nil
} }
@@ -129,6 +132,7 @@ func GetMyPointsRewards(c *gin.Context) {
AllProducts bool `json:"all_products"` AllProducts bool `json:"all_products"`
ProductIDs []int `json:"product_ids"` ProductIDs []int `json:"product_ids"`
ProductNames []string `json:"product_names"` ProductNames []string `json:"product_names"`
Quantity float64 `json:"quantity"`
} }
type RewardItemResponse struct { type RewardItemResponse struct {
@@ -150,22 +154,10 @@ func GetMyPointsRewards(c *gin.Context) {
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"` EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
} }
// Collecter tous les product_ids nécessaires en un seul passage candidates, err := resolveCategoryRewardCandidates(database, reward)
allProductIDs := make([]int, 0) if err != nil {
if reward != nil { log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
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)
}
}
} }
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs)
pools := make([]PoolInfo, 0, len(settings.PointsPools)) pools := make([]PoolInfo, 0, len(settings.PointsPools))
for _, pool := range settings.PointsPools { for _, pool := range settings.PointsPools {
@@ -191,8 +183,11 @@ func GetMyPointsRewards(c *gin.Context) {
} }
names := make([]string, 0, len(cfg.ProductIDs)) names := make([]string, 0, len(cfg.ProductIDs))
for _, pid := range cfg.ProductIDs { for _, pid := range cfg.ProductIDs {
if n, ok := productNames[pid]; ok { for _, cand := range candidates {
names = append(names, n) if cand.ProductID == pid && cand.Category == cfg.Category {
names = append(names, cand.Name)
break
}
} }
} }
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{ eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
@@ -201,31 +196,28 @@ func GetMyPointsRewards(c *gin.Context) {
AllProducts: cfg.AllProducts, AllProducts: cfg.AllProducts,
ProductIDs: cfg.ProductIDs, ProductIDs: cfg.ProductIDs,
ProductNames: names, ProductNames: names,
Quantity: cfg.Quantity,
}) })
} }
} }
eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories)
eligibleRewardItems := make([]RewardItemResponse, 0) eligibleRewardItems := make([]RewardItemResponse, 0)
if reward != nil { for _, cand := range candidates {
for _, item := range reward.RewardItems { if !poolCats[cand.Category] {
rewardType, ok := eligibleProducts[item.ProductID] continue
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,
})
} }
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{ 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 // Aperçu global des produits récompense, indépendant d'un pool précis — le
// global, indépendant d'un pool précis — le type/prix effectif par pool // type/prix effectif par pool est celui exposé dans pools[].eligible_reward_items.
// est celui exposé dans pools[].eligible_reward_items).
var rewardMeta gin.H var rewardMeta gin.H
if reward != nil { if reward != nil {
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems)) rewardItems := make([]RewardItemResponse, 0, len(candidates))
for _, item := range reward.RewardItems { for _, cand := range candidates {
if item.ProductID <= 0 { price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil {
continue 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{ rewardItems = append(rewardItems, RewardItemResponse{
ProductID: item.ProductID, ProductID: cand.ProductID,
ProductName: name, ProductName: cand.Name,
Quantity: item.Quantity, Quantity: cand.Quantity,
Price: price, Price: price,
Type: rewardType, Type: cand.Type,
}) })
} }
rewardMeta = gin.H{ 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 // 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 // fait partie des catégories du pool — sans ce filtre, un client pourrait
// filtre, un client pourrait réclamer n'importe quel produit récompense // réclamer n'importe quel produit récompense (toutes catégories
// (toutes catégories confondues) avec les points d'un pool quelconque. // confondues) avec les points d'un pool quelconque.
poolCategories := make(map[string]bool, len(selectedPool.Categories)) poolCategories := make(map[string]bool, len(selectedPool.Categories))
for _, cat := range selectedPool.Categories { for _, cat := range selectedPool.Categories {
poolCategories[cat] = true poolCategories[cat] = true
} }
rewardProductIDs := make([]int, 0, len(reward.RewardItems)) candidates, err := resolveCategoryRewardCandidates(database, reward)
for _, item := range reward.RewardItems {
if item.ProductID > 0 {
rewardProductIDs = append(rewardProductIDs, item.ProductID)
}
}
productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs)
if err != nil { if err != nil {
utils.ServerErr(c, "Erreur lecture catégories produits", err) utils.ServerErr(c, "Erreur résolution produits récompense", err)
return return
} }
eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories)
// Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici, // 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 // 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 // palier de prix actif), la réclamation entière échoue proprement, avant
// même de démarrer la transaction de consommation de points. // même de démarrer la transaction de consommation de points.
eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems)) eligibleItems := make([]models.RewardItem, 0, len(candidates))
for _, item := range reward.RewardItems { for _, cand := range candidates {
rewardType, ok := eligibleProducts[item.ProductID] if !poolCategories[cand.Category] {
if !ok {
continue continue
} }
price, err := effectiveRewardPrice(database, item, rewardType) price, err := effectiveRewardPrice(database, cand.ProductID, cand.Quantity, cand.Type)
if err != nil { if err != nil {
log.Printf("❌ [CLAIM] %s: %v", username, err) log.Printf("❌ [CLAIM] %s: %v", username, err)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"}) c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return return
} }
item.Price = price eligibleItems = append(eligibleItems, models.RewardItem{
eligibleItems = append(eligibleItems, item) ProductID: cand.ProductID,
Quantity: cand.Quantity,
Price: price,
})
} }
itemsToAdd := eligibleItems itemsToAdd := eligibleItems
+17 -11
View File
@@ -15,29 +15,35 @@ type PointsTier struct {
} }
// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense, // 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 { type RewardCategoryConfig struct {
Category string `json:"category"` // nom de la catégorie Category string `json:"category"` // nom de la catégorie
Type string `json:"type"` // "free_product" (défaut) | "half_price_product" Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie 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 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 { type RewardItem struct {
ProductID int `json:"product_id"` // ID du produit ajouté au panier ProductID int `json:"product_id"` // ID du produit ajouté au panier
Quantity float64 `json:"quantity"` // quantité offerte 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. // 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 // Le type de récompense (gratuit ou -50%) et la quantité concernée sont
// dans CategoryConfigs (voir RewardCategoryConfig.Type). // définis par catégorie dans CategoryConfigs (voir RewardCategoryConfig) —
// les produits éligibles et leur quantité ne sont plus saisis à part.
type PointsReward struct { type PointsReward struct {
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20) Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
Description string `json:"description"` // description libre affichée au client Description string `json:"description"` // description libre affichée au client
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type par catégorie CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type + quantité par catégorie
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
} }
// DaySchedule représente les horaires de livraison pour un jour de la semaine // DaySchedule représente les horaires de livraison pour un jour de la semaine
+80 -37
View File
@@ -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é // 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 // (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) { func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
t.Helper() t.Helper()
settings := db.DefaultSettings() 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 // 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) { func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) {
cleanupStockTestData(t) cleanupStockTestData(t)
username := newTestClient(t, "reward_http_flow") username := newTestClient(t, "reward_http_flow")
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5) rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
configureRewardSettings(t, &models.PointsReward{ configureRewardSettings(t, &models.PointsReward{
Threshold: 20, Threshold: 20,
Description: "Un produit offert", Description: "Un produit offert",
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}}, CategoryConfigs: []models.RewardCategoryConfig{
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, {Category: "test", Type: "free_product", AllProducts: true, Quantity: 1},
},
}) })
setClientPoolPoints(t, username, "pool_0", 20) 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 // Catégorie configurée en "half_price_product" avec quantité=1 : le produit
// être ajouté au panier à 50% du prix catalogue actif (pas 0€, pas le prix // récompense doit être ajouté au panier à 50% du prix catalogue actif pour
// indicatif RewardItem.Price saisi par l'admin). // cette quantité (palier ≤ 1), pas 0€.
func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) { func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) {
cleanupStockTestData(t) cleanupStockTestData(t)
username := newTestClient(t, "reward_http_halfprice") 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). // newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go).
configureRewardSettings(t, &models.PointsReward{ configureRewardSettings(t, &models.PointsReward{
Threshold: 20, Threshold: 20,
Description: "Un produit à moitié prix", Description: "Un produit à moitié prix",
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "half_price_product", AllProducts: true}}, CategoryConfigs: []models.RewardCategoryConfig{
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré {Category: "test", Type: "half_price_product", AllProducts: true, Quantity: 1},
},
}) })
setClientPoolPoints(t, username, "pool_0", 20) 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) { func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
cleanupStockTestData(t) cleanupStockTestData(t)
username := newTestClient(t, "reward_http_below") username := newTestClient(t, "reward_http_below")
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5) newTestProduct(t, "RewardHTTPBelow", 5)
configureRewardSettings(t, &models.PointsReward{ configureRewardSettings(t, &models.PointsReward{
Threshold: 20, Threshold: 20,
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}}, CategoryConfigs: []models.RewardCategoryConfig{
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, {Category: "test", Type: "free_product", AllProducts: true, Quantity: 1},
},
}) })
setClientPoolPoints(t, username, "pool_0", 5) 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 // Si un produit configuré par l'admin (via ProductIDs explicite) pointe vers
// supprimé/inexistant, la réclamation entière doit échouer — la récompense // un produit supprimé/inexistant, la réclamation entière doit échouer — la
// ne doit pas être consommée sans qu'aucun produit ne soit livré au client // récompense ne doit pas être consommée sans qu'aucun produit ne soit livré
// (ClaimPoolReward + AddRewardsToBasket sont maintenant dans la même // au client (ClaimPoolReward + AddRewardsToBasket sont dans la même
// transaction via ClaimPoolRewardAndAddToBasket). // transaction via ClaimPoolRewardAndAddToBasket ; la contrainte de clé
// étrangère sur baskets.product_id fait échouer l'insertion).
func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) { func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) {
cleanupStockTestData(t) cleanupStockTestData(t)
username := newTestClient(t, "reward_http_missing_product") username := newTestClient(t, "reward_http_missing_product")
configureRewardSettings(t, &models.PointsReward{ configureRewardSettings(t, &models.PointsReward{
Threshold: 20, Threshold: 20,
// ProductIDs explicite (pas AllProducts) : le produit n'existe pas en CategoryConfigs: []models.RewardCategoryConfig{
// base, donc il n'apparaîtrait jamais dans productCategories et ne {Category: "test", Type: "free_product", ProductIDs: []int{999999999}, Quantity: 1},
// 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
}) })
setClientPoolPoints(t, username, "pool_0", 20) 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 // 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 // parallèle (un lot de produits offerts + un lot de produits à -50%), chacun
// avec sa propre sélection de produits (voir eligibleRewardProducts, qui // avec sa propre sélection de produits et sa propre quantité. Un seul claim
// n'impose aucune unicité de catégorie dans CategoryConfigs). Un seul claim
// doit alors ajouter les deux produits au panier, chacun tarifé selon son // 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) { func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T) {
cleanupStockTestData(t) cleanupStockTestData(t)
username := newTestClient(t, "reward_http_dual_type") username := newTestClient(t, "reward_http_dual_type")
@@ -194,12 +241,8 @@ func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T
Threshold: 20, Threshold: 20,
Description: "Un produit offert + un produit à -50%", Description: "Un produit offert + un produit à -50%",
CategoryConfigs: []models.RewardCategoryConfig{ CategoryConfigs: []models.RewardCategoryConfig{
{Category: "test", Type: "free_product", ProductIDs: []int{freeProductID}}, {Category: "test", Type: "free_product", ProductIDs: []int{freeProductID}, Quantity: 1},
{Category: "test", Type: "half_price_product", ProductIDs: []int{halfProductID}}, {Category: "test", Type: "half_price_product", ProductIDs: []int{halfProductID}, Quantity: 1},
},
RewardItems: []models.RewardItem{
{ProductID: freeProductID, Quantity: 1, Price: 12},
{ProductID: halfProductID, Quantity: 1, Price: 12},
}, },
}) })
setClientPoolPoints(t, username, "pool_0", 20) setClientPoolPoints(t, username, "pool_0", 20)
+1 -1
View File
@@ -2172,7 +2172,7 @@ export type RewardCategoryConfig = {
all_products: boolean; all_products: boolean;
product_ids: number[]; product_ids: number[];
product_names: string[]; product_names: string[];
amount: number; quantity: number;
}; };
export type RewardItemConfig = { export type RewardItemConfig = {