package tests import ( "sync" "testing" ) // setClientReferralBalance fixe directement le solde de crédit parrainage // d'un client de test (contourne le flux normal de parrainage/checkout pour // tester isolément le débit/crédit). func setClientReferralBalance(t *testing.T, username string, amount float64) { t.Helper() if err := testDB.GDB.Exec( `UPDATE clients SET referral_balance = ? WHERE username = ?`, amount, username, ).Error; err != nil { t.Fatalf("setClientReferralBalance: %v", err) } } func referralBalance(t *testing.T, username string) float64 { t.Helper() balance, err := testDB.GetClientReferralBalance(username) if err != nil { t.Fatalf("GetClientReferralBalance: %v", err) } return balance } // ── DebitReferralBalance ───────────────────────────────────────────────────── func TestDebitReferralBalance_SucceedsWithSufficientBalance(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_debit_ok") setClientReferralBalance(t, username, 50) if err := testDB.DebitReferralBalance(username, 30); err != nil { t.Fatalf("DebitReferralBalance: %v", err) } if got := referralBalance(t, username); got != 20 { t.Errorf("solde après débit (50 - 30): got=%.2f want=20", got) } } func TestDebitReferralBalance_FailsWithInsufficientBalance(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_debit_insuff") setClientReferralBalance(t, username, 10) if err := testDB.DebitReferralBalance(username, 30); err == nil { t.Fatal("attendu une erreur (solde 10€ insuffisant pour débiter 30€)") } if got := referralBalance(t, username); got != 10 { t.Errorf("solde ne doit pas bouger si le débit échoue: got=%.2f want=10", got) } } // Un montant nul ou négatif est un no-op silencieux (cas "pas de crédit // parrainage utilisé" au checkout) — ne doit jamais faire échouer ni modifier // le solde. func TestDebitReferralBalance_ZeroOrNegativeAmountIsNoop(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_debit_zero") setClientReferralBalance(t, username, 15) if err := testDB.DebitReferralBalance(username, 0); err != nil { t.Errorf("montant nul ne doit jamais échouer: %v", err) } if err := testDB.DebitReferralBalance(username, -5); err != nil { t.Errorf("montant négatif ne doit jamais échouer: %v", err) } if got := referralBalance(t, username); got != 15 { t.Errorf("solde ne doit pas bouger sur un débit nul/négatif: got=%.2f want=15", got) } } // Trois débits concurrents pour un solde qui ne permet qu'un seul d'entre eux // ne doivent en laisser passer qu'un seul (verrou FOR UPDATE) — même classe de // bug que le double-submit checkout, appliquée au solde de parrainage. func TestDebitReferralBalance_ConcurrentDebitsDoNotOverspend(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_debit_concurrent") setClientReferralBalance(t, username, 30) var wg sync.WaitGroup n := 3 errs := make([]error, n) for i := range n { wg.Add(1) go func(idx int) { defer wg.Done() errs[idx] = testDB.DebitReferralBalance(username, 30) }(i) } wg.Wait() successCount := 0 for _, err := range errs { if err == nil { successCount++ } } if successCount != 1 { t.Errorf("un seul débit concurrent de 30€ sur un solde de 30€ doit réussir: got=%d succès", successCount) } if got := referralBalance(t, username); got != 0 { t.Errorf("solde final après débits concurrents: got=%.2f want=0 (un seul débit appliqué)", got) } } // ── CreditClientReferral ───────────────────────────────────────────────────── func TestCreditClientReferral_AddsAmount(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_credit_ok") setClientReferralBalance(t, username, 10) if err := testDB.CreditClientReferral(username, 25); err != nil { t.Fatalf("CreditClientReferral: %v", err) } if got := referralBalance(t, username); got != 35 { t.Errorf("solde après crédit (10 + 25): got=%.2f want=35", got) } } func TestCreditClientReferral_RejectsNonPositiveAmount(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_credit_negative") setClientReferralBalance(t, username, 10) if err := testDB.CreditClientReferral(username, 0); err == nil { t.Error("un crédit de montant nul doit être rejeté") } if err := testDB.CreditClientReferral(username, -5); err == nil { t.Error("un crédit de montant négatif doit être rejeté") } if got := referralBalance(t, username); got != 10 { t.Errorf("solde ne doit pas bouger sur un crédit rejeté: got=%.2f want=10", got) } } func TestCreditClientReferral_FailsForUnknownClient(t *testing.T) { if err := testDB.CreditClientReferral(testUserPrefix+"does_not_exist", 10); err == nil { t.Fatal("attendu une erreur pour un client inexistant") } } // ── ResetClientReferralBalance ─────────────────────────────────────────────── func TestResetClientReferralBalance_SetsToZero(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_reset") setClientReferralBalance(t, username, 42) if err := testDB.ResetClientReferralBalance(username); err != nil { t.Fatalf("ResetClientReferralBalance: %v", err) } if got := referralBalance(t, username); got != 0 { t.Errorf("solde après reset: got=%.2f want=0", got) } } // ── Séquence complète débit -> checkout, reproduisant exactement le flux du // handler ValidateBasket (handlers/panier.go) : débit AVANT la création de la // commande, puis re-crédit compensatoire si CreateCommandWithAddress échoue. // Sans ce re-crédit, un client perdrait sèchement son crédit de parrainage // sur un checkout qui a pourtant échoué (violation explicite de la doc métier). func TestReferral_DebitThenCheckoutFailure_RecreditsBalance(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_checkout_fail") productID := newTestProduct(t, "ReferralCheckoutFail", 1) setClientReferralBalance(t, username, 20) insertNormalBasketRow(t, username, productID, 5, 50) // 5 demandés pour 1 en stock -> échec garanti referralUsed := 20.0 if err := testDB.DebitReferralBalance(username, referralUsed); err != nil { t.Fatalf("DebitReferralBalance: %v", err) } if got := referralBalance(t, username); got != 0 { t.Fatalf("précondition: solde débité: got=%.2f want=0", got) } _, err := testDB.CreateCommandWithAddress(username, "1 rue de test") if err == nil { t.Fatal("attendu un échec de checkout (stock insuffisant)") } // Reproduction exacte de la compensation faite par le handler en cas d'échec. if err := testDB.CreditClientReferral(username, referralUsed); err != nil { t.Fatalf("CreditClientReferral (compensation): %v", err) } if got := referralBalance(t, username); got != 20 { t.Errorf("le crédit parrainage doit être intégralement restauré après échec du checkout: got=%.2f want=20", got) } if got := productStock(t, productID); got != 1 { t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=1", got) } } func TestReferral_DebitThenCheckoutSuccess_BalanceStaysDebited(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_checkout_ok") productID := newTestProduct(t, "ReferralCheckoutOk", 10) setClientReferralBalance(t, username, 20) insertNormalBasketRow(t, username, productID, 3, 30) if err := testDB.DebitReferralBalance(username, 20); err != nil { t.Fatalf("DebitReferralBalance: %v", err) } if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { t.Fatalf("CreateCommandWithAddress: %v", err) } if got := referralBalance(t, username); got != 0 { t.Errorf("le crédit parrainage reste débité après un checkout réussi: got=%.2f want=0", got) } if got := productStock(t, productID); got != 7 { t.Errorf("stock après checkout réussi: got=%.2f want=7", got) } } // Combine les deux règles demandées explicitement : réclamation de // récompense (même produit en reward + en achat normal) ET utilisation du // crédit de parrainage sur la même commande. func TestReferral_CombinedWithSameProductRewardAndNormal_AllInvariantsHold(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "referral_combined_reward") productID := newTestProduct(t, "ReferralCombinedReward", 20) setClientReferralBalance(t, username, 15) insertRewardBasketRow(t, username, productID, 1) insertNormalBasketRow(t, username, productID, 4, 40) if err := testDB.DebitReferralBalance(username, 15); err != nil { t.Fatalf("DebitReferralBalance: %v", err) } if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { t.Fatalf("CreateCommandWithAddress: %v", err) } // 20 initial - 1 (reward) - 4 (normal) = 15. if got := productStock(t, productID); got != 15 { t.Errorf("stock après checkout combiné (reward+normal même produit + parrainage): got=%.2f want=15", got) } if got := referralBalance(t, username); got != 0 { t.Errorf("crédit parrainage débité et non restauré après succès: got=%.2f want=0", got) } }