405 lines
16 KiB
Go
405 lines
16 KiB
Go
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)
|
|
}
|
|
}
|