417 lines
15 KiB
Go
417 lines
15 KiB
Go
package tests
|
|
|
|
import (
|
|
"gestion/models"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// setClientPoolPoints fixe directement les points cumulés d'un client pour un
|
|
// pool donné (contourne le flux normal d'accumulation pour tester isolément
|
|
// la réclamation de récompense).
|
|
func setClientPoolPoints(t *testing.T, username, poolKey string, points int) {
|
|
t.Helper()
|
|
if err := testDB.GDB.Exec(
|
|
`UPDATE clients SET points_extra = jsonb_set(COALESCE(points_extra, '{}'::jsonb), ARRAY[?], to_jsonb(?::int)) WHERE username = ?`,
|
|
poolKey, points, username,
|
|
).Error; err != nil {
|
|
t.Fatalf("setClientPoolPoints: %v", err)
|
|
}
|
|
}
|
|
|
|
type rewardBasketRow struct {
|
|
ProductID int `gorm:"column:product_id"`
|
|
Quantity float64 `gorm:"column:quantity"`
|
|
Price float64 `gorm:"column:price"`
|
|
IsReward bool `gorm:"column:is_reward"`
|
|
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
|
}
|
|
|
|
func basketRewardItems(t *testing.T, username string) []rewardBasketRow {
|
|
t.Helper()
|
|
var rows []rewardBasketRow
|
|
if err := testDB.GDB.Raw(
|
|
`SELECT product_id, quantity, price, is_reward, reward_pool_key
|
|
FROM baskets WHERE username = ? AND is_reward = true`, username,
|
|
).Scan(&rows).Error; err != nil {
|
|
t.Fatalf("lecture panier récompense: %v", err)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// ── ClaimPoolReward : seuil, atomicité, épuisement ──────────────────────────
|
|
|
|
func TestClaimPoolReward_BelowThresholdFails(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_below_threshold")
|
|
setClientPoolPoints(t, username, "pool_0", 19)
|
|
|
|
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
|
|
t.Fatal("attendu une erreur : 19 points < seuil 20")
|
|
} else if !strings.Contains(err.Error(), "pas de récompense disponible") {
|
|
t.Errorf("message d'erreur inattendu: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClaimPoolReward_ExactlyAtThresholdSucceeds(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_exact_threshold")
|
|
setClientPoolPoints(t, username, "pool_0", 20)
|
|
|
|
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
|
if err != nil {
|
|
t.Fatalf("ClaimPoolReward: %v", err)
|
|
}
|
|
if remaining != 0 {
|
|
t.Errorf("remaining: got=%d want=0 (1 récompense gagnée, 1 réclamée)", remaining)
|
|
}
|
|
}
|
|
|
|
func TestClaimPoolReward_MultipleRewardsEarned(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_multiple")
|
|
setClientPoolPoints(t, username, "pool_0", 45) // 45/20 = 2 récompenses gagnées
|
|
|
|
remaining1, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
|
if err != nil {
|
|
t.Fatalf("1er claim: %v", err)
|
|
}
|
|
if remaining1 != 1 {
|
|
t.Errorf("après 1er claim: got=%d want=1", remaining1)
|
|
}
|
|
|
|
remaining2, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
|
if err != nil {
|
|
t.Fatalf("2e claim: %v", err)
|
|
}
|
|
if remaining2 != 0 {
|
|
t.Errorf("après 2e claim: got=%d want=0", remaining2)
|
|
}
|
|
|
|
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
|
|
t.Fatal("3e claim: attendu une erreur (récompenses épuisées)")
|
|
}
|
|
}
|
|
|
|
// Trois réclamations concurrentes pour un client n'ayant droit qu'à UNE seule
|
|
// récompense ne doivent en laisser passer qu'une seule (verrou FOR UPDATE).
|
|
func TestClaimPoolReward_ConcurrentClaimsDoNotOverclaim(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_concurrent")
|
|
setClientPoolPoints(t, username, "pool_0", 20) // 1 seule récompense disponible
|
|
|
|
var wg sync.WaitGroup
|
|
n := 3
|
|
errs := make([]error, n)
|
|
for i := range n {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
_, errs[idx] = testDB.ClaimPoolReward(username, "pool_0", 20)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
successCount := 0
|
|
for _, err := range errs {
|
|
if err == nil {
|
|
successCount++
|
|
}
|
|
}
|
|
if successCount != 1 {
|
|
t.Errorf("un seul claim concurrent doit réussir: got=%d succès", successCount)
|
|
}
|
|
|
|
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
|
if err != nil {
|
|
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
|
}
|
|
if redeemed["pool_0"] != 1 {
|
|
t.Errorf("compteur redeemed après claims concurrents: got=%d want=1", redeemed["pool_0"])
|
|
}
|
|
}
|
|
|
|
// Les pools sont indépendants : les points d'un pool ne doivent pas permettre
|
|
// de réclamer une récompense sur un autre pool.
|
|
func TestClaimPoolReward_PoolsAreIndependent(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_pool_isolation")
|
|
setClientPoolPoints(t, username, "pool_0", 20)
|
|
// pool_1 n'a aucun point.
|
|
|
|
if _, err := testDB.ClaimPoolReward(username, "pool_1", 20); err == nil {
|
|
t.Fatal("attendu une erreur : aucun point sur pool_1")
|
|
}
|
|
if remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
|
|
t.Errorf("pool_0 devrait rester réclamable: %v", err)
|
|
} else if remaining != 0 {
|
|
t.Errorf("remaining pool_0: got=%d want=0", remaining)
|
|
}
|
|
}
|
|
|
|
// ── ClaimPoolRewardAndAddToBasket : atomicité réclamation + livraison ───────
|
|
|
|
func TestClaimPoolRewardAndAddToBasket_SucceedsAndDecrementsAvailable(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_combined_ok")
|
|
productID := newTestProduct(t, "RewardCombinedOk", 5)
|
|
setClientPoolPoints(t, username, "pool_0", 20)
|
|
|
|
remaining, added, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
|
|
[]models.RewardItem{{ProductID: productID, Quantity: 1, Price: 10}})
|
|
if err != nil {
|
|
t.Fatalf("ClaimPoolRewardAndAddToBasket: %v", err)
|
|
}
|
|
if remaining != 0 {
|
|
t.Errorf("remaining: got=%d want=0", remaining)
|
|
}
|
|
if len(added) != 1 {
|
|
t.Fatalf("articles ajoutés: got=%d want=1", len(added))
|
|
}
|
|
|
|
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
|
if err != nil {
|
|
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
|
}
|
|
if redeemed["pool_0"] != 1 {
|
|
t.Errorf("redeemed: got=%d want=1", redeemed["pool_0"])
|
|
}
|
|
}
|
|
|
|
// Si le produit récompense est introuvable, ni la récompense ni le panier ne
|
|
// doivent être modifiés (rollback complet de la transaction combinée).
|
|
func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_combined_rollback")
|
|
setClientPoolPoints(t, username, "pool_0", 20)
|
|
|
|
_, _, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
|
|
[]models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}})
|
|
if err == nil {
|
|
t.Fatal("attendu une erreur pour un produit récompense inexistant")
|
|
}
|
|
|
|
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
|
if err != nil {
|
|
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
|
}
|
|
if redeemed["pool_0"] != 0 {
|
|
t.Errorf("la récompense ne doit pas être consommée si l'ajout au panier échoue: got redeemed=%d want=0", redeemed["pool_0"])
|
|
}
|
|
if rows := basketRewardItems(t, username); len(rows) != 0 {
|
|
t.Errorf("aucun article récompense ne doit rester en panier: got=%d", len(rows))
|
|
}
|
|
}
|
|
|
|
// ── AddRewardsToBasket : flags et remplacement ──────────────────────────────
|
|
|
|
// 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)
|
|
|
|
items := []models.RewardItem{{ProductID: productID, Quantity: 2, Price: 15.0}}
|
|
added, err := testDB.AddRewardsToBasket(username, items, "pool_0")
|
|
if err != nil {
|
|
t.Fatalf("AddRewardsToBasket: %v", err)
|
|
}
|
|
if len(added) != 1 {
|
|
t.Fatalf("nombre d'articles ajoutés: got=%d want=1", len(added))
|
|
}
|
|
|
|
rows := basketRewardItems(t, username)
|
|
if len(rows) != 1 {
|
|
t.Fatalf("articles récompense en base: got=%d want=1", len(rows))
|
|
}
|
|
row := rows[0]
|
|
if !row.IsReward {
|
|
t.Error("is_reward doit être true")
|
|
}
|
|
if row.RewardPoolKey != "pool_0" {
|
|
t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0")
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Réclamer une nouvelle récompense doit remplacer les articles récompense
|
|
// précédents, pas les cumuler (évite d'accumuler indéfiniment des articles
|
|
// gratuits si le client reclique plusieurs fois).
|
|
func TestAddRewardsToBasket_ReplacesPreviousRewardItems(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_basket_replace")
|
|
productA := newTestProduct(t, "RewardReplaceA", 20)
|
|
productB := newTestProduct(t, "RewardReplaceB", 20)
|
|
|
|
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productA, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
|
|
t.Fatalf("1er AddRewardsToBasket: %v", err)
|
|
}
|
|
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productB, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
|
|
t.Fatalf("2e AddRewardsToBasket: %v", err)
|
|
}
|
|
|
|
rows := basketRewardItems(t, username)
|
|
if len(rows) != 1 {
|
|
t.Fatalf("un seul article récompense doit rester après remplacement: got=%d", len(rows))
|
|
}
|
|
if rows[0].ProductID != productB {
|
|
t.Errorf("l'article récompense restant doit être le dernier réclamé: got=%d want=%d", rows[0].ProductID, productB)
|
|
}
|
|
}
|
|
|
|
// Un article récompense pointant vers un produit inexistant doit faire
|
|
// échouer l'ajout, sans rien insérer du tout (transaction).
|
|
func TestAddRewardsToBasket_FailsOnUnknownProduct(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_basket_unknown")
|
|
|
|
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}}, "pool_0"); err == nil {
|
|
t.Fatal("attendu une erreur pour un produit inexistant")
|
|
}
|
|
|
|
rows := basketRewardItems(t, username)
|
|
if len(rows) != 0 {
|
|
t.Errorf("aucun article ne doit être ajouté si le produit est introuvable: got=%d", len(rows))
|
|
}
|
|
}
|
|
|
|
// ── Chaîne complète : réclamation -> panier -> checkout -> stock ────────────
|
|
|
|
// C'est le scénario demandé explicitement : vérifier que le stock est bien
|
|
// déduit pour un article obtenu par récompense, exactement comme un article
|
|
// payant (règle métier explicite : les récompenses ne sont jamais exclues du
|
|
// décompte de stock).
|
|
func TestRewardClaim_FullChain_DecrementsStockAtCheckout(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_full_chain")
|
|
rewardProductID := newTestProduct(t, "RewardFullChainFree", 5)
|
|
paidProductID := newTestProduct(t, "RewardFullChainPaid", 10)
|
|
|
|
setClientPoolPoints(t, username, "pool_0", 20)
|
|
|
|
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
|
if err != nil {
|
|
t.Fatalf("ClaimPoolReward: %v", err)
|
|
}
|
|
if remaining != 0 {
|
|
t.Errorf("remaining: got=%d want=0", remaining)
|
|
}
|
|
|
|
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
|
|
t.Fatalf("AddRewardsToBasket: %v", err)
|
|
}
|
|
if _, err := testDB.AddToBasket(username, paidProductID, 3); err != nil {
|
|
t.Fatalf("AddToBasket (article payant): %v", err)
|
|
}
|
|
|
|
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
|
|
t.Fatalf("CreateCommandWithAddress: %v", err)
|
|
}
|
|
|
|
if got := productStock(t, rewardProductID); got != 3 {
|
|
t.Errorf("stock article récompense après checkout (5 initial - 2 offerts): got=%.2f want=3", got)
|
|
}
|
|
if got := productStock(t, paidProductID); got != 7 {
|
|
t.Errorf("stock article payant après checkout (10 initial - 3 achetés): got=%.2f want=7", got)
|
|
}
|
|
|
|
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
|
if err != nil {
|
|
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
|
}
|
|
if redeemed["pool_0"] != 1 {
|
|
t.Errorf("compteur de récompenses réclamées après checkout: got=%d want=1", redeemed["pool_0"])
|
|
}
|
|
}
|
|
|
|
// Si le checkout échoue (stock insuffisant sur l'article payant du même
|
|
// panier), l'article récompense ne doit pas non plus voir son stock décrémenté
|
|
// (rollback complet, cohérent avec le comportement déjà vérifié pour les
|
|
// articles payants).
|
|
func TestRewardClaim_CheckoutFailure_DoesNotDecrementRewardStock(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_checkout_fail")
|
|
rewardProductID := newTestProduct(t, "RewardCheckoutFailFree", 5)
|
|
shortProductID := newTestProduct(t, "RewardCheckoutFailShort", 1)
|
|
|
|
setClientPoolPoints(t, username, "pool_0", 20)
|
|
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
|
|
t.Fatalf("ClaimPoolReward: %v", err)
|
|
}
|
|
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
|
|
t.Fatalf("AddRewardsToBasket: %v", err)
|
|
}
|
|
// Article payant en rupture pour forcer l'échec du checkout.
|
|
if err := testDB.GDB.Exec(
|
|
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) VALUES (?, ?, 5, 50, false, CURRENT_TIMESTAMP)`,
|
|
username, shortProductID,
|
|
).Error; err != nil {
|
|
t.Fatalf("insertion panier insuffisant: %v", err)
|
|
}
|
|
|
|
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
|
|
t.Fatal("attendu un échec de checkout (stock insuffisant sur l'article payant)")
|
|
}
|
|
|
|
if got := productStock(t, rewardProductID); got != 5 {
|
|
t.Errorf("stock article récompense ne doit pas bouger si le checkout échoue: got=%.2f want=5", got)
|
|
}
|
|
}
|
|
|
|
// ── ResetClientRedeemed (admin) ──────────────────────────────────────────────
|
|
|
|
func TestResetClientRedeemed_SpecificPool(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_reset_specific")
|
|
setClientPoolPoints(t, username, "pool_0", 20)
|
|
setClientPoolPoints(t, username, "pool_1", 20)
|
|
testDB.ClaimPoolReward(username, "pool_0", 20)
|
|
testDB.ClaimPoolReward(username, "pool_1", 20)
|
|
|
|
if err := testDB.ResetClientRedeemed(username, "pool_0"); err != nil {
|
|
t.Fatalf("ResetClientRedeemed: %v", err)
|
|
}
|
|
|
|
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
|
if err != nil {
|
|
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
|
}
|
|
if redeemed["pool_0"] != 0 {
|
|
t.Errorf("pool_0 doit être remis à zéro: got=%d", redeemed["pool_0"])
|
|
}
|
|
if redeemed["pool_1"] != 1 {
|
|
t.Errorf("pool_1 ne doit pas être affecté: got=%d want=1", redeemed["pool_1"])
|
|
}
|
|
}
|
|
|
|
func TestResetClientRedeemed_AllPools(t *testing.T) {
|
|
cleanupStockTestData(t)
|
|
username := newTestClient(t, "reward_reset_all")
|
|
setClientPoolPoints(t, username, "pool_0", 20)
|
|
setClientPoolPoints(t, username, "pool_1", 20)
|
|
testDB.ClaimPoolReward(username, "pool_0", 20)
|
|
testDB.ClaimPoolReward(username, "pool_1", 20)
|
|
|
|
if err := testDB.ResetClientRedeemed(username, ""); err != nil {
|
|
t.Fatalf("ResetClientRedeemed: %v", err)
|
|
}
|
|
|
|
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
|
if err != nil {
|
|
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
|
}
|
|
if len(redeemed) != 0 {
|
|
t.Errorf("tous les pools doivent être remis à zéro: got=%v", redeemed)
|
|
}
|
|
}
|