package tests import ( "sync" "testing" ) // ── Client (CancelCommandAtomic) ──────────────────────────────────────────── func TestCancelCommandAtomic_RefundsStockExactly(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "cancel_client_ok") productID := newTestProduct(t, "CancelClientOk", 5) // pending, sans livreur assigné -> annulation sans pénalité ni confirmation requise cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err != nil { t.Fatalf("CancelCommandAtomic: %v", err) } if got := productStock(t, productID); got != 8 { t.Errorf("stock après annulation client (5 initial + 3 remboursés): got=%.2f want=8", got) } if got := commandStatus(t, cmdID); got != "cancelled" { t.Errorf("statut après annulation: got=%s want=cancelled", got) } } func TestCancelCommandAtomic_DoubleCancelDoesNotDoubleRefund(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "cancel_client_dbl") productID := newTestProduct(t, "CancelClientDbl", 5) cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err != nil { t.Fatalf("1er CancelCommandAtomic: %v", err) } // Rejeu (double-tap / retry réseau) : doit échouer proprement, pas de second remboursement. if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err == nil { t.Fatal("le second appel sur une commande déjà annulée doit renvoyer une erreur") } if got := productStock(t, productID); got != 8 { t.Errorf("stock après double annulation (doit rester remboursé une seule fois): got=%.2f want=8", got) } } func TestCancelCommandAtomic_ConcurrentCancelDoesNotDoubleRefund(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "cancel_client_concurrent") productID := newTestProduct(t, "CancelClientConcurrent", 5) cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) var wg sync.WaitGroup results := make([]error, 3) for i := range 3 { wg.Add(1) go func(idx int) { defer wg.Done() _, results[idx] = testDB.CancelCommandAtomic(cmdID, username, "test", false) }(i) } wg.Wait() successCount := 0 for _, err := range results { if err == nil { successCount++ } } if successCount != 1 { t.Errorf("un seul appel concurrent doit réussir l'annulation: got=%d succès", successCount) } if got := productStock(t, productID); got != 8 { t.Errorf("stock après 3 annulations concurrentes de la même commande: got=%.2f want=8 (un seul remboursement)", got) } } // ── Livreur (CancelDeliveryByLivreurAtomic) ───────────────────────────────── func TestCancelDeliveryByLivreurAtomic_RefundsStockExactly(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "cancel_livreur_ok") productID := newTestProduct(t, "CancelLivreurOk", 5) cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurX", productID, 2, 20) alreadyCancelled, prevStatus, err := testDB.CancelDeliveryByLivreurAtomic(cmdID) if err != nil { t.Fatalf("CancelDeliveryByLivreurAtomic: %v", err) } if alreadyCancelled { t.Error("alreadyCancelled ne doit pas être true au premier appel") } if prevStatus != "en_route" { t.Errorf("prevStatus: got=%s want=en_route", prevStatus) } if got := productStock(t, productID); got != 7 { t.Errorf("stock après annulation livreur (5 initial + 2 remboursés): got=%.2f want=7", got) } if got := commandStatus(t, cmdID); got != "cancelled" { t.Errorf("statut après annulation livreur: got=%s want=cancelled", got) } } func TestCancelDeliveryByLivreurAtomic_DoubleCancelIsIdempotent(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "cancel_livreur_dbl") productID := newTestProduct(t, "CancelLivreurDbl", 5) cmdID := newTestCommandWithItem(t, username, "arrived", testUserPrefix+"livreurX", productID, 2, 20) if _, _, err := testDB.CancelDeliveryByLivreurAtomic(cmdID); err != nil { t.Fatalf("1er appel: %v", err) } alreadyCancelled, _, err := testDB.CancelDeliveryByLivreurAtomic(cmdID) if err != nil { t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err) } if !alreadyCancelled { t.Error("le 2e appel doit renvoyer alreadyCancelled=true") } if got := productStock(t, productID); got != 7 { t.Errorf("stock après double annulation livreur (doit rester remboursé une seule fois): got=%.2f want=7", got) } } func TestCancelDeliveryByLivreurAtomic_ConcurrentCancelDoesNotDoubleRefund(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "cancel_livreur_concurrent") productID := newTestProduct(t, "CancelLivreurConcurrent", 5) cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurX", productID, 2, 20) var wg sync.WaitGroup n := 3 alreadyFlags := make([]bool, n) errs := make([]error, n) for i := range n { wg.Add(1) go func(idx int) { defer wg.Done() alreadyFlags[idx], _, errs[idx] = testDB.CancelDeliveryByLivreurAtomic(cmdID) }(i) } wg.Wait() freshCancelCount := 0 for i := range n { if errs[i] != nil { t.Errorf("appel %d: erreur inattendue: %v", i, errs[i]) continue } if !alreadyFlags[i] { freshCancelCount++ } } if freshCancelCount != 1 { t.Errorf("un seul appel concurrent doit effectuer l'annulation réelle: got=%d", freshCancelCount) } if got := productStock(t, productID); got != 7 { t.Errorf("stock après annulations concurrentes livreur: got=%.2f want=7 (un seul remboursement)", got) } } // ── Admin/Cabine (DeleteCommandAtomic) ────────────────────────────────────── func TestDeleteCommandAtomic_RefundsStockAndRemovesCommand(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "delete_admin_ok") productID := newTestProduct(t, "DeleteAdminOk", 5) cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 4, 40) if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil { t.Fatalf("DeleteCommandAtomic: %v", err) } if got := productStock(t, productID); got != 9 { t.Errorf("stock après suppression admin (5 initial + 4 remboursés): got=%.2f want=9", got) } var count int64 testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE id = ?`, cmdID).Scan(&count) if count != 0 { t.Errorf("la commande doit être supprimée de la base: got=%d lignes restantes", count) } } // Si la commande est déjà annulée ou approuvée, le stock a déjà été traité // par le chemin correspondant — DeleteCommandAtomic ne doit pas rembourser // une seconde fois lors d'une suppression a posteriori. func TestDeleteCommandAtomic_DoesNotRefundAlreadyCancelledOrApprovedOrder(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "delete_admin_already") productID := newTestProduct(t, "DeleteAdminAlready", 5) cmdID := newTestCommandWithItem(t, username, "cancelled", "", productID, 4, 40) if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil { t.Fatalf("DeleteCommandAtomic: %v", err) } if got := productStock(t, productID); got != 5 { t.Errorf("stock ne doit pas être remboursé pour une commande déjà annulée: got=%.2f want=5", got) } } // ── Crypto (CancelCryptoCommand) ──────────────────────────────────────────── func TestCancelCryptoCommand_RefundsStockOnPendingPayment(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "cancel_crypto_ok") productID := newTestProduct(t, "CancelCryptoOk", 5) cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20) if err := testDB.CancelCryptoCommand(cmdID); err != nil { t.Fatalf("CancelCryptoCommand: %v", err) } if got := productStock(t, productID); got != 7 { t.Errorf("stock après annulation crypto (5 initial + 2 remboursés): got=%.2f want=7", got) } if got := commandStatus(t, cmdID); got != "cancelled" { t.Errorf("statut après annulation crypto: got=%s want=cancelled", got) } } func TestCancelCryptoCommand_RejectsIfNotPendingPayment(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "cancel_crypto_wrong") productID := newTestProduct(t, "CancelCryptoWrong", 5) cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20) if err := testDB.CancelCryptoCommand(cmdID); err == nil { t.Fatal("attendu une erreur : seule une commande pending_payment est annulable via ce chemin") } if got := productStock(t, productID); got != 5 { t.Errorf("stock ne doit pas bouger si le statut n'est pas pending_payment: got=%.2f want=5", got) } } func TestCancelCryptoCommand_DoubleCancelDoesNotDoubleRefund(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "cancel_crypto_dbl") productID := newTestProduct(t, "CancelCryptoDbl", 5) cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20) if err := testDB.CancelCryptoCommand(cmdID); err != nil { t.Fatalf("1er appel: %v", err) } if err := testDB.CancelCryptoCommand(cmdID); err == nil { t.Fatal("le 2e appel (webhook rejoué) doit échouer, pas rembourser une seconde fois") } if got := productStock(t, productID); got != 7 { t.Errorf("stock après webhook rejoué: got=%.2f want=7 (un seul remboursement)", got) } }