package tests import ( "sync" "testing" ) // SetClientParrainAndCredit lie un parrain à un client ET crédite le parrain // dans une seule transaction — la doc métier avertit explicitement que sans // cette atomicité, un crédit peut être appliqué sans lien enregistré, ou // l'inverse (lien enregistré sans jamais créditer le parrain). func TestSetClientParrainAndCredit_LinksAndCreditsAtomically(t *testing.T) { cleanupStockTestData(t) client := newTestClient(t, "parrain_ok_client") parrain := newTestClient(t, "parrain_ok_parrain") setClientReferralBalance(t, parrain, 5) if err := testDB.SetClientParrainAndCredit(client, parrain, 10); err != nil { t.Fatalf("SetClientParrainAndCredit: %v", err) } got, err := testDB.GetClientParrain(client) if err != nil { t.Fatalf("GetClientParrain: %v", err) } if got != parrain { t.Errorf("parrain enregistré: got=%q want=%q", got, parrain) } if bal := referralBalance(t, parrain); bal != 15 { t.Errorf("solde du parrain après crédit (5 + 10): got=%.2f want=15", bal) } } // Un client qui a déjà un parrain ne doit jamais pouvoir en changer via cette // fonction (contrainte "parrain déjà défini") — et surtout, le nouveau // parrain proposé ne doit recevoir aucun crédit si le lien est refusé. func TestSetClientParrainAndCredit_RejectsIfClientAlreadyHasParrain(t *testing.T) { cleanupStockTestData(t) client := newTestClient(t, "parrain_already_client") firstParrain := newTestClient(t, "parrain_already_first") secondParrain := newTestClient(t, "parrain_already_second") if err := testDB.SetClientParrainAndCredit(client, firstParrain, 10); err != nil { t.Fatalf("1er lien: %v", err) } if err := testDB.SetClientParrainAndCredit(client, secondParrain, 10); err == nil { t.Fatal("attendu un rejet : le client a déjà un parrain") } if got, _ := testDB.GetClientParrain(client); got != firstParrain { t.Errorf("le parrain enregistré ne doit pas changer: got=%q want=%q", got, firstParrain) } if bal := referralBalance(t, secondParrain); bal != 0 { t.Errorf("le second parrain (lien refusé) ne doit recevoir aucun crédit: got=%.2f want=0", bal) } if bal := referralBalance(t, firstParrain); bal != 10 { t.Errorf("le premier parrain garde son crédit initial, pas de second crédit: got=%.2f want=10", bal) } } // Scénario exact mis en garde par la doc métier : si le parrain indiqué // n'existe pas, le crédit échoue — et le lien parrain (première moitié de la // transaction) doit être annulé avec, pas laissé enregistré tout seul. func TestSetClientParrainAndCredit_RejectsUnknownParrain_RollsBackLink(t *testing.T) { cleanupStockTestData(t) client := newTestClient(t, "parrain_unknown_client") unknownParrain := testUserPrefix + "does_not_exist_parrain" if err := testDB.SetClientParrainAndCredit(client, unknownParrain, 10); err == nil { t.Fatal("attendu une erreur : le parrain n'existe pas") } got, err := testDB.GetClientParrain(client) if err != nil { t.Fatalf("GetClientParrain: %v", err) } if got != "" { t.Errorf("le lien parrain ne doit PAS être enregistré si le crédit échoue (rollback complet): got=%q want=\"\"", got) } } // Quand le parrainage est désactivé (ReferralEnabled=false côté handler), le // montant crédité vaut 0 — la liaison doit tout de même réussir sans tenter // de créditer personne. func TestSetClientParrainAndCredit_ZeroCreditLinksWithoutCrediting(t *testing.T) { cleanupStockTestData(t) client := newTestClient(t, "parrain_zero_client") parrain := newTestClient(t, "parrain_zero_parrain") if err := testDB.SetClientParrainAndCredit(client, parrain, 0); err != nil { t.Fatalf("SetClientParrainAndCredit avec montant nul: %v", err) } if got, _ := testDB.GetClientParrain(client); got != parrain { t.Errorf("le lien doit être enregistré même sans crédit: got=%q want=%q", got, parrain) } if bal := referralBalance(t, parrain); bal != 0 { t.Errorf("aucun crédit ne doit être appliqué avec un montant nul: got=%.2f want=0", bal) } } func TestSetClientParrainAndCredit_RejectsUnknownClient(t *testing.T) { cleanupStockTestData(t) parrain := newTestClient(t, "parrain_unknown_client_target") unknownClient := testUserPrefix + "does_not_exist_client" if err := testDB.SetClientParrainAndCredit(unknownClient, parrain, 10); err == nil { t.Fatal("attendu une erreur : le client cible n'existe pas") } if bal := referralBalance(t, parrain); bal != 0 { t.Errorf("le parrain ne doit pas être crédité si le client cible est introuvable: got=%.2f want=0", bal) } } // Deux tentatives concurrentes de parrainage sur le MÊME client (deux // parrains différents) ne doivent en laisser passer qu'une seule — la // condition "parrain IS NULL OR parrain = ''" de l'UPDATE sérialise // naturellement les deux tentatives au niveau de la ligne. func TestSetClientParrainAndCredit_ConcurrentSetOnSameClientOnlyOneSucceeds(t *testing.T) { cleanupStockTestData(t) client := newTestClient(t, "parrain_concurrent_client") parrainA := newTestClient(t, "parrain_concurrent_a") parrainB := newTestClient(t, "parrain_concurrent_b") var wg sync.WaitGroup errs := make([]error, 2) wg.Add(2) go func() { defer wg.Done() errs[0] = testDB.SetClientParrainAndCredit(client, parrainA, 10) }() go func() { defer wg.Done() errs[1] = testDB.SetClientParrainAndCredit(client, parrainB, 10) }() wg.Wait() successCount := 0 for _, err := range errs { if err == nil { successCount++ } } if successCount != 1 { t.Errorf("une seule tentative concurrente de parrainage doit réussir: got=%d succès", successCount) } finalParrain, _ := testDB.GetClientParrain(client) if finalParrain != parrainA && finalParrain != parrainB { t.Fatalf("parrain final inattendu: %q", finalParrain) } balA := referralBalance(t, parrainA) balB := referralBalance(t, parrainB) if (balA == 10) == (balB == 10) { t.Errorf("exactement un des deux parrains doit être crédité de 10, pas les deux ni aucun: balA=%.2f balB=%.2f", balA, balB) } }