package tests import ( "encoding/json" "gestion/models" "sync" "testing" "gorm.io/gorm" ) // setPointsPoolsSettings remplace la configuration des pools de points pour la // durée du test (table app_settings, clé "points_pools"), et restaure l'état // par défaut en fin de test. func setPointsPoolsSettings(t *testing.T, pools []models.PointsPool) { t.Helper() data, err := json.Marshal(pools) if err != nil { t.Fatalf("marshal pools: %v", err) } if err := testDB.GDB.Exec( `INSERT INTO app_settings (key, value) VALUES ('points_pools', ?) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, string(data), ).Error; err != nil { t.Fatalf("setPointsPoolsSettings: %v", err) } t.Cleanup(func() { testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_pools'`) }) } // setPointsRewardSettings configure le seuil de récompense globale (déduction // de points lors de la consommation d'un article récompense). func setPointsRewardSettings(t *testing.T, reward models.PointsReward) { t.Helper() data, err := json.Marshal(reward) if err != nil { t.Fatalf("marshal points_reward: %v", err) } if err := testDB.GDB.Exec( `INSERT INTO app_settings (key, value) VALUES ('points_reward', ?) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, string(data), ).Error; err != nil { t.Fatalf("setPointsRewardSettings: %v", err) } t.Cleanup(func() { testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_reward'`) }) } // insertRewardCommandItem ajoute directement un item récompense à une // commande déjà créée (contourne le checkout, pour isoler le calcul de points). func insertRewardCommandItem(t *testing.T, commandID, productID int, quantite, prix float64, poolKey string) { t.Helper() if err := testDB.GDB.Exec( `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status, is_reward, reward_pool_key) VALUES (?, ?, 'item reward test', ?, ?, 'pending', true, ?)`, commandID, productID, quantite, prix, poolKey, ).Error; err != nil { t.Fatalf("insertRewardCommandItem: %v", err) } } func clientPointsExtra(t *testing.T, username string) map[string]int { t.Helper() extra, _, err := testDB.GetClientPointsAndRewards(username) if err != nil { t.Fatalf("GetClientPointsAndRewards: %v", err) } return extra } // ── CalculateAndAddPointsForCommandTx : logique bas niveau ────────────────── func TestCalculateAndAddPointsForCommandTx_CreditsPointsPerPoolFromTiers(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "points_tiers_ok") productID := newTestProduct(t, "PointsTiersOk", 20) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{ {Min: 30, Max: 50, Points: 1}, {Min: 60, Max: 0, Points: 5}, }}, }) // Total commande = 60€ -> palier "60 et plus" = 5 points. cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60) err := testDB.GDB.Transaction(func(tx *gorm.DB) error { pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) if err != nil { t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) } if pts != 5 { t.Errorf("points calculés: got=%d want=5", pts) } if cat != "Pool Test" { t.Errorf("catégorie: got=%q want=%q", cat, "Pool Test") } return nil }) if err != nil { t.Fatalf("transaction: %v", err) } if got := clientPointsExtra(t, username)["pool_0"]; got != 5 { t.Errorf("points_extra[pool_0] après crédit: got=%d want=5", got) } } func TestCalculateAndAddPointsForCommandTx_NoPoolsConfigured_ReturnsZero(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "points_no_pools") productID := newTestProduct(t, "PointsNoPools", 20) setPointsPoolsSettings(t, []models.PointsPool{}) // aucun pool configuré cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60) testDB.GDB.Transaction(func(tx *gorm.DB) error { pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) if err != nil { t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) } if pts != 0 || cat != "" { t.Errorf("sans pool configuré: got pts=%d cat=%q, want 0/\"\"", pts, cat) } return nil }) if got := clientPointsExtra(t, username); len(got) != 0 { t.Errorf("points_extra ne doit pas bouger sans pool configuré: got=%v", got) } } // Un pool existe mais aucune de ses catégories ne correspond à la catégorie // des produits commandés ("test", posée par newTestProduct) -> 0 point. func TestCalculateAndAddPointsForCommandTx_CategoryNotInAnyPool_ReturnsZero(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "points_cat_mismatch") productID := newTestProduct(t, "PointsCatMismatch", 20) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Autre Catégorie", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{ {Min: 30, Max: 0, Points: 5}, }}, }) cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60) testDB.GDB.Transaction(func(tx *gorm.DB) error { pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) if err != nil { t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) } if pts != 0 { t.Errorf("catégorie hors pool: got pts=%d want=0", pts) } return nil }) if got := clientPointsExtra(t, username); len(got) != 0 { t.Errorf("points_extra ne doit pas bouger si aucune catégorie ne matche: got=%v", got) } } // Deux pools indépendants : seul celui dont la catégorie correspond aux // produits de la commande doit recevoir des points. func TestCalculateAndAddPointsForCommandTx_MultiplePoolsIndependent(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "points_multi_pool") productID := newTestProduct(t, "PointsMultiPool", 20) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Match", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 7}}}, {Key: "pool_1", Name: "Pool No Match", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{{Min: 30, Max: 0, Points: 99}}}, }) cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 2, 20) testDB.GDB.Transaction(func(tx *gorm.DB) error { pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) if err != nil { t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) } if pts != 7 { t.Errorf("total points (seul pool_0 doit contribuer): got=%d want=7", pts) } return nil }) extra := clientPointsExtra(t, username) if extra["pool_0"] != 7 { t.Errorf("pool_0: got=%d want=7", extra["pool_0"]) } if extra["pool_1"] != 0 { t.Errorf("pool_1 ne doit recevoir aucun point (catégorie non matchée): got=%d want=0", extra["pool_1"]) } } // Un article récompense présent dans la commande déduit "threshold" points du // pool correspondant, en plus des points gagnés par les articles payants de // la même commande. func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoints(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "points_reward_deduct") paidProductID := newTestProduct(t, "PointsRewardDeductPaid", 20) rewardProductID := newTestProduct(t, "PointsRewardDeductFree", 5) 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}) setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10) insertRewardCommandItem(t, cmdID, rewardProductID, 1, 0, "pool_0") testDB.GDB.Transaction(func(tx *gorm.DB) error { pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) if err != nil { t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) } if pts != 3 { t.Errorf("points gagnés sur l'article payant: got=%d want=3", pts) } return nil }) // 25 (initial) + 3 (gagnés) - 20 (seuil déduit pour la récompense consommée) = 8. if got := clientPointsExtra(t, username)["pool_0"]; got != 8 { t.Errorf("points_extra[pool_0] après crédit + déduction récompense: got=%d want=8", got) } } // Propriété documentée du design : cette fonction bas niveau n'a aucune garde // d'idempotence intégrée — appeler deux fois pour la même commande double les // points. C'est le rôle de l'appelant (ApproveDeliveryAtomic, via son // verrou de transition de statut livre->approved) d'empêcher un second appel. func TestCalculateAndAddPointsForCommandTx_CalledTwice_DoublesPoints(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "points_called_twice") productID := newTestProduct(t, "PointsCalledTwice", 20) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 4}}}, }) cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) for i := 0; i < 2; i++ { testDB.GDB.Transaction(func(tx *gorm.DB) error { _, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) if err != nil { t.Fatalf("appel %d: %v", i+1, err) } return nil }) } if got := clientPointsExtra(t, username)["pool_0"]; got != 8 { t.Errorf("deux appels bruts doublent les points (4+4): got=%d want=8 — ceci documente pourquoi ApproveDeliveryAtomic doit rester le seul appelant", got) } } // ── ApproveDeliveryAtomic : la vraie règle métier "points uniquement à // l'approbation, jamais avant" ──────────────────────────────────────────── func TestApproveDeliveryAtomic_CreditsPointsExactlyOnApproval(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "approve_credits_points") productID := newTestProduct(t, "ApproveCreditsPoints", 20) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, }) cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) pts, _, err := testDB.ApproveDeliveryAtomic(cmdID, username) if err != nil { t.Fatalf("ApproveDeliveryAtomic: %v", err) } if pts != 6 { t.Errorf("points retournés par l'approbation: got=%d want=6", pts) } if got := commandStatus(t, cmdID); got != "approved" { t.Errorf("statut après approbation: got=%s want=approved", got) } if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { t.Errorf("points_extra après approbation: got=%d want=6", got) } } // La règle centrale : tant que la commande n'est pas "livre", l'approbation // doit être rejetée et AUCUN point ne doit être crédité. func TestApproveDeliveryAtomic_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) { cleanupStockTestData(t) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, }) for _, status := range []string{"pending", "assigned", "en_route", "arrived"} { t.Run(status, func(t *testing.T) { username := newTestClient(t, "approve_reject_"+status) productID := newTestProduct(t, "ApproveReject"+status, 20) cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err == nil { t.Fatalf("attendu un rejet pour une commande en statut %q", status) } if got := clientPointsExtra(t, username)["pool_0"]; got != 0 { t.Errorf("aucun point ne doit être crédité pour une commande non 'livre' (statut=%s): got=%d want=0", status, got) } if got := commandStatus(t, cmdID); got != status { t.Errorf("le statut ne doit pas changer sur une approbation rejetée: got=%s want=%s", got, status) } }) } } // Double approbation (retry réseau / double-tap client) : la seconde doit // être un no-op silencieux, jamais un second crédit de points. func TestApproveDeliveryAtomic_DoubleApprove_DoesNotDoublePoints(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "approve_double") productID := newTestProduct(t, "ApproveDouble", 20) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, }) cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err != nil { t.Fatalf("1ère approbation: %v", err) } pts2, _, err := testDB.ApproveDeliveryAtomic(cmdID, username) if err != nil { t.Fatalf("2e approbation (doit être idempotente, pas une erreur): %v", err) } if pts2 != 0 { t.Errorf("2e approbation ne doit rapporter aucun point: got=%d want=0", pts2) } if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { t.Errorf("points après double approbation (doivent rester crédités une seule fois): got=%d want=6", got) } } func TestApproveDeliveryAtomic_ConcurrentApprove_CreditsPointsOnlyOnce(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "approve_concurrent") productID := newTestProduct(t, "ApproveConcurrent", 20) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, }) cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) var wg sync.WaitGroup n := 3 errs := make([]error, n) ptsResults := make([]int, n) for i := range n { wg.Add(1) go func(idx int) { defer wg.Done() ptsResults[idx], _, errs[idx] = testDB.ApproveDeliveryAtomic(cmdID, username) }(i) } wg.Wait() // ApproveDeliveryAtomic traite une commande déjà approuvée comme un no-op // idempotent (err=nil, pts=0), pas comme une erreur — donc le critère de // "vrai succès" est pts>0 (crédit réellement appliqué), pas err==nil. freshCreditCount := 0 for i := range n { if errs[i] != nil { t.Errorf("appel %d: erreur inattendue: %v", i, errs[i]) continue } if ptsResults[i] > 0 { freshCreditCount++ } } if freshCreditCount != 1 { t.Errorf("une seule approbation concurrente doit réellement créditer des points: got=%d", freshCreditCount) } if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { t.Errorf("points après approbations concurrentes: got=%d want=6 (un seul crédit)", got) } } func TestApproveDeliveryAtomic_WrongOwnerRejected(t *testing.T) { cleanupStockTestData(t) owner := newTestClient(t, "approve_owner") intruder := newTestClient(t, "approve_intruder") productID := newTestProduct(t, "ApproveWrongOwner", 20) setPointsPoolsSettings(t, []models.PointsPool{ {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, }) cmdID := newTestCommandWithItem(t, owner, "livre", "", productID, 1, 10) if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, intruder); err == nil { t.Fatal("un client ne doit pas pouvoir approuver la commande d'un autre client") } if got := clientPointsExtra(t, intruder)["pool_0"]; got != 0 { t.Errorf("l'intrus ne doit recevoir aucun point: got=%d want=0", got) } if got := clientPointsExtra(t, owner)["pool_0"]; got != 0 { t.Errorf("le propriétaire ne doit pas non plus recevoir de point tant que ce n'est pas lui qui approuve: got=%d want=0", got) } if got := commandStatus(t, cmdID); got != "livre" { t.Errorf("statut ne doit pas changer sur une tentative d'un intrus: got=%s want=livre", got) } }