chore: build
This commit is contained in:
@@ -7,6 +7,25 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetActiveProductPrice retourne le prix catalogue actif pour un produit et
|
||||
// une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que
|
||||
// AddToBasket) — utilisé pour calculer le prix effectif d'une récompense
|
||||
// "half_price_product" (50% de ce prix).
|
||||
func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity <= ? AND active_price = true
|
||||
ORDER BY quantity DESC LIMIT 1`,
|
||||
productID, quantity).Scan(&result).Error
|
||||
if err != nil || result.Price == 0 {
|
||||
return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
@@ -42,7 +61,9 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (prix = 0, is_reward = true).
|
||||
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true),
|
||||
// au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert,
|
||||
// ou le prix effectif déjà calculé pour une remise — voir handlers/points.go).
|
||||
// Supprime les anciens items récompense avant d'insérer les nouveaux.
|
||||
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
|
||||
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||
@@ -78,9 +99,9 @@ func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardIte
|
||||
var basket models.Panier
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
|
||||
VALUES (?, ?, ?, ?, true, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
||||
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
|
||||
username, item.ProductID, item.Quantity, item.Price, poolKey).Scan(&basket).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baskets = append(baskets, basket)
|
||||
|
||||
@@ -155,9 +155,10 @@ func GetDeliveryDetails(c *gin.Context) {
|
||||
itemsSummary := make([]gin.H, len(items))
|
||||
for i, item := range items {
|
||||
itemsSummary[i] = gin.H{
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"produit": item["produit"],
|
||||
"quantite": item["quantite"],
|
||||
"prix": item["prix"],
|
||||
"is_reward": item["is_reward"],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// eligibleRewardProductIDs détermine, pour un pool donné, quels product_id de
|
||||
// reward.RewardItems sont éligibles : 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 eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]bool {
|
||||
eligible := make(map[int]bool)
|
||||
// normalizeRewardCategoryType retombe sur "free_product" pour toute valeur
|
||||
// vide ou inconnue — rétrocompatibilité avec les configurations enregistrées
|
||||
// avant l'introduction du type par catégorie (RewardCategoryConfig.Type).
|
||||
func normalizeRewardCategoryType(t string) string {
|
||||
if t == "half_price_product" {
|
||||
return "half_price_product"
|
||||
}
|
||||
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)
|
||||
if reward == nil {
|
||||
return eligible
|
||||
}
|
||||
@@ -24,21 +37,62 @@ func eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[st
|
||||
if !poolCategories[cfg.Category] {
|
||||
continue
|
||||
}
|
||||
rewardType := normalizeRewardCategoryType(cfg.Type)
|
||||
if cfg.AllProducts {
|
||||
for pid, cat := range productCategories {
|
||||
if cat == cfg.Category {
|
||||
eligible[pid] = true
|
||||
eligible[pid] = rewardType
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, pid := range cfg.ProductIDs {
|
||||
eligible[pid] = true
|
||||
eligible[pid] = rewardType
|
||||
}
|
||||
}
|
||||
}
|
||||
return eligible
|
||||
}
|
||||
|
||||
// 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
|
||||
// "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) {
|
||||
if rewardType != "half_price_product" {
|
||||
return 0, nil
|
||||
}
|
||||
catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err)
|
||||
}
|
||||
return math.Round(catalogPrice/2*100) / 100, nil
|
||||
}
|
||||
|
||||
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
|
||||
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
|
||||
func GetMyPointsRewards(c *gin.Context) {
|
||||
@@ -71,6 +125,7 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
|
||||
type EligibleConfigResponse struct {
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
AllProducts bool `json:"all_products"`
|
||||
ProductIDs []int `json:"product_ids"`
|
||||
ProductNames []string `json:"product_names"`
|
||||
@@ -81,6 +136,7 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type PoolInfo struct {
|
||||
@@ -141,6 +197,7 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
}
|
||||
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
|
||||
Category: cfg.Category,
|
||||
Type: normalizeRewardCategoryType(cfg.Type),
|
||||
AllProducts: cfg.AllProducts,
|
||||
ProductIDs: cfg.ProductIDs,
|
||||
ProductNames: names,
|
||||
@@ -148,18 +205,25 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
eligibleProductIDs := eligibleRewardProductIDs(reward, poolCats, productCategories)
|
||||
eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories)
|
||||
eligibleRewardItems := make([]RewardItemResponse, 0)
|
||||
if reward != nil {
|
||||
for _, item := range reward.RewardItems {
|
||||
if !eligibleProductIDs[item.ProductID] {
|
||||
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: item.Price,
|
||||
Price: price,
|
||||
Type: rewardType,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -176,7 +240,9 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// Construire la liste des produits récompense avec leurs noms
|
||||
// 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).
|
||||
var rewardMeta gin.H
|
||||
if reward != nil {
|
||||
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
|
||||
@@ -184,17 +250,22 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
if item.ProductID <= 0 {
|
||||
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,
|
||||
Price: item.Price,
|
||||
Price: price,
|
||||
Type: rewardType,
|
||||
})
|
||||
}
|
||||
rewardMeta = gin.H{
|
||||
"threshold": reward.Threshold,
|
||||
"type": reward.Type,
|
||||
"description": reward.Description,
|
||||
"reward_items": rewardItems,
|
||||
}
|
||||
@@ -273,13 +344,26 @@ func ClaimMyReward(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
eligibleProductIDs := eligibleRewardProductIDs(reward, poolCategories, productCategories)
|
||||
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 {
|
||||
if eligibleProductIDs[item.ProductID] {
|
||||
eligibleItems = append(eligibleItems, item)
|
||||
rewardType, ok := eligibleProducts[item.ProductID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
price, err := effectiveRewardPrice(database, item, rewardType)
|
||||
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)
|
||||
}
|
||||
|
||||
itemsToAdd := eligibleItems
|
||||
|
||||
@@ -19,12 +19,19 @@ type Product struct {
|
||||
func (Product) TableName() string { return "products" }
|
||||
|
||||
type ProductPrice struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ProductID int `json:"product_id" gorm:"column:product_id;index"`
|
||||
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
||||
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
ActivePrice bool `json:"active_price" gorm:"column:active_price;default:true"`
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ProductID int `json:"product_id" gorm:"column:product_id;index"`
|
||||
Quantity float64 `json:"quantity" gorm:"column:quantity" binding:"required"`
|
||||
Price float64 `json:"price" gorm:"column:price" binding:"required"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
// Pas de tag gorm "default:true" ici : GORM omet de l'INSERT tout champ
|
||||
// dont la valeur Go est la valeur zéro (false) s'il porte un tag
|
||||
// "default", laissant Postgres appliquer sa propre valeur par défaut
|
||||
// (TRUE) à la place — un prix explicitement désactivé (false) revenait
|
||||
// donc toujours actif après un Create(). La colonne a déjà son défaut
|
||||
// TRUE posé au niveau SQL (db_init.go), ce tag Go était redondant et
|
||||
// seulement source du bug.
|
||||
ActivePrice bool `json:"active_price" gorm:"column:active_price"`
|
||||
}
|
||||
|
||||
func (ProductPrice) TableName() string { return "product_prices" }
|
||||
|
||||
@@ -14,9 +14,11 @@ type PointsTier struct {
|
||||
Points int `json:"points"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
@@ -28,12 +30,13 @@ type RewardItem struct {
|
||||
Price float64 `json:"price"` // valeur indicative affichée au client
|
||||
}
|
||||
|
||||
// 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
|
||||
// dans CategoryConfigs (voir RewardCategoryConfig.Type).
|
||||
type PointsReward struct {
|
||||
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
|
||||
Type string `json:"type"` // "free_product" | "half_price_product" | "custom"
|
||||
Description string `json:"description"` // description libre affichée au client
|
||||
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles
|
||||
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
|
||||
}
|
||||
|
||||
@@ -99,8 +102,8 @@ type AppSettings struct {
|
||||
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
|
||||
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
|
||||
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
|
||||
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
|
||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||
TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
|
||||
TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
|
||||
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
|
||||
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
|
||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func deliveryDetailsContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/livreur/deliveries/%d", commandID), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", username)
|
||||
c.Set("role", "livreur")
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// GetDeliveryDetails doit exposer is_reward par item, au même titre que
|
||||
// GetMyDeliveries (la liste) — sans quoi le modal "détails" côté livreur ne
|
||||
// peut pas signaler un article récompense (gratuit ou -50%), ni afficher son
|
||||
// prix effectif correctement.
|
||||
func TestGetDeliveryDetails_ExposesIsRewardPerItem(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
client := newTestClient(t, "delivdetails_client")
|
||||
livreur := newTestClient(t, "delivdetails_livreur")
|
||||
paidProductID := newTestProduct(t, "DelivDetailsPaid", 20)
|
||||
rewardProductID := newTestProduct(t, "DelivDetailsReward", 5)
|
||||
|
||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, paidProductID, 1, 10)
|
||||
insertRewardCommandItem(t, cmdID, rewardProductID, 1, 5, "pool_0")
|
||||
|
||||
c, rec := deliveryDetailsContext(livreur, cmdID)
|
||||
handlers.GetDeliveryDetails(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"`
|
||||
Delivery struct {
|
||||
Items []struct {
|
||||
Produit string `json:"produit"`
|
||||
IsReward bool `json:"is_reward"`
|
||||
} `json:"items"`
|
||||
} `json:"delivery"`
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("réponse non successful: body=%s", rec.Body.String())
|
||||
}
|
||||
if len(resp.Delivery.Items) != 2 {
|
||||
t.Fatalf("nombre d'items: got=%d want=2", len(resp.Delivery.Items))
|
||||
}
|
||||
|
||||
var sawReward, sawPaid bool
|
||||
for _, it := range resp.Delivery.Items {
|
||||
if it.IsReward {
|
||||
sawReward = true
|
||||
} else {
|
||||
sawPaid = true
|
||||
}
|
||||
}
|
||||
if !sawReward {
|
||||
t.Errorf("l'item récompense doit avoir is_reward=true dans la réponse: %+v", resp.Delivery.Items)
|
||||
}
|
||||
if !sawPaid {
|
||||
t.Errorf("l'item payant doit avoir is_reward=false dans la réponse: %+v", resp.Delivery.Items)
|
||||
}
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoi
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 3}}},
|
||||
})
|
||||
setPointsRewardSettings(t, models.PointsReward{Threshold: 20, Type: "free_product"})
|
||||
setPointsRewardSettings(t, models.PointsReward{Threshold: 20})
|
||||
setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande
|
||||
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10)
|
||||
|
||||
@@ -24,9 +24,14 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// configureRewardSettings applique la récompense donnée, avec pool_0 mappé
|
||||
// sur la catégorie "test" — nécessaire pour que eligibleRewardProducts
|
||||
// (qui croise pool.Categories et reward.CategoryConfigs) considère les
|
||||
// reward_items 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)
|
||||
@@ -41,10 +46,10 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Type: "free_product",
|
||||
Description: "Un produit offert",
|
||||
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}},
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
@@ -75,6 +80,43 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
|
||||
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" : 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).
|
||||
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}},
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
||||
@@ -83,9 +125,9 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Type: "free_product",
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
Threshold: 20,
|
||||
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 5)
|
||||
|
||||
@@ -108,9 +150,12 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
|
||||
username := newTestClient(t, "reward_http_missing_product")
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Type: "free_product",
|
||||
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
|
||||
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
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
|
||||
@@ -206,7 +206,12 @@ func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.
|
||||
|
||||
// ── AddRewardsToBasket : flags et remplacement ──────────────────────────────
|
||||
|
||||
func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
|
||||
// AddRewardsToBasket ne recalcule plus le prix : elle stocke tel quel le
|
||||
// RewardItem.Price fourni par l'appelant (0 pour "free_product", prix -50%
|
||||
// déjà résolu par handlers/points.go pour "half_price_product") — voir
|
||||
// TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice
|
||||
// pour le flux complet qui résout ce prix par type de catégorie.
|
||||
func TestAddRewardsToBasket_SetsRewardFlagsAndStoresGivenPrice(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_basket_flags")
|
||||
productID := newTestProduct(t, "RewardBasketFlags", 20)
|
||||
@@ -231,8 +236,8 @@ func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
|
||||
if row.RewardPoolKey != "pool_0" {
|
||||
t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0")
|
||||
}
|
||||
if row.Price != 0 {
|
||||
t.Errorf("prix affiché doit être 0 (gratuit): got=%.2f", row.Price)
|
||||
if row.Price != 15.0 {
|
||||
t.Errorf("le prix fourni par l'appelant doit être stocké tel quel: got=%.2f want=15.00", row.Price)
|
||||
}
|
||||
if row.Quantity != 2 {
|
||||
t.Errorf("quantité: got=%.2f want=2", row.Quantity)
|
||||
|
||||
@@ -278,6 +278,55 @@ func TestUpdateProduct_UpdatesStockWhenProvided(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// productPriceActiveFlags relit active_price par palier de quantité pour un
|
||||
// produit, directement en base.
|
||||
func productPriceActiveFlags(t *testing.T, productID int) map[float64]bool {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
ActivePrice bool `gorm:"column:active_price"`
|
||||
}
|
||||
if err := testDB.GDB.Raw(
|
||||
`SELECT quantity, active_price FROM product_prices WHERE product_id = ?`, productID,
|
||||
).Scan(&rows).Error; err != nil {
|
||||
t.Fatalf("lecture active_price: %v", err)
|
||||
}
|
||||
out := make(map[float64]bool, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.Quantity] = r.ActivePrice
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Un prix explicitement désactivé (active_price=false) doit rester désactivé
|
||||
// après UpdateProduct — piège classique de GORM : un champ bool à sa valeur
|
||||
// zéro (false) avec un tag gorm "default" est omis de l'INSERT, laissant la
|
||||
// base appliquer son propre défaut (TRUE) à la place. Voir le commentaire sur
|
||||
// ProductPrice.ActivePrice dans models/product.go.
|
||||
func TestUpdateProduct_PersistsInactivePriceFlag(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
cat := newTestCategory(t, "CatInactive")
|
||||
productID := newTestProduct(t, "UPInactive", 5)
|
||||
|
||||
body := []byte(fmt.Sprintf(
|
||||
`{"name":"UPInactive","category":%q,"description":"d2","unit":"g","stock":10,"prices":[{"quantity":1,"price":5,"active_price":false},{"quantity":5,"price":20,"active_price":true}]}`,
|
||||
cat,
|
||||
))
|
||||
c, rec := updateProductJSONContext("admin", body, productID)
|
||||
handlers.UpdateProduct(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateProduct valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
flags := productPriceActiveFlags(t, productID)
|
||||
if flags[1] != false {
|
||||
t.Errorf("palier qty=1 doit rester désactivé après UpdateProduct: got active_price=%v", flags[1])
|
||||
}
|
||||
if flags[5] != true {
|
||||
t.Errorf("palier qty=5 doit rester actif après UpdateProduct: got active_price=%v", flags[5])
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteProduct ────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteProduct_RemovesProductWithoutMedia(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user