Files
projet_gestion_commande/backend/gestion/tests/stock_checkout_test.go
T
Xor290 22a8d5026c
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s
chore: build
2026-08-06 12:06:05 +02:00

201 lines
7.3 KiB
Go

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)
}
}