chore: build
This commit is contained in:
@@ -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...)
|
||||
candidates, err := resolveCategoryRewardCandidates(database, reward)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [POINTS] Résolution candidats récompense: %v", err)
|
||||
}
|
||||
}
|
||||
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))
|
||||
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,32 +196,29 @@ 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 {
|
||||
for _, cand := range candidates {
|
||||
if !poolCats[cand.Category] {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, item, 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: item.ProductID,
|
||||
ProductName: productNames[item.ProductID],
|
||||
Quantity: item.Quantity,
|
||||
ProductID: cand.ProductID,
|
||||
ProductName: cand.Name,
|
||||
Quantity: cand.Quantity,
|
||||
Price: price,
|
||||
Type: rewardType,
|
||||
Type: cand.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pools = append(pools, PoolInfo{
|
||||
Key: pool.Key,
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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,7 +39,9 @@ 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")
|
||||
@@ -48,8 +50,9 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
|
||||
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}},
|
||||
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")
|
||||
@@ -97,8 +100,9 @@ func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPri
|
||||
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é
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
||||
// 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_below")
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
|
||||
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,
|
||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2172,7 +2172,7 @@ export type RewardCategoryConfig = {
|
||||
all_products: boolean;
|
||||
product_ids: number[];
|
||||
product_names: string[];
|
||||
amount: number;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type RewardItemConfig = {
|
||||
|
||||
Reference in New Issue
Block a user