201 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|