package tests import ( "sync" "testing" ) // AddToBasket vérifie le stock disponible mais ne le décrémente jamais — // le stock réel n'est consommé qu'au checkout (voir comprehension-metier). func TestAddToBasket_RejectsInsufficientStock(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "addbasket_insuff") productID := newTestProduct(t, "AddBasketInsuff", 2) _, err := testDB.AddToBasket(username, productID, 3) if err == nil { t.Fatal("attendu une erreur (stock insuffisant), reçu nil") } if got := productStock(t, productID); got != 2 { t.Errorf("stock ne doit pas bouger sur un ajout panier refusé: got=%.2f want=2", got) } } func TestAddToBasket_DoesNotDecrementStock(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "addbasket_ok") productID := newTestProduct(t, "AddBasketOk", 10) if _, err := testDB.AddToBasket(username, productID, 4); err != nil { t.Fatalf("AddToBasket: %v", err) } if got := productStock(t, productID); got != 10 { t.Errorf("le stock ne doit être décrémenté qu'au checkout, pas à l'ajout panier: got=%.2f want=10", got) } } // Le checkout (CreateCommandWithAddress) doit décrémenter le stock exactement // de la quantité commandée, dans la même transaction que la création de la // commande et le vidage du panier. func TestCheckout_DecrementsStockExactly(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "checkout_ok") productID := newTestProduct(t, "CheckoutOk", 10) if _, err := testDB.AddToBasket(username, productID, 3); err != nil { t.Fatalf("AddToBasket: %v", err) } cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") if err != nil { t.Fatalf("CreateCommandWithAddress: %v", err) } if got := productStock(t, productID); got != 7 { t.Errorf("stock après checkout de 3 unités sur 10: got=%.2f want=7", got) } var basketCount int64 testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount) if basketCount != 0 { t.Errorf("le panier doit être vidé après checkout, reste %d article(s)", basketCount) } var status string testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, cmd.ID).Scan(&status) if status != "pending" { t.Errorf("statut de la commande créée: got=%s want=pending", status) } } // Règle métier : les articles récompense (is_reward=true) sont des produits // physiques réellement distribués et doivent décrémenter le stock exactement // comme un article payant — jamais exclus du décompte. func TestCheckout_RewardItemDecrementsStockLikeAPaidItem(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "checkout_reward") paidProductID := newTestProduct(t, "CheckoutRewardPaid", 10) rewardProductID := newTestProduct(t, "CheckoutRewardFree", 5) if _, err := testDB.AddToBasket(username, paidProductID, 2); err != nil { t.Fatalf("AddToBasket (payant): %v", err) } // Article récompense : inséré directement (produit par ClaimMyReward en // production), prix affiché 0€, mais le stock doit être traité pareil. if err := testDB.GDB.Exec( `INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at) VALUES (?, ?, 2, 0, true, 'pool_0', CURRENT_TIMESTAMP)`, username, rewardProductID, ).Error; err != nil { t.Fatalf("insertion article récompense: %v", err) } if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { t.Fatalf("CreateCommandWithAddress: %v", err) } if got := productStock(t, paidProductID); got != 8 { t.Errorf("stock produit payant après checkout: got=%.2f want=8", got) } if got := productStock(t, rewardProductID); got != 3 { t.Errorf("stock produit récompense après checkout (doit décrémenter comme un article payant): got=%.2f want=3", got) } } // Un stock insuffisant sur un seul article du panier doit faire échouer tout // le checkout, sans décrémenter partiellement les autres articles ni créer de // commande fantôme (le tout est dans une seule transaction). func TestCheckout_InsufficientStockOnOneItemRollsBackEverything(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "checkout_partial") okProductID := newTestProduct(t, "CheckoutPartialOk", 10) shortProductID := newTestProduct(t, "CheckoutPartialShort", 1) if _, err := testDB.AddToBasket(username, okProductID, 5); err != nil { t.Fatalf("AddToBasket (ok): %v", err) } // Second article : on force un panier dont la quantité dépasse le stock // disponible au moment du checkout (simulation d'une désynchronisation, // par ex. deux clients ayant chacun ajouté le dernier article en stock). 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) } before := productStock(t, okProductID) _, err := testDB.CreateCommandWithAddress(username, "1 rue de test") if err == nil { t.Fatal("attendu un échec de checkout (stock insuffisant), reçu nil") } if got := productStock(t, okProductID); got != before { t.Errorf("le stock du 1er article ne doit pas être décrémenté si le 2e échoue (pas de décrément partiel): got=%.2f want=%.2f", got, before) } if got := productStock(t, shortProductID); got != 1 { t.Errorf("stock du produit en rupture ne doit pas bouger: got=%.2f want=1", got) } var basketCount int64 testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount) if basketCount != 2 { t.Errorf("le panier ne doit pas être vidé si le checkout échoue: got=%d want=2", basketCount) } var cmdCount int64 testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount) if cmdCount != 0 { t.Errorf("aucune commande fantôme ne doit être créée si le checkout échoue: got=%d want=0", cmdCount) } } // Deux checkouts quasi simultanés pour le même client (double-tap / retry // réseau) sur un stock tout juste suffisant pour une seule commande ne // doivent décrémenter le stock qu'une seule fois — le verrou FOR UPDATE sur // le panier sérialise les deux tentatives. func TestCheckout_ConcurrentDoubleSubmitDoesNotOversell(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "checkout_concurrent") productID := newTestProduct(t, "CheckoutConcurrent", 2) if _, err := testDB.AddToBasket(username, productID, 2); err != nil { t.Fatalf("AddToBasket: %v", err) } var wg sync.WaitGroup results := make([]error, 2) for i := range 2 { wg.Add(1) go func(idx int) { defer wg.Done() _, results[idx] = testDB.CreateCommandWithAddress(username, "1 rue de test") }(i) } wg.Wait() successCount := 0 for _, err := range results { if err == nil { successCount++ } } if successCount != 1 { t.Errorf("exactement 1 des 2 checkouts concurrents doit réussir (panier vidé par le premier): got=%d succès", successCount) } if got := productStock(t, productID); got != 0 { t.Errorf("stock final après un seul checkout réussi de 2 unités sur 2: got=%.2f want=0", got) } var cmdCount int64 testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount) if cmdCount != 1 { t.Errorf("une seule commande doit avoir été créée: got=%d want=1", cmdCount) } }