This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ces tests appellent le vrai service Nominatim (réseau réel, rate-limité à
|
||||
// 1 req/s — voir services/adresses_correction.go). Contrairement aux tests
|
||||
// purs de services/adresses_correction_test.go (normalisation, décomposition,
|
||||
// scoring — sans réseau), ceux-ci vérifient le comportement de bout en bout
|
||||
// de ResolveAddress sur de vraies adresses nantaises mal écrites.
|
||||
//
|
||||
// Chaque cas a été vérifié manuellement au préalable (curl vers l'API
|
||||
// Nominatim) pour confirmer ce que la recherche directe résout déjà seule
|
||||
// (Nominatim tolère nativement la casse, les accents et certaines
|
||||
// abréviations sans point) et ce qui nécessite réellement la logique de
|
||||
// correction (variantes, décomposition structurée, repli ville+code postal).
|
||||
//
|
||||
// Un délai explicite sépare chaque cas, en plus du throttle déjà appliqué à
|
||||
// chaque requête HTTP interne (1.1s dans queryNominatim), par courtoisie
|
||||
// envers le service public.
|
||||
|
||||
const (
|
||||
nantesLatMin, nantesLatMax = 47.15, 47.28
|
||||
nantesLonMin, nantesLonMax = -1.65, -1.45
|
||||
)
|
||||
|
||||
func isWithinNantes(lat, lon float64) bool {
|
||||
return lat >= nantesLatMin && lat <= nantesLatMax && lon >= nantesLonMin && lon <= nantesLonMax
|
||||
}
|
||||
|
||||
func TestResolveAddress_RealNantesAddresses(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||
}
|
||||
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
correction := services.NewAddressCorrectionService(geoService)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
minConfidence float64
|
||||
maxConfidence float64
|
||||
}{
|
||||
{
|
||||
// Nominatim tolère nativement la casse et l'absence d'accent :
|
||||
// résolution directe (étape 1 de ResolveAddress), confiance max.
|
||||
name: "tout minuscule sans accent",
|
||||
input: "12 rue crebillon 44000 nantes",
|
||||
minConfidence: 0.90,
|
||||
maxConfidence: 1.0,
|
||||
},
|
||||
{
|
||||
// Abréviation sans point ("Pl" au lieu de "Place") — également
|
||||
// tolérée nativement par Nominatim, résolution directe.
|
||||
name: "abréviation sans point",
|
||||
input: "3 Pl Royale 44000 Nantes",
|
||||
minConfidence: 0.90,
|
||||
maxConfidence: 1.0,
|
||||
},
|
||||
{
|
||||
// Faute de frappe réaliste sur un nom de rue réel (Gambetta ->
|
||||
// Gambeta) : vérifié que la recherche Nominatim directe ET
|
||||
// toutes les variantes générées par l'algorithme (accents,
|
||||
// abréviations, décomposition structurée essais 1 et 2)
|
||||
// échouent — seul le repli ville+code postal (essai 3,
|
||||
// confiance fixe 0.30) aboutit. Documente une vraie limite :
|
||||
// l'algorithme ne corrige pas les fautes de frappe arbitraires
|
||||
// dans un nom de rue, il retombe sur "quelque part dans la
|
||||
// bonne ville".
|
||||
name: "faute de frappe non corrigible sur le nom de rue",
|
||||
input: "15 Rue Gambeta 44000 Nantes",
|
||||
minConfidence: 0.25,
|
||||
maxConfidence: 0.35,
|
||||
},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if i > 0 {
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
}
|
||||
|
||||
suggestion, err := correction.ResolveAddress(c.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAddress(%q): %v", c.input, err)
|
||||
}
|
||||
|
||||
if !isWithinNantes(suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) {
|
||||
t.Errorf("coordonnées hors de Nantes pour %q: lat=%.4f lon=%.4f",
|
||||
c.input, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
|
||||
}
|
||||
if suggestion.Confidence < c.minConfidence || suggestion.Confidence > c.maxConfidence {
|
||||
t.Errorf("confiance hors intervalle attendu pour %q: got=%.2f want=[%.2f,%.2f]",
|
||||
c.input, suggestion.Confidence, c.minConfidence, c.maxConfidence)
|
||||
}
|
||||
if suggestion.CorrectedAddress == "" {
|
||||
t.Errorf("adresse corrigée vide pour %q", c.input)
|
||||
}
|
||||
|
||||
t.Logf("%q -> %q (confiance=%.2f, source=%s, correction_appliquée=%v, lat=%.4f lon=%.4f)",
|
||||
c.input, suggestion.CorrectedAddress, suggestion.Confidence, suggestion.Source,
|
||||
suggestion.CorrectionApplied, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Une adresse totalement absurde (aucun rapport avec un lieu réel) doit
|
||||
// échouer proprement plutôt que renvoyer une coordonnée aléatoire.
|
||||
func TestResolveAddress_NonsenseAddressFailsCleanly(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||
}
|
||||
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
correction := services.NewAddressCorrectionService(geoService)
|
||||
|
||||
_, err := correction.ResolveAddress("Xyzzyplonk Zorbaxx 00000 Nullepart")
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur pour une adresse sans aucun rapport avec un lieu réel")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Adresses réalistes de Nantes (44000) utilisées pour vérifier que l'adresse
|
||||
// de livraison survit intacte à la création puis à toutes les voies de
|
||||
// récupération d'une commande (client, admin, livreur, historique).
|
||||
var nantesAddresses = []string{
|
||||
"12 Rue Crébillon, 44000 Nantes",
|
||||
"3 Place Royale, 44000 Nantes",
|
||||
"5 Cours des 50 Otages, 44000 Nantes",
|
||||
"8 Rue de Verdun, 44000 Nantes",
|
||||
}
|
||||
|
||||
// newTestCommandWithAddress crée directement une commande avec une adresse et
|
||||
// un statut contrôlés (en contournant le checkout), pour tester isolément la
|
||||
// récupération de l'adresse par les différentes fonctions de listing.
|
||||
func newTestCommandWithAddress(t *testing.T, username, status, address, livreurAssign string, productID int, quantite, prix float64) int {
|
||||
t.Helper()
|
||||
var cmdID int
|
||||
if err := testDB.GDB.Raw(
|
||||
`INSERT INTO commandes (username, status, adresse, livreur_assign, total_prix, created_at, updated_at)
|
||||
VALUES (?, ?, ?, NULLIF(?, ''), ?, NOW(), NOW()) RETURNING id`,
|
||||
username, status, address, livreurAssign, prix,
|
||||
).Scan(&cmdID).Error; err != nil {
|
||||
t.Fatalf("création commande test avec adresse: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
|
||||
cmdID, productID, quantite, prix,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création item test: %v", err)
|
||||
}
|
||||
return cmdID
|
||||
}
|
||||
|
||||
// Le checkout réel (panier -> CreateCommandWithAddress) doit stocker l'adresse
|
||||
// telle quelle, et GetCommandByID doit la restituer à l'identique.
|
||||
func TestCreateCommandWithAddress_RoundTripsRealNantesAddress(t *testing.T) {
|
||||
for _, address := range nantesAddresses {
|
||||
t.Run(address, func(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "addr_checkout")
|
||||
productID := newTestProduct(t, "AddrCheckout", 10)
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
cmd, err := testDB.CreateCommandWithAddress(username, address)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
command, err := testDB.GetCommandByID(cmd.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandByID: %v", err)
|
||||
}
|
||||
got, _ := command["adresse"].(string)
|
||||
if got != address {
|
||||
t.Errorf("adresse récupérée: got=%q want=%q", got, address)
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("l'adresse ne doit jamais être vide")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Une adresse vide ou uniquement composée d'espaces doit être rejetée au
|
||||
// checkout — pas de commande créée avec une adresse de livraison absente.
|
||||
func TestCreateCommandWithAddress_RejectsEmptyOrBlankAddress(t *testing.T) {
|
||||
for _, address := range []string{"", " ", "\t\n"} {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "addr_blank")
|
||||
productID := newTestProduct(t, "AddrBlank", 10)
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
if _, err := testDB.CreateCommandWithAddress(username, address); err == nil {
|
||||
t.Errorf("adresse %q aurait dû être rejetée", address)
|
||||
}
|
||||
|
||||
var count int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("aucune commande ne doit être créée avec une adresse %q: got=%d", address, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllCommands (vue admin) doit toujours renvoyer l'adresse de chaque
|
||||
// commande, quel que soit son statut.
|
||||
func TestGetAllCommands_AlwaysIncludesAddress(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "addr_admin_list")
|
||||
productID := newTestProduct(t, "AddrAdminList", 10)
|
||||
|
||||
want := map[int]string{}
|
||||
for i, address := range nantesAddresses {
|
||||
status := []string{"pending", "assigned", "en_route", "livre"}[i%4]
|
||||
cmdID := newTestCommandWithAddress(t, username, status, address, "", productID, 1, 10)
|
||||
want[cmdID] = address
|
||||
}
|
||||
|
||||
commands, err := testDB.GetAllCommands("", username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllCommands: %v", err)
|
||||
}
|
||||
if len(commands) != len(want) {
|
||||
t.Fatalf("nombre de commandes: got=%d want=%d", len(commands), len(want))
|
||||
}
|
||||
for _, c := range commands {
|
||||
id, _ := c["id"].(int)
|
||||
address, _ := c["adresse"].(string)
|
||||
if address == "" {
|
||||
t.Errorf("commande %d: adresse vide", id)
|
||||
}
|
||||
if want[id] != address {
|
||||
t.Errorf("commande %d: adresse=%q want=%q", id, address, want[id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeliveryPersonCommands (vue livreur) doit inclure l'adresse de chaque
|
||||
// commande qui lui est assignée.
|
||||
func TestGetDeliveryPersonCommands_IncludesAddress(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "addr_livreur_client")
|
||||
productID := newTestProduct(t, "AddrLivreur", 10)
|
||||
livreurUsername := testUserPrefix + "addr_livreur"
|
||||
address := nantesAddresses[0]
|
||||
|
||||
cmdID := newTestCommandWithAddress(t, username, "assigned", address, livreurUsername, productID, 1, 10)
|
||||
|
||||
commands, err := testDB.GetDeliveryPersonCommands(livreurUsername, "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDeliveryPersonCommands: %v", err)
|
||||
}
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("nombre de commandes assignées: got=%d want=1", len(commands))
|
||||
}
|
||||
got, _ := commands[0]["adresse"].(string)
|
||||
if got != address {
|
||||
t.Errorf("adresse: got=%q want=%q", got, address)
|
||||
}
|
||||
// Le type Go concret de la colonne "id" issue d'un Raw(...).Scan(&[]map[string]any)
|
||||
// n'est pas garanti (int64 selon le driver) — même précaution que le code
|
||||
// de production (ex: handlers/deleviry.go, fmt.Sprintf + strconv.Atoi).
|
||||
id, _ := strconv.Atoi(fmt.Sprintf("%v", commands[0]["id"]))
|
||||
if id != cmdID {
|
||||
t.Errorf("id de commande inattendu: got=%d want=%d", id, cmdID)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCancelledCommands doit inclure l'adresse même pour une commande annulée.
|
||||
func TestGetCancelledCommands_IncludesAddress(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "addr_cancelled")
|
||||
productID := newTestProduct(t, "AddrCancelled", 10)
|
||||
address := nantesAddresses[1]
|
||||
|
||||
newTestCommandWithAddress(t, username, "cancelled", address, "", productID, 1, 10)
|
||||
|
||||
commands, err := testDB.GetCancelledCommands(username, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCancelledCommands: %v", err)
|
||||
}
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("nombre de commandes annulées: got=%d want=1", len(commands))
|
||||
}
|
||||
got, _ := commands[0]["adresse"].(string)
|
||||
if got != address {
|
||||
t.Errorf("adresse: got=%q want=%q", got, address)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCompletedCommandsByUsername (historique client) doit inclure l'adresse
|
||||
// des commandes terminées (approved).
|
||||
func TestGetCompletedCommandsByUsername_IncludesAddress(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "addr_history")
|
||||
productID := newTestProduct(t, "AddrHistory", 10)
|
||||
address := nantesAddresses[2]
|
||||
|
||||
newTestCommandWithAddress(t, username, "approved", address, "", productID, 1, 10)
|
||||
|
||||
commands, err := testDB.GetCompletedCommandsByUsername(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCompletedCommandsByUsername: %v", err)
|
||||
}
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("nombre de commandes terminées: got=%d want=1", len(commands))
|
||||
}
|
||||
got, _ := commands[0]["adresse"].(string)
|
||||
if got != address {
|
||||
t.Errorf("adresse: got=%q want=%q", got, address)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllCommandsOldestFirst (vue cabine/admin triée) doit également inclure
|
||||
// l'adresse de chaque commande.
|
||||
func TestGetAllCommandsOldestFirst_IncludesAddress(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "addr_oldest_first")
|
||||
productID := newTestProduct(t, "AddrOldestFirst", 10)
|
||||
address := nantesAddresses[3]
|
||||
|
||||
newTestCommandWithAddress(t, username, "pending", address, "", productID, 1, 10)
|
||||
|
||||
commands, err := testDB.GetAllCommandsOldestFirst("", username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllCommandsOldestFirst: %v", err)
|
||||
}
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("nombre de commandes: got=%d want=1", len(commands))
|
||||
}
|
||||
got, _ := commands[0]["adresse"].(string)
|
||||
if got != address {
|
||||
t.Errorf("adresse: got=%q want=%q", got, address)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"gestion/handlers"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Ce fichier teste UpdateCommandStatusAdmin (annulation par admin/cabine).
|
||||
// Ce chemin utilisait auparavant deux appels séparés (lecture du statut, puis
|
||||
// restauration du stock hors transaction) — un double-tap ou appel concurrent
|
||||
// pouvait alors rembourser le stock deux fois. Il délègue maintenant à
|
||||
// db.CancelCommandByAdminAtomic, qui verrouille la commande (FOR UPDATE) et
|
||||
// fait remboursement + changement de statut dans une seule transaction,
|
||||
// comme CancelCommandAtomic (client) et CancelDeliveryByLivreurAtomic (livreur).
|
||||
|
||||
func adminCancelContext(commandID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
body, _ := json.Marshal(map[string]string{"status": "cancelled"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/orders/x/status", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("role", "admin")
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func TestUpdateCommandStatusAdmin_RefundsStockOnCancel(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "admincancel_single")
|
||||
productID := newTestProduct(t, "AdminCancelSingle", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
|
||||
|
||||
c, rec := adminCancelContext(cmdID)
|
||||
handlers.UpdateCommandStatusAdmin(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := productStock(t, productID); got != 8 {
|
||||
t.Errorf("stock après annulation admin (5 initial + 3 remboursés): got=%.2f want=8", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Statut déjà terminal (livré) : la commande peut toujours être basculée en
|
||||
// "cancelled" par l'admin (correction), mais le stock ne doit pas être
|
||||
// remboursé une seconde fois puisqu'il a déjà quitté l'entrepôt.
|
||||
func TestUpdateCommandStatusAdmin_DoesNotRefundAlreadyDeliveredOrder(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "admincancel_livre")
|
||||
productID := newTestProduct(t, "AdminCancelLivre", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 3, 30)
|
||||
|
||||
c, rec := adminCancelContext(cmdID)
|
||||
handlers.UpdateCommandStatusAdmin(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := productStock(t, productID); got != 5 {
|
||||
t.Errorf("stock ne doit pas être remboursé pour une commande déjà livrée: got=%.2f want=5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Double-tap / retry réseau sur le bouton "annuler" côté admin : régression
|
||||
// du bug corrigé (double remboursement). Barrière pour maximiser le
|
||||
// recouvrement réel entre goroutines.
|
||||
func TestUpdateCommandStatusAdmin_ConcurrentCancelDoesNotDoubleRefundStock(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "admincancel_concurrent")
|
||||
productID := newTestProduct(t, "AdminCancelConcurrent", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
|
||||
|
||||
n := 10
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
c, _ := adminCancelContext(cmdID)
|
||||
handlers.UpdateCommandStatusAdmin(c)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if got := productStock(t, productID); got != 8 {
|
||||
t.Errorf("stock après %d annulations admin concurrentes de la même commande (5 initial + 3 remboursés une seule fois attendu): got=%.2f", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Reproduction déterministe de l'ancienne fenêtre de course : deux appels
|
||||
// annulent la même commande l'un juste après l'autre (simulation d'un
|
||||
// double-tap sans dépendre du timing du scheduler). Avec CancelCommandByAdmin
|
||||
// Atomic, le second appel voit la commande déjà 'cancelled' sous verrou et ne
|
||||
// rembourse pas une seconde fois.
|
||||
func TestUpdateCommandStatusAdmin_SequentialDoubleCancelDoesNotDoubleRefund(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "admincancel_sequential")
|
||||
productID := newTestProduct(t, "AdminCancelSequential", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
|
||||
|
||||
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
|
||||
t.Fatalf("1er appel: %v", err)
|
||||
}
|
||||
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
|
||||
t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err)
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 8 {
|
||||
t.Errorf("stock après double annulation admin: got=%.2f want=8 (un seul remboursement)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const notificationsScheduledKey = "notifications:scheduled"
|
||||
|
||||
// ScheduleETANotifications programme un rappel 5min et 3min avant l'arrivée
|
||||
// estimée — mais seulement si l'ETA le justifie (voir bornes ci-dessous).
|
||||
func TestScheduleETANotifications_SchedulesBothForLongETA(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_long")
|
||||
productID := newTestProduct(t, "EtaNotifLong", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
before := time.Now()
|
||||
if err := testDB.ScheduleETANotifications(cmdID, 10); err != nil {
|
||||
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||
}
|
||||
|
||||
score5, err := getScheduledScore(t, cmdID, "5min")
|
||||
if err != nil {
|
||||
t.Fatalf("notification 5min absente: %v", err)
|
||||
}
|
||||
score3, err := getScheduledScore(t, cmdID, "3min")
|
||||
if err != nil {
|
||||
t.Fatalf("notification 3min absente: %v", err)
|
||||
}
|
||||
|
||||
wantAt5 := before.Add(5 * time.Minute).Unix() // arrivée dans 10min - 5min = dans 5min
|
||||
wantAt3 := before.Add(7 * time.Minute).Unix() // arrivée dans 10min - 3min = dans 7min
|
||||
if diff := abs64(score5 - wantAt5); diff > 2 {
|
||||
t.Errorf("score notification 5min: got=%d want≈%d (écart %ds)", score5, wantAt5, diff)
|
||||
}
|
||||
if diff := abs64(score3 - wantAt3); diff > 2 {
|
||||
t.Errorf("score notification 3min: got=%d want≈%d (écart %ds)", score3, wantAt3, diff)
|
||||
}
|
||||
if score3 <= score5 {
|
||||
t.Errorf("la notification 3min doit être programmée après la 5min (plus proche de l'arrivée): score5=%d score3=%d", score5, score3)
|
||||
}
|
||||
}
|
||||
|
||||
// À la limite exacte (ETA = 5 min), un rappel "5 minutes avant l'arrivée"
|
||||
// se déclencherait immédiatement (redondant) — il n'est donc volontairement
|
||||
// pas programmé (condition stricte ">5", pas ">=5"). Seul le rappel 3min
|
||||
// reste pertinent.
|
||||
func TestScheduleETANotifications_ExactlyFiveMinutes_SkipsFiveMinReminder(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_five")
|
||||
productID := newTestProduct(t, "EtaNotifFive", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.ScheduleETANotifications(cmdID, 5); err != nil {
|
||||
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||
}
|
||||
|
||||
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
|
||||
t.Error("aucune notification 5min ne doit être programmée quand ETA=5min exactement")
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
|
||||
t.Errorf("la notification 3min doit être programmée quand ETA=5min: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// À ETA = 3 min, même le rappel "3 minutes avant" serait immédiat : aucune
|
||||
// notification ne doit être programmée.
|
||||
func TestScheduleETANotifications_ExactlyThreeMinutes_SchedulesNothing(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_three")
|
||||
productID := newTestProduct(t, "EtaNotifThree", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.ScheduleETANotifications(cmdID, 3); err != nil {
|
||||
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||
}
|
||||
|
||||
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
|
||||
t.Error("aucune notification 5min ne doit être programmée pour ETA=3min")
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
|
||||
t.Error("aucune notification 3min ne doit être programmée pour ETA=3min (serait immédiate)")
|
||||
}
|
||||
}
|
||||
|
||||
// Une ETA très courte (MinETA=3min, plancher de tout le système) ne doit
|
||||
// jamais programmer de notification de rappel — cohérent avec le cas ci-dessus.
|
||||
func TestScheduleETANotifications_VeryShortETASchedulesNothing(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_short")
|
||||
productID := newTestProduct(t, "EtaNotifShort", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.ScheduleETANotifications(cmdID, 1); err != nil {
|
||||
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
|
||||
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
|
||||
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
|
||||
}
|
||||
}
|
||||
|
||||
// SetCommandETA (appelée par UpdateDeliveryStatus au passage en "en_route")
|
||||
// déclenche automatiquement ScheduleETANotifications — vérifie l'intégration
|
||||
// complète, pas seulement la fonction isolée.
|
||||
func TestSetCommandETA_TriggersScheduledNotifications(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_integration")
|
||||
productID := newTestProduct(t, "EtaNotifIntegration", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETA(cmdID, 15); err != nil {
|
||||
t.Fatalf("SetCommandETA: %v", err)
|
||||
}
|
||||
|
||||
if _, err := getScheduledScore(t, cmdID, "5min"); err != nil {
|
||||
t.Errorf("SetCommandETA doit programmer une notification 5min: %v", err)
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
|
||||
t.Errorf("SetCommandETA doit programmer une notification 3min: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Le message envoyé au client doit contenir une indication de temps lisible,
|
||||
// pas juste le statut brut.
|
||||
func TestSendETANotification_MessageContainsReadableTime(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_message")
|
||||
productID := newTestProduct(t, "EtaNotifMessage", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
channel := fmt.Sprintf("notifications:command:%d", cmdID)
|
||||
pubsub := db.Redis.Subscribe(db.RedisCtx, channel)
|
||||
defer pubsub.Close()
|
||||
// Consommer le message de confirmation d'abonnement avant de publier.
|
||||
if _, err := pubsub.Receive(db.RedisCtx); err != nil {
|
||||
t.Fatalf("abonnement pubsub: %v", err)
|
||||
}
|
||||
|
||||
testDB.SendETANotification(cmdID, "5min")
|
||||
|
||||
select {
|
||||
case msg := <-pubsub.Channel():
|
||||
if msg.Payload == "" {
|
||||
t.Fatal("message de notification vide")
|
||||
}
|
||||
if !strings.Contains(msg.Payload, "5min") {
|
||||
t.Errorf("le message doit indiquer le temps restant (%q): %q", "5min", msg.Payload)
|
||||
}
|
||||
t.Logf("message reçu: %q", msg.Payload)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("aucun message reçu sur le canal de notification dans le délai imparti")
|
||||
}
|
||||
}
|
||||
|
||||
func abs64(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// getScheduledScore lit le score (timestamp Unix) d'une notification
|
||||
// programmée pour "{commandID}:{suffix}" dans le sorted set Redis.
|
||||
func getScheduledScore(t *testing.T, commandID int, suffix string) (int64, error) {
|
||||
t.Helper()
|
||||
member := fmt.Sprintf("%d:%s", commandID, suffix)
|
||||
score, err := db.Redis.ZScore(db.RedisCtx, notificationsScheduledKey, member).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(score), nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/handlers"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Les clients ont signalé ne jamais voir le temps de livraison (notifications,
|
||||
// app mobile, site web). Ces tests reproduisent le chemin réel : une commande
|
||||
// est assignée automatiquement (le worker de queue appelle
|
||||
// SetCommandETAWithDetails, PAS SetCommandETA), puis un client consulte le
|
||||
// suivi de sa commande — exactement ce que fait l'app mobile / le site web.
|
||||
|
||||
func etaTestContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/x/tracking", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", username)
|
||||
c.Set("role", "client")
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// SetCommandETAWithDetails est le chemin utilisé par le worker
|
||||
// d'auto-assignation/réoptimisation de queue (db/redis_queue_optimization.go,
|
||||
// db/redis_queue_assignment.go) — de loin le plus emprunté en production.
|
||||
// Il doit remplir "eta_minutes", pas seulement "total_eta_minutes", car
|
||||
// c'est le champ que l'app mobile et le site web lisent (confirmé dans
|
||||
// mobile/src/screens/client/OrderTrackingScreen.tsx et api.ts des deux
|
||||
// frontends).
|
||||
func TestSetCommandETAWithDetails_WritesEtaMinutesField(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_details_field")
|
||||
productID := newTestProduct(t, "EtaDetailsField", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETAWithDetails(cmdID, 22, 1); err != nil {
|
||||
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||
}
|
||||
|
||||
etaData, err := testDB.GetCommandETA(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandETA: %v", err)
|
||||
}
|
||||
|
||||
got, ok := etaData["eta_minutes"]
|
||||
if !ok || got == "" {
|
||||
t.Errorf(`champ "eta_minutes" absent après SetCommandETAWithDetails (contenu: %v) — `+
|
||||
`c'est le champ lu par le mobile et le site web, d'où l'absence de temps affiché`, etaData)
|
||||
} else if got != "22" {
|
||||
t.Errorf(`"eta_minutes" = %q, want "22"`, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Reproduction bout-en-bout du symptôme signalé : après une assignation
|
||||
// automatique (SetCommandETAWithDetails), le client consulte le suivi de sa
|
||||
// commande (GetCommandTracking, l'endpoint utilisé par l'app mobile et le
|
||||
// site web) — la réponse doit exposer eta.eta_minutes.
|
||||
func TestGetCommandTracking_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_tracking_client")
|
||||
productID := newTestProduct(t, "EtaTrackingClient", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA2", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETAWithDetails(cmdID, 17, 2); err != nil {
|
||||
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||
}
|
||||
|
||||
c, rec := etaTestContext(username, cmdID)
|
||||
handlers.GetCommandTracking(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
ETA map[string]any `json:"eta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
|
||||
etaMinutesRaw, ok := resp.ETA["eta_minutes"]
|
||||
if !ok {
|
||||
t.Fatalf(`la réponse de /tracking n'expose pas "eta.eta_minutes" (contenu eta: %v) — `+
|
||||
`reproduit exactement le bug signalé par les clients`, resp.ETA)
|
||||
}
|
||||
etaMinutesStr, _ := etaMinutesRaw.(string)
|
||||
if got, _ := strconv.Atoi(etaMinutesStr); got != 17 {
|
||||
t.Errorf("eta.eta_minutes: got=%v want=17", etaMinutesRaw)
|
||||
}
|
||||
}
|
||||
|
||||
// Même reproduction via GetCommandStatus (autre endpoint de suivi, utilisé
|
||||
// par l'app mobile pour le statut temps réel).
|
||||
func TestGetCommandStatus_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_status_client")
|
||||
productID := newTestProduct(t, "EtaStatusClient", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA3", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETAWithDetails(cmdID, 9, 1); err != nil {
|
||||
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||
}
|
||||
|
||||
c, rec := etaTestContext(username, cmdID)
|
||||
handlers.GetCommandStatus(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ETA map[string]any `json:"eta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v", err)
|
||||
}
|
||||
if _, ok := resp.ETA["eta_minutes"]; !ok {
|
||||
t.Fatalf(`GetCommandStatus n'expose pas "eta.eta_minutes" (contenu: %v)`, resp.ETA)
|
||||
}
|
||||
}
|
||||
|
||||
// SetCommandETA (chemin utilisé par UpdateDeliveryStatus côté livreur) doit
|
||||
// lui aussi rester lisible par les lecteurs qui attendent "total_eta_minutes"
|
||||
// (handlers/deleviry.go, validation_deleviry.go, geoloca.go).
|
||||
func TestSetCommandETA_AlsoWritesTotalEtaMinutesField(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_simple_field")
|
||||
productID := newTestProduct(t, "EtaSimpleField", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA4", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
|
||||
t.Fatalf("SetCommandETA: %v", err)
|
||||
}
|
||||
|
||||
etaData, err := testDB.GetCommandETA(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandETA: %v", err)
|
||||
}
|
||||
if got, ok := etaData["total_eta_minutes"]; !ok || got != "14" {
|
||||
t.Errorf(`"total_eta_minutes" = %q (présent=%v), want "14"`, got, ok)
|
||||
}
|
||||
if got, ok := etaData["eta_minutes"]; !ok || got != "14" {
|
||||
t.Errorf(`"eta_minutes" = %q (présent=%v), want "14"`, got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeliverymanLocationForCommand (vue admin/cabine) lisait l'ETA via
|
||||
// Redis.Get sur une clé qui est en réalité un hash (HSet) — l'erreur
|
||||
// WRONGTYPE était silencieusement ignorée et etaMinutes restait toujours à 0.
|
||||
func TestGetDeliverymanLocationForCommand_ExposesEtaMinutes(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_admin_view_client")
|
||||
productID := newTestProduct(t, "EtaAdminView", 10)
|
||||
livreurUsername := testUserPrefix + "eta_admin_view_livreur"
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", livreurUsername, productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETAWithDetails(cmdID, 12, 1); err != nil {
|
||||
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||
}
|
||||
// Position GPS du livreur, requise par le handler avant de lire l'ETA.
|
||||
if err := testDB.UpdateDeliveryPersonLocation(livreurUsername, 47.2148, -1.5584); err != nil {
|
||||
t.Fatalf("UpdateDeliveryPersonLocation: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", "admin_test")
|
||||
c.Set("role", "admin")
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(cmdID)}}
|
||||
|
||||
handlers.GetDeliverymanLocationForCommand(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
ETA struct {
|
||||
Minutes float64 `json:"minutes"`
|
||||
HasETA bool `json:"has_eta"`
|
||||
} `json:"eta"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !resp.Data.ETA.HasETA {
|
||||
t.Errorf("has_eta devrait être true, ETA pourtant définie via SetCommandETAWithDetails")
|
||||
}
|
||||
if resp.Data.ETA.Minutes != 12 {
|
||||
t.Errorf("data.eta.minutes: got=%.0f want=12", resp.Data.ETA.Minutes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/handlers"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newTestDeliveredOrder crée une commande livrée/approuvée assignée à un
|
||||
// livreur avec une date de mise à jour contrôlée (GetMyDeliveryStats groupe
|
||||
// par updated_at, pas created_at).
|
||||
func newTestDeliveredOrder(t *testing.T, livreurUsername, clientUsername, status string, productID int, quantite, prix, referralUsed float64, updatedAt time.Time) {
|
||||
t.Helper()
|
||||
var cmdID int
|
||||
if err := testDB.GDB.Raw(
|
||||
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, referral_used, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`,
|
||||
clientUsername, status, livreurUsername, prix, referralUsed, updatedAt, updatedAt,
|
||||
).Scan(&cmdID).Error; err != nil {
|
||||
t.Fatalf("création commande livrée test: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||
VALUES (?, ?, 'item test', ?, ?, 'delivered')`,
|
||||
cmdID, productID, quantite, prix,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création item commande livrée test: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func livreurStatsContext(username string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/livreur/stats", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", username)
|
||||
c.Set("role", "livreur")
|
||||
return c, rec
|
||||
}
|
||||
|
||||
type deliveryStatsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
TodayCount int `json:"today_count"`
|
||||
TodayRevenue float64 `json:"today_revenue"`
|
||||
}
|
||||
|
||||
// today_count/today_revenue ne doivent compter que les livraisons du jour
|
||||
// courant (livre/approved), nettes du crédit de parrainage — pas les jours
|
||||
// précédents, même récents (voir by_day/by_week qui eux les agrègent).
|
||||
func TestGetMyDeliveryStats_TodayCountAndRevenue(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreurUsername := newTestClient(t, "stats_livreur_today")
|
||||
clientUsername := newTestClient(t, "stats_livreur_today_client")
|
||||
productID := newTestProduct(t, "StatsLivreurToday", 100)
|
||||
|
||||
now := time.Now()
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
|
||||
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 2, 40, 10, now) // net 30, aujourd'hui
|
||||
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 1, 25, 0, yesterday) // hier, ne doit pas compter dans "today"
|
||||
|
||||
c, rec := livreurStatsContext(livreurUsername)
|
||||
handlers.GetMyDeliveryStats(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp deliveryStatsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success=false, body=%s", rec.Body.String())
|
||||
}
|
||||
if resp.TodayCount != 1 {
|
||||
t.Errorf("today_count: got=%d want=1 (la commande d'hier ne doit pas compter)", resp.TodayCount)
|
||||
}
|
||||
if resp.TodayRevenue != 30 {
|
||||
t.Errorf("today_revenue: got=%.2f want=30 (40 - 10 de parrainage)", resp.TodayRevenue)
|
||||
}
|
||||
}
|
||||
|
||||
// Aucune livraison aujourd'hui : today_count/today_revenue doivent être 0,
|
||||
// pas une absence de champ (voir le bug initial où le seul indicateur de
|
||||
// "livraisons du jour" était l'absence de ligne dans by_day).
|
||||
func TestGetMyDeliveryStats_TodayCountZeroWhenNoDeliveryToday(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreurUsername := newTestClient(t, "stats_livreur_none")
|
||||
|
||||
c, rec := livreurStatsContext(livreurUsername)
|
||||
handlers.GetMyDeliveryStats(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp deliveryStatsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if resp.TodayCount != 0 {
|
||||
t.Errorf("today_count: got=%d want=0", resp.TodayCount)
|
||||
}
|
||||
if resp.TodayRevenue != 0 {
|
||||
t.Errorf("today_revenue: got=%.2f want=0", resp.TodayRevenue)
|
||||
}
|
||||
}
|
||||
|
||||
// Seules les commandes assignées à CE livreur doivent être comptées.
|
||||
func TestGetMyDeliveryStats_OnlyCountsOwnDeliveries(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreurA := newTestClient(t, "stats_livreur_a")
|
||||
livreurB := newTestClient(t, "stats_livreur_b")
|
||||
client := newTestClient(t, "stats_livreur_shared_client")
|
||||
productID := newTestProduct(t, "StatsLivreurIsolation", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestDeliveredOrder(t, livreurA, client, "approved", productID, 1, 20, 0, now)
|
||||
newTestDeliveredOrder(t, livreurB, client, "approved", productID, 1, 999, 0, now)
|
||||
|
||||
c, rec := livreurStatsContext(livreurA)
|
||||
handlers.GetMyDeliveryStats(c)
|
||||
|
||||
var resp deliveryStatsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v", err)
|
||||
}
|
||||
if resp.TodayCount != 1 || resp.TodayRevenue != 20 {
|
||||
t.Errorf("stats livreur A ne doivent refléter que ses propres livraisons: got count=%d revenue=%.2f want count=1 revenue=20", resp.TodayCount, resp.TodayRevenue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package tests regroupe les tests de bout en bout de la gestion de stock,
|
||||
// écrits contre l'API publique des paquets db/ et handlers/ (aucun accès à
|
||||
// leurs symboles non exportés) — voir docker-compose.yml pour la base de
|
||||
// test locale nécessaire pour les exécuter (`go test ./tests/...`).
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// testDB est l'instance partagée par tous les tests de ce paquet. Toutes les
|
||||
// données créées utilisent un préfixe dédié (testUserPrefix / testProductPrefix)
|
||||
// et sont nettoyées avant et après chaque test, ce qui rend la suite sans
|
||||
// danger même si elle tourne contre une base partagée.
|
||||
var testDB *db.Database
|
||||
|
||||
const (
|
||||
testUserPrefix = "stocktest_"
|
||||
testProductPrefix = "TESTSTOCK_"
|
||||
)
|
||||
|
||||
// testPhoneCounter garantit un numéro de téléphone unique par client de test
|
||||
// (colonne UNIQUE sur clients.telephone).
|
||||
var testPhoneCounter int64
|
||||
|
||||
func nextTestPhone() string {
|
||||
n := atomic.AddInt64(&testPhoneCounter, 1)
|
||||
return fmt.Sprintf("+3361%09d", n)
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
testDB = db.InitDB()
|
||||
db.InitRedis()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// cleanupStockTestData supprime toutes les données créées par les tests de
|
||||
// gestion de stock (identifiées par leur préfixe), dans le bon ordre pour
|
||||
// respecter les contraintes de clé étrangère.
|
||||
func cleanupStockTestData(t *testing.T) {
|
||||
t.Helper()
|
||||
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username LIKE ?)`, testUserPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM commandes WHERE username LIKE ?`, testUserPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM baskets WHERE username LIKE ?`, testUserPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM clients WHERE username LIKE ?`, testUserPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name LIKE ?)`, testProductPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM products WHERE name LIKE ?`, testProductPrefix+"%")
|
||||
}
|
||||
|
||||
// newTestProduct crée un produit de test avec un stock initial donné et un
|
||||
// prix actif pour quantity=1, et programme son nettoyage en fin de test.
|
||||
func newTestProduct(t *testing.T, name string, stock float64) int {
|
||||
t.Helper()
|
||||
fullName := testProductPrefix + name
|
||||
var id int
|
||||
if err := testDB.GDB.Raw(
|
||||
`INSERT INTO products (name, category, description, stock) VALUES (?, 'test', '', ?) RETURNING id`,
|
||||
fullName, stock,
|
||||
).Scan(&id).Error; err != nil {
|
||||
t.Fatalf("création produit test %q: %v", fullName, err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`,
|
||||
id,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création prix produit test %q: %v", fullName, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id)
|
||||
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// productStock relit le stock courant d'un produit directement en base.
|
||||
func productStock(t *testing.T, productID int) float64 {
|
||||
t.Helper()
|
||||
var stock float64
|
||||
if err := testDB.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, productID).Scan(&stock).Error; err != nil {
|
||||
t.Fatalf("lecture stock produit %d: %v", productID, err)
|
||||
}
|
||||
return stock
|
||||
}
|
||||
|
||||
// newTestClient crée un client de test et programme son nettoyage en fin de test.
|
||||
func newTestClient(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
username := testUserPrefix + name
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO clients (username, password, nom, prenom, telephone) VALUES (?, 'x', 'T', 'C', ?)
|
||||
ON CONFLICT (username) DO NOTHING`,
|
||||
username, nextTestPhone(),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création client test %q: %v", username, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
|
||||
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username = ?)`, username)
|
||||
testDB.GDB.Exec(`DELETE FROM commandes WHERE username = ?`, username)
|
||||
testDB.GDB.Exec(`DELETE FROM clients WHERE username = ?`, username)
|
||||
})
|
||||
return username
|
||||
}
|
||||
|
||||
// newTestCommandWithItem crée directement une commande avec un item (en
|
||||
// contournant le checkout), pour tester isolément les chemins d'annulation
|
||||
// et de remboursement de stock. Retourne l'ID de la commande créée.
|
||||
func newTestCommandWithItem(t *testing.T, username, status, livreurAssign string, productID int, quantite float64, prix float64) int {
|
||||
t.Helper()
|
||||
var cmdID int
|
||||
if err := testDB.GDB.Raw(
|
||||
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, created_at, updated_at)
|
||||
VALUES (?, ?, NULLIF(?, ''), 'Adresse test', ?, NOW(), NOW()) RETURNING id`,
|
||||
username, status, livreurAssign, prix,
|
||||
).Scan(&cmdID).Error; err != nil {
|
||||
t.Fatalf("création commande test: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
|
||||
cmdID, productID, quantite, prix,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création item test: %v", err)
|
||||
}
|
||||
return cmdID
|
||||
}
|
||||
|
||||
func commandStatus(t *testing.T, commandID int) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&status)
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"gestion/handlers"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func clientAmendeAndCount(t *testing.T, username string) (amende float64, count int) {
|
||||
t.Helper()
|
||||
var row struct {
|
||||
Amende float64 `gorm:"column:amende"`
|
||||
CancellationsCount int `gorm:"column:cancellations_count"`
|
||||
}
|
||||
if err := testDB.GDB.Raw(
|
||||
`SELECT COALESCE(amende, 0) AS amende, COALESCE(cancellations_count, 0) AS cancellations_count FROM clients WHERE username = ?`,
|
||||
username,
|
||||
).Scan(&row).Error; err != nil {
|
||||
t.Fatalf("lecture amende/cancellations_count: %v", err)
|
||||
}
|
||||
return row.Amende, row.CancellationsCount
|
||||
}
|
||||
|
||||
// ── CalculateCancellationPenalty : barème par défaut ────────────────────────
|
||||
// (0->20, 1->50, 2->100, 3 et plus->150 — voir DefaultSettings)
|
||||
|
||||
func TestCalculateCancellationPenalty_MatchesDefaultTiers(t *testing.T) {
|
||||
cases := []struct {
|
||||
cancellationsCount int
|
||||
wantPenalty int
|
||||
}{
|
||||
{0, 20},
|
||||
{1, 50},
|
||||
{2, 100},
|
||||
{3, 150},
|
||||
{10, 150}, // palier "4e et plus", plafonné
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "penalty_tier")
|
||||
testDB.GDB.Exec(`UPDATE clients SET cancellations_count = ? WHERE username = ?`, c.cancellationsCount, username)
|
||||
|
||||
got, err := testDB.CalculateCancellationPenalty(username)
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateCancellationPenalty (count=%d): %v", c.cancellationsCount, err)
|
||||
}
|
||||
if got != c.wantPenalty {
|
||||
t.Errorf("count=%d: penalty=%d want=%d", c.cancellationsCount, got, c.wantPenalty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ApplyCancellationPenalty ("client absent" livreur) : cumul + concurrence ─
|
||||
|
||||
func TestApplyCancellationPenalty_AccumulatesAcrossSequentialCalls(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "penalty_sequential")
|
||||
|
||||
wantPenalties := []int{20, 50, 100, 150}
|
||||
wantCumulative := []float64{20, 70, 170, 320}
|
||||
|
||||
for i, want := range wantPenalties {
|
||||
penalty, err := testDB.ApplyCancellationPenalty(username)
|
||||
if err != nil {
|
||||
t.Fatalf("appel %d: %v", i+1, err)
|
||||
}
|
||||
if penalty != want {
|
||||
t.Errorf("appel %d: penalty=%d want=%d", i+1, penalty, want)
|
||||
}
|
||||
amende, count := clientAmendeAndCount(t, username)
|
||||
if amende != wantCumulative[i] {
|
||||
t.Errorf("appel %d: amende cumulée=%.2f want=%.2f", i+1, amende, wantCumulative[i])
|
||||
}
|
||||
if count != i+1 {
|
||||
t.Errorf("appel %d: cancellations_count=%d want=%d", i+1, count, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plusieurs "client absent" quasi simultanés sur le même client (deux
|
||||
// livreurs différents annulant chacun une commande de ce client au même
|
||||
// moment) ne doivent pas se marcher dessus (verrou FOR UPDATE).
|
||||
func TestApplyCancellationPenalty_ConcurrentCallsDoNotLoseUpdates(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "penalty_concurrent")
|
||||
|
||||
n := 4
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
_, errs[idx] = testDB.ApplyCancellationPenalty(username)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Errorf("appel concurrent %d: erreur inattendue: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
amende, count := clientAmendeAndCount(t, username)
|
||||
if count != n {
|
||||
t.Errorf("cancellations_count après %d appels concurrents: got=%d want=%d", n, count, n)
|
||||
}
|
||||
if amende != 320 { // 20+50+100+150, un seul palier consommé par appel
|
||||
t.Errorf("amende après %d appels concurrents: got=%.2f want=320.00 (pas de mise à jour perdue)", n, amende)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CheckCommandETAExistsAndValid (bug corrigé : lisait la clé avec Get au lieu de HGetAll) ─
|
||||
|
||||
func TestCheckCommandETAExistsAndValid_FalseWhenNoETA(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_check_none")
|
||||
productID := newTestProduct(t, "EtaCheckNone", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurCheckETA", productID, 1, 10)
|
||||
|
||||
if testDB.CheckCommandETAExistsAndValid(cmdID) {
|
||||
t.Error("aucune ETA définie: attendu false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCommandETAExistsAndValid_TrueWhenETASet(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_check_valid")
|
||||
productID := newTestProduct(t, "EtaCheckValid", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurCheckETA2", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETA(cmdID, 15); err != nil {
|
||||
t.Fatalf("SetCommandETA: %v", err)
|
||||
}
|
||||
|
||||
if !testDB.CheckCommandETAExistsAndValid(cmdID) {
|
||||
t.Error("ETA valide définie: attendu true")
|
||||
}
|
||||
}
|
||||
|
||||
// ── CancelCommandAtomic : détection d'annulation tardive et pénalité ────────
|
||||
|
||||
func TestCancelCommandAtomic_NoPenaltyWithoutLivreur(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_penalty_no_livreur")
|
||||
productID := newTestProduct(t, "CancelPenaltyNoLivreur", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CancelCommandAtomic: %v", err)
|
||||
}
|
||||
if penalty != 0 {
|
||||
t.Errorf("aucune pénalité attendue sans livreur assigné: got=%d", penalty)
|
||||
}
|
||||
amende, count := clientAmendeAndCount(t, username)
|
||||
if amende != 0 {
|
||||
t.Errorf("amende doit rester à 0: got=%.2f", amende)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("cancellations_count doit tout de même être incrémenté: got=%d want=1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandAtomic_LateCancel_EnRoute_RequiresForceConfirmation(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_penalty_enroute_noforce")
|
||||
productID := newTestProduct(t, "CancelPenaltyEnrouteNoforce", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurLate1", productID, 1, 10)
|
||||
|
||||
_, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
|
||||
if err == nil || err.Error() != "confirmation requise" {
|
||||
t.Fatalf(`attendu l'erreur "confirmation requise", got=%v`, err)
|
||||
}
|
||||
|
||||
if got := commandStatus(t, cmdID); got != "en_route" {
|
||||
t.Errorf("le statut ne doit pas changer sans confirmation: got=%s want=en_route", got)
|
||||
}
|
||||
if got := productStock(t, productID); got != 5 {
|
||||
t.Errorf("le stock ne doit pas être remboursé sans confirmation: got=%.2f want=5", got)
|
||||
}
|
||||
amende, _ := clientAmendeAndCount(t, username)
|
||||
if amende != 0 {
|
||||
t.Errorf("aucune amende ne doit être appliquée sans confirmation: got=%.2f", amende)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandAtomic_LateCancel_Arrived_RequiresForceConfirmation(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_penalty_arrived_noforce")
|
||||
productID := newTestProduct(t, "CancelPenaltyArrivedNoforce", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "arrived", testUserPrefix+"livreurLate2", productID, 1, 10)
|
||||
|
||||
_, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
|
||||
if err == nil || err.Error() != "confirmation requise" {
|
||||
t.Fatalf(`attendu l'erreur "confirmation requise" pour status=arrived, got=%v`, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandAtomic_LateCancelWithForce_AppliesPenalty(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_penalty_enroute_force")
|
||||
productID := newTestProduct(t, "CancelPenaltyEnrouteForce", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurLate3", productID, 2, 20)
|
||||
|
||||
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true)
|
||||
if err != nil {
|
||||
t.Fatalf("CancelCommandAtomic avec force: %v", err)
|
||||
}
|
||||
if penalty != 20 { // 1ère annulation de ce client
|
||||
t.Errorf("penalty: got=%d want=20", penalty)
|
||||
}
|
||||
|
||||
if got := commandStatus(t, cmdID); got != "cancelled" {
|
||||
t.Errorf("statut: got=%s want=cancelled", got)
|
||||
}
|
||||
if got := productStock(t, productID); got != 7 {
|
||||
t.Errorf("stock après annulation confirmée (5 initial + 2 remboursés): got=%.2f want=7", got)
|
||||
}
|
||||
amende, count := clientAmendeAndCount(t, username)
|
||||
if amende != 20 {
|
||||
t.Errorf("amende: got=%.2f want=20", amende)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("cancellations_count: got=%d want=1", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Un livreur assigné mais sans statut avancé (assigned) et sans ETA connue
|
||||
// n'est pas considéré comme une annulation tardive : pas besoin de force.
|
||||
func TestCancelCommandAtomic_AssignedWithoutETA_NoForceNeeded(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_penalty_assigned_noeta")
|
||||
productID := newTestProduct(t, "CancelPenaltyAssignedNoeta", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurLate4", productID, 1, 10)
|
||||
|
||||
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CancelCommandAtomic: %v", err)
|
||||
}
|
||||
if penalty != 0 {
|
||||
t.Errorf("aucune pénalité attendue (pas d'ETA, statut non avancé): got=%d", penalty)
|
||||
}
|
||||
}
|
||||
|
||||
// Un livreur assigné avec une ETA valide en cache doit être traité comme une
|
||||
// annulation tardive, même si le statut est encore "assigned" (régression du
|
||||
// bug CheckCommandETAExistsAndValid corrigé ci-dessus).
|
||||
func TestCancelCommandAtomic_AssignedWithValidETA_RequiresForce(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_penalty_assigned_eta")
|
||||
productID := newTestProduct(t, "CancelPenaltyAssignedEta", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurLate5", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETA(cmdID, 12); err != nil {
|
||||
t.Fatalf("SetCommandETA: %v", err)
|
||||
}
|
||||
|
||||
_, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
|
||||
if err == nil || err.Error() != "confirmation requise" {
|
||||
t.Fatalf(`attendu "confirmation requise" (ETA valide définie): got=%v`, err)
|
||||
}
|
||||
|
||||
// Avec force=true, la pénalité doit maintenant s'appliquer.
|
||||
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true)
|
||||
if err != nil {
|
||||
t.Fatalf("CancelCommandAtomic avec force: %v", err)
|
||||
}
|
||||
if penalty != 20 {
|
||||
t.Errorf("penalty: got=%d want=20", penalty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandAtomic_PenaltyProgressesAcrossMultipleLateCancellations(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_penalty_progression")
|
||||
productID := newTestProduct(t, "CancelPenaltyProgression", 20)
|
||||
|
||||
wantPenalties := []int{20, 50, 100, 150}
|
||||
for i, want := range wantPenalties {
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurProg", productID, 1, 10)
|
||||
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true)
|
||||
if err != nil {
|
||||
t.Fatalf("annulation %d: %v", i+1, err)
|
||||
}
|
||||
if penalty != want {
|
||||
t.Errorf("annulation %d: penalty=%d want=%d", i+1, penalty, want)
|
||||
}
|
||||
}
|
||||
|
||||
amende, count := clientAmendeAndCount(t, username)
|
||||
if count != 4 {
|
||||
t.Errorf("cancellations_count: got=%d want=4", count)
|
||||
}
|
||||
if amende != 320 { // 20+50+100+150
|
||||
t.Errorf("amende cumulée: got=%.2f want=320.00", amende)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandAtomic_NonCancellableStatusRejected(t *testing.T) {
|
||||
for _, status := range []string{"livre", "approved", "cancelled", "disabled"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_penalty_terminal_"+status)
|
||||
productID := newTestProduct(t, "CancelPenaltyTerminal"+status, 5)
|
||||
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
|
||||
|
||||
if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", true); err == nil {
|
||||
t.Errorf("statut %q devrait être rejeté même avec force=true", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandAtomic_WrongOwnerRejected(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "cancel_penalty_owner")
|
||||
intruder := newTestClient(t, "cancel_penalty_intruder")
|
||||
productID := newTestProduct(t, "CancelPenaltyOwner", 5)
|
||||
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
|
||||
|
||||
if _, err := testDB.CancelCommandAtomic(cmdID, intruder, "test", false); err == nil {
|
||||
t.Error("un client ne doit pas pouvoir annuler la commande d'un autre client")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flux "client absent" (livreur annule depuis 'arrived') via le handler ───
|
||||
|
||||
func deliveryStatusContext(livreurUsername string, commandID int, status, notes string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
body := []byte(`{"status":"` + status + `","notes":"` + notes + `"}`)
|
||||
req := httptest.NewRequest(http.MethodPut, "/x", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", livreurUsername)
|
||||
c.Set("role", "livreur")
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// Quand le livreur marque le client absent (annulation depuis "arrived"),
|
||||
// l'amende doit être appliquée au CLIENT, jamais au livreur — règle métier
|
||||
// explicite (comprehension-metier).
|
||||
func TestUpdateDeliveryStatus_ClientAbsent_AppliesPenaltyToClientNotLivreur(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
clientUsername := newTestClient(t, "penalty_absent_client")
|
||||
livreurUsername := testUserPrefix + "penalty_absent_livreur"
|
||||
productID := newTestProduct(t, "PenaltyAbsent", 5)
|
||||
cmdID := newTestCommandWithItem(t, clientUsername, "arrived", livreurUsername, productID, 1, 10)
|
||||
|
||||
c, rec := deliveryStatusContext(livreurUsername, cmdID, "cancelled", "Client absent")
|
||||
handlers.UpdateDeliveryStatus(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
clientAmende, clientCount := clientAmendeAndCount(t, clientUsername)
|
||||
if clientAmende != 20 {
|
||||
t.Errorf("amende client après 'client absent': got=%.2f want=20", clientAmende)
|
||||
}
|
||||
if clientCount != 1 {
|
||||
t.Errorf("cancellations_count client: got=%d want=1", clientCount)
|
||||
}
|
||||
|
||||
if got := commandStatus(t, cmdID); got != "cancelled" {
|
||||
t.Errorf("statut commande: got=%s want=cancelled", got)
|
||||
}
|
||||
if got := productStock(t, productID); got != 6 {
|
||||
t.Errorf("stock après remboursement (5 initial + 1 remboursé): got=%.2f want=6", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Annuler depuis un statut autre que "arrived"/"livre" (ex: "en_route") ne
|
||||
// doit PAS déclencher la pénalité "client absent" — ce n'est pas le même
|
||||
// motif d'annulation.
|
||||
func TestUpdateDeliveryStatus_CancelFromEnRoute_DoesNotApplyClientAbsentPenalty(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
clientUsername := newTestClient(t, "penalty_enroute_client")
|
||||
livreurUsername := testUserPrefix + "penalty_enroute_livreur"
|
||||
productID := newTestProduct(t, "PenaltyEnrouteCancel", 5)
|
||||
cmdID := newTestCommandWithItem(t, clientUsername, "en_route", livreurUsername, productID, 1, 10)
|
||||
|
||||
c, rec := deliveryStatusContext(livreurUsername, cmdID, "cancelled", "Problème livraison")
|
||||
handlers.UpdateDeliveryStatus(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
clientAmende, _ := clientAmendeAndCount(t, clientUsername)
|
||||
if clientAmende != 0 {
|
||||
t.Errorf("aucune amende ne doit être appliquée depuis en_route: got=%.2f", clientAmende)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"gestion/handlers"
|
||||
"gestion/models"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/points/claim", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", username)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
|
||||
t.Helper()
|
||||
settings := db.DefaultSettings()
|
||||
settings.PointsReward = reward
|
||||
if err := testDB.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Flux complet réel : POST /points/claim avec un seuil atteint doit ajouter
|
||||
// le produit récompense configuré au panier et décompter la récompense.
|
||||
func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_flow")
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Type: "free_product",
|
||||
Description: "Un produit offert",
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
RemainingRewards int `json:"remaining_rewards"`
|
||||
ProductAdded bool `json:"product_added"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !resp.Success || !resp.ProductAdded {
|
||||
t.Fatalf("réclamation devrait réussir avec produit ajouté: %+v", resp)
|
||||
}
|
||||
if resp.RemainingRewards != 0 {
|
||||
t.Errorf("remaining_rewards: got=%d want=0", resp.RemainingRewards)
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 1 || rows[0].ProductID != rewardProductID {
|
||||
t.Errorf("le produit récompense doit être dans le panier: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_below")
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Type: "free_product",
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 5)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusConflict, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Si un item récompense configuré par l'admin pointe vers un produit
|
||||
// supprimé/inexistant, la réclamation entière doit échouer — la récompense
|
||||
// ne doit pas être consommée sans qu'aucun produit ne soit livré au client
|
||||
// (ClaimPoolReward + AddRewardsToBasket sont maintenant dans la même
|
||||
// transaction via ClaimPoolRewardAndAddToBasket).
|
||||
func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_missing_product")
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Type: "free_product",
|
||||
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("la réclamation ne doit pas réussir avec un produit récompense invalide, body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 0 {
|
||||
t.Errorf("la récompense ne doit PAS être consommée si le produit est introuvable: got redeemed=%d want=0", redeemed["pool_0"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setClientPoolPoints fixe directement les points cumulés d'un client pour un
|
||||
// pool donné (contourne le flux normal d'accumulation pour tester isolément
|
||||
// la réclamation de récompense).
|
||||
func setClientPoolPoints(t *testing.T, username, poolKey string, points int) {
|
||||
t.Helper()
|
||||
if err := testDB.GDB.Exec(
|
||||
`UPDATE clients SET points_extra = jsonb_set(COALESCE(points_extra, '{}'::jsonb), ARRAY[?], to_jsonb(?::int)) WHERE username = ?`,
|
||||
poolKey, points, username,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("setClientPoolPoints: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type rewardBasketRow struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
}
|
||||
|
||||
func basketRewardItems(t *testing.T, username string) []rewardBasketRow {
|
||||
t.Helper()
|
||||
var rows []rewardBasketRow
|
||||
if err := testDB.GDB.Raw(
|
||||
`SELECT product_id, quantity, price, is_reward, reward_pool_key
|
||||
FROM baskets WHERE username = ? AND is_reward = true`, username,
|
||||
).Scan(&rows).Error; err != nil {
|
||||
t.Fatalf("lecture panier récompense: %v", err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// ── ClaimPoolReward : seuil, atomicité, épuisement ──────────────────────────
|
||||
|
||||
func TestClaimPoolReward_BelowThresholdFails(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_below_threshold")
|
||||
setClientPoolPoints(t, username, "pool_0", 19)
|
||||
|
||||
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
|
||||
t.Fatal("attendu une erreur : 19 points < seuil 20")
|
||||
} else if !strings.Contains(err.Error(), "pas de récompense disponible") {
|
||||
t.Errorf("message d'erreur inattendu: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimPoolReward_ExactlyAtThresholdSucceeds(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_exact_threshold")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimPoolReward: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Errorf("remaining: got=%d want=0 (1 récompense gagnée, 1 réclamée)", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimPoolReward_MultipleRewardsEarned(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_multiple")
|
||||
setClientPoolPoints(t, username, "pool_0", 45) // 45/20 = 2 récompenses gagnées
|
||||
|
||||
remaining1, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("1er claim: %v", err)
|
||||
}
|
||||
if remaining1 != 1 {
|
||||
t.Errorf("après 1er claim: got=%d want=1", remaining1)
|
||||
}
|
||||
|
||||
remaining2, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("2e claim: %v", err)
|
||||
}
|
||||
if remaining2 != 0 {
|
||||
t.Errorf("après 2e claim: got=%d want=0", remaining2)
|
||||
}
|
||||
|
||||
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
|
||||
t.Fatal("3e claim: attendu une erreur (récompenses épuisées)")
|
||||
}
|
||||
}
|
||||
|
||||
// Trois réclamations concurrentes pour un client n'ayant droit qu'à UNE seule
|
||||
// récompense ne doivent en laisser passer qu'une seule (verrou FOR UPDATE).
|
||||
func TestClaimPoolReward_ConcurrentClaimsDoNotOverclaim(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_concurrent")
|
||||
setClientPoolPoints(t, username, "pool_0", 20) // 1 seule récompense disponible
|
||||
|
||||
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.ClaimPoolReward(username, "pool_0", 20)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Errorf("un seul claim concurrent doit réussir: got=%d succès", successCount)
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 1 {
|
||||
t.Errorf("compteur redeemed après claims concurrents: got=%d want=1", redeemed["pool_0"])
|
||||
}
|
||||
}
|
||||
|
||||
// Les pools sont indépendants : les points d'un pool ne doivent pas permettre
|
||||
// de réclamer une récompense sur un autre pool.
|
||||
func TestClaimPoolReward_PoolsAreIndependent(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_pool_isolation")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
// pool_1 n'a aucun point.
|
||||
|
||||
if _, err := testDB.ClaimPoolReward(username, "pool_1", 20); err == nil {
|
||||
t.Fatal("attendu une erreur : aucun point sur pool_1")
|
||||
}
|
||||
if remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
|
||||
t.Errorf("pool_0 devrait rester réclamable: %v", err)
|
||||
} else if remaining != 0 {
|
||||
t.Errorf("remaining pool_0: got=%d want=0", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ClaimPoolRewardAndAddToBasket : atomicité réclamation + livraison ───────
|
||||
|
||||
func TestClaimPoolRewardAndAddToBasket_SucceedsAndDecrementsAvailable(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_combined_ok")
|
||||
productID := newTestProduct(t, "RewardCombinedOk", 5)
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
remaining, added, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
|
||||
[]models.RewardItem{{ProductID: productID, Quantity: 1, Price: 10}})
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimPoolRewardAndAddToBasket: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Errorf("remaining: got=%d want=0", remaining)
|
||||
}
|
||||
if len(added) != 1 {
|
||||
t.Fatalf("articles ajoutés: got=%d want=1", len(added))
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 1 {
|
||||
t.Errorf("redeemed: got=%d want=1", redeemed["pool_0"])
|
||||
}
|
||||
}
|
||||
|
||||
// Si le produit récompense est introuvable, ni la récompense ni le panier ne
|
||||
// doivent être modifiés (rollback complet de la transaction combinée).
|
||||
func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_combined_rollback")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
_, _, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
|
||||
[]models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}})
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur pour un produit récompense inexistant")
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 0 {
|
||||
t.Errorf("la récompense ne doit pas être consommée si l'ajout au panier échoue: got redeemed=%d want=0", redeemed["pool_0"])
|
||||
}
|
||||
if rows := basketRewardItems(t, username); len(rows) != 0 {
|
||||
t.Errorf("aucun article récompense ne doit rester en panier: got=%d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// ── AddRewardsToBasket : flags et remplacement ──────────────────────────────
|
||||
|
||||
func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_basket_flags")
|
||||
productID := newTestProduct(t, "RewardBasketFlags", 20)
|
||||
|
||||
items := []models.RewardItem{{ProductID: productID, Quantity: 2, Price: 15.0}}
|
||||
added, err := testDB.AddRewardsToBasket(username, items, "pool_0")
|
||||
if err != nil {
|
||||
t.Fatalf("AddRewardsToBasket: %v", err)
|
||||
}
|
||||
if len(added) != 1 {
|
||||
t.Fatalf("nombre d'articles ajoutés: got=%d want=1", len(added))
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("articles récompense en base: got=%d want=1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if !row.IsReward {
|
||||
t.Error("is_reward doit être true")
|
||||
}
|
||||
if row.RewardPoolKey != "pool_0" {
|
||||
t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0")
|
||||
}
|
||||
if row.Price != 0 {
|
||||
t.Errorf("prix affiché doit être 0 (gratuit): got=%.2f", row.Price)
|
||||
}
|
||||
if row.Quantity != 2 {
|
||||
t.Errorf("quantité: got=%.2f want=2", row.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// Réclamer une nouvelle récompense doit remplacer les articles récompense
|
||||
// précédents, pas les cumuler (évite d'accumuler indéfiniment des articles
|
||||
// gratuits si le client reclique plusieurs fois).
|
||||
func TestAddRewardsToBasket_ReplacesPreviousRewardItems(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_basket_replace")
|
||||
productA := newTestProduct(t, "RewardReplaceA", 20)
|
||||
productB := newTestProduct(t, "RewardReplaceB", 20)
|
||||
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productA, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
|
||||
t.Fatalf("1er AddRewardsToBasket: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productB, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
|
||||
t.Fatalf("2e AddRewardsToBasket: %v", err)
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("un seul article récompense doit rester après remplacement: got=%d", len(rows))
|
||||
}
|
||||
if rows[0].ProductID != productB {
|
||||
t.Errorf("l'article récompense restant doit être le dernier réclamé: got=%d want=%d", rows[0].ProductID, productB)
|
||||
}
|
||||
}
|
||||
|
||||
// Un article récompense pointant vers un produit inexistant doit faire
|
||||
// échouer l'ajout, sans rien insérer du tout (transaction).
|
||||
func TestAddRewardsToBasket_FailsOnUnknownProduct(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_basket_unknown")
|
||||
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}}, "pool_0"); err == nil {
|
||||
t.Fatal("attendu une erreur pour un produit inexistant")
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("aucun article ne doit être ajouté si le produit est introuvable: got=%d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chaîne complète : réclamation -> panier -> checkout -> stock ────────────
|
||||
|
||||
// C'est le scénario demandé explicitement : vérifier que le stock est bien
|
||||
// déduit pour un article obtenu par récompense, exactement comme un article
|
||||
// payant (règle métier explicite : les récompenses ne sont jamais exclues du
|
||||
// décompte de stock).
|
||||
func TestRewardClaim_FullChain_DecrementsStockAtCheckout(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_full_chain")
|
||||
rewardProductID := newTestProduct(t, "RewardFullChainFree", 5)
|
||||
paidProductID := newTestProduct(t, "RewardFullChainPaid", 10)
|
||||
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimPoolReward: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Errorf("remaining: got=%d want=0", remaining)
|
||||
}
|
||||
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
|
||||
t.Fatalf("AddRewardsToBasket: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(username, paidProductID, 3); err != nil {
|
||||
t.Fatalf("AddToBasket (article payant): %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
if got := productStock(t, rewardProductID); got != 3 {
|
||||
t.Errorf("stock article récompense après checkout (5 initial - 2 offerts): got=%.2f want=3", got)
|
||||
}
|
||||
if got := productStock(t, paidProductID); got != 7 {
|
||||
t.Errorf("stock article payant après checkout (10 initial - 3 achetés): got=%.2f want=7", got)
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 1 {
|
||||
t.Errorf("compteur de récompenses réclamées après checkout: got=%d want=1", redeemed["pool_0"])
|
||||
}
|
||||
}
|
||||
|
||||
// Si le checkout échoue (stock insuffisant sur l'article payant du même
|
||||
// panier), l'article récompense ne doit pas non plus voir son stock décrémenté
|
||||
// (rollback complet, cohérent avec le comportement déjà vérifié pour les
|
||||
// articles payants).
|
||||
func TestRewardClaim_CheckoutFailure_DoesNotDecrementRewardStock(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_checkout_fail")
|
||||
rewardProductID := newTestProduct(t, "RewardCheckoutFailFree", 5)
|
||||
shortProductID := newTestProduct(t, "RewardCheckoutFailShort", 1)
|
||||
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
|
||||
t.Fatalf("ClaimPoolReward: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
|
||||
t.Fatalf("AddRewardsToBasket: %v", err)
|
||||
}
|
||||
// Article payant en rupture pour forcer l'échec du checkout.
|
||||
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)
|
||||
}
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
|
||||
t.Fatal("attendu un échec de checkout (stock insuffisant sur l'article payant)")
|
||||
}
|
||||
|
||||
if got := productStock(t, rewardProductID); got != 5 {
|
||||
t.Errorf("stock article récompense ne doit pas bouger si le checkout échoue: got=%.2f want=5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ResetClientRedeemed (admin) ──────────────────────────────────────────────
|
||||
|
||||
func TestResetClientRedeemed_SpecificPool(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_reset_specific")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
setClientPoolPoints(t, username, "pool_1", 20)
|
||||
testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
testDB.ClaimPoolReward(username, "pool_1", 20)
|
||||
|
||||
if err := testDB.ResetClientRedeemed(username, "pool_0"); err != nil {
|
||||
t.Fatalf("ResetClientRedeemed: %v", err)
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 0 {
|
||||
t.Errorf("pool_0 doit être remis à zéro: got=%d", redeemed["pool_0"])
|
||||
}
|
||||
if redeemed["pool_1"] != 1 {
|
||||
t.Errorf("pool_1 ne doit pas être affecté: got=%d want=1", redeemed["pool_1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetClientRedeemed_AllPools(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_reset_all")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
setClientPoolPoints(t, username, "pool_1", 20)
|
||||
testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
testDB.ClaimPoolReward(username, "pool_1", 20)
|
||||
|
||||
if err := testDB.ResetClientRedeemed(username, ""); err != nil {
|
||||
t.Fatalf("ResetClientRedeemed: %v", err)
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if len(redeemed) != 0 {
|
||||
t.Errorf("tous les pools doivent être remis à zéro: got=%v", redeemed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestOrderForStats crée directement une commande (+ un item) avec un
|
||||
// statut, un total, un crédit de parrainage utilisé et une date de création
|
||||
// contrôlés, pour tester isolément les calculs de stats admin.
|
||||
func newTestOrderForStats(t *testing.T, username, status string, productID int, quantite, prix, referralUsed float64, createdAt time.Time) int {
|
||||
t.Helper()
|
||||
var cmdID int
|
||||
if err := testDB.GDB.Raw(
|
||||
`INSERT INTO commandes (username, status, adresse, total_prix, referral_used, created_at, updated_at)
|
||||
VALUES (?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`,
|
||||
username, status, prix, referralUsed, createdAt, createdAt,
|
||||
).Scan(&cmdID).Error; err != nil {
|
||||
t.Fatalf("création commande stats test: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
|
||||
cmdID, productID, quantite, prix,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création item stats test: %v", err)
|
||||
}
|
||||
return cmdID
|
||||
}
|
||||
|
||||
// TotalRevenue ne compte que les commandes approuvées, nettes du crédit de
|
||||
// parrainage utilisé — les commandes annulées ou encore en cours sont exclues.
|
||||
func TestTotalRevenue_NetsReferralAndExcludesNonApproved(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_total_revenue")
|
||||
productID := newTestProduct(t, "StatsTotalRevenue", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productID, 5, 100, 30, now) // net 70
|
||||
newTestOrderForStats(t, username, "approved", productID, 2, 50, 0, now) // net 50
|
||||
newTestOrderForStats(t, username, "cancelled", productID, 9, 999, 0, now) // exclue
|
||||
newTestOrderForStats(t, username, "pending", productID, 9, 999, 0, now) // exclue (pas encore approuvée)
|
||||
|
||||
total, err := testDB.TotalRevenue(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalRevenue: %v", err)
|
||||
}
|
||||
if total != 120 {
|
||||
t.Errorf("TotalRevenue: got=%.2f want=120 (70+50, parrainage déduit, annulée/pending exclues)", total)
|
||||
}
|
||||
}
|
||||
|
||||
// TotalOrders exclut uniquement les commandes annulées (contrairement à
|
||||
// TotalRevenue, il compte aussi les commandes non encore approuvées).
|
||||
func TestTotalOrders_ExcludesOnlyCancelled(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_total_orders")
|
||||
productID := newTestProduct(t, "StatsTotalOrders", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "pending", productID, 1, 10, 0, now)
|
||||
newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, now)
|
||||
newTestOrderForStats(t, username, "cancelled", productID, 1, 10, 0, now)
|
||||
|
||||
total, err := testDB.TotalOrders(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalOrders: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("TotalOrders: got=%d want=2 (pending+approved, cancelled exclue)", total)
|
||||
}
|
||||
}
|
||||
|
||||
// Régression du bug corrigé : le revenu par produit/catégorie (TopProducts,
|
||||
// QuantityBreakdown, DailyProductDetailForDate) doit rester cohérent avec
|
||||
// TotalRevenue même quand une commande approuvée a utilisé du crédit de
|
||||
// parrainage — avant le fix, ces trois vues sommaient ci.prix brut sans
|
||||
// déduire referral_used, produisant un total supérieur au résumé global.
|
||||
func TestProductBreakdowns_RevenueMatchesTotalRevenue_WithReferralUsed(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_breakdown_referral")
|
||||
productA := newTestProduct(t, "StatsBreakdownA", 100)
|
||||
productB := newTestProduct(t, "StatsBreakdownB", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productA, 5, 100, 30, now) // net 70
|
||||
newTestOrderForStats(t, username, "approved", productB, 2, 50, 0, now) // net 50
|
||||
|
||||
totalRevenue, err := testDB.TotalRevenue(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalRevenue: %v", err)
|
||||
}
|
||||
if totalRevenue != 120 {
|
||||
t.Fatalf("précondition TotalRevenue: got=%.2f want=120", totalRevenue)
|
||||
}
|
||||
|
||||
var prodRows []models.ProductRow
|
||||
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
|
||||
t.Fatalf("TopProducts: %v", err)
|
||||
}
|
||||
sumTop := 0.0
|
||||
for _, r := range prodRows {
|
||||
sumTop += r.Revenue
|
||||
}
|
||||
if sumTop != totalRevenue {
|
||||
t.Errorf("somme TopProducts.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumTop, totalRevenue)
|
||||
}
|
||||
|
||||
var qtyRows []models.QuantityBreakdownRow
|
||||
if err := testDB.QuantityBreakdown(&qtyRows, time.Time{}); err != nil {
|
||||
t.Fatalf("QuantityBreakdown: %v", err)
|
||||
}
|
||||
sumQty := 0.0
|
||||
for _, r := range qtyRows {
|
||||
sumQty += r.Revenue
|
||||
}
|
||||
if sumQty != totalRevenue {
|
||||
t.Errorf("somme QuantityBreakdown.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumQty, totalRevenue)
|
||||
}
|
||||
|
||||
var dailyRows []models.DailyProductRow
|
||||
if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil {
|
||||
t.Fatalf("DailyProductDetailForDate: %v", err)
|
||||
}
|
||||
sumDaily := 0.0
|
||||
for _, r := range dailyRows {
|
||||
sumDaily += r.Revenue
|
||||
}
|
||||
if sumDaily != totalRevenue {
|
||||
t.Errorf("somme DailyProductDetailForDate.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumDaily, totalRevenue)
|
||||
}
|
||||
}
|
||||
|
||||
// Cas limite : commande entièrement couverte par le crédit de parrainage
|
||||
// (total_prix == referral_used) — la part de revenu attribuée à l'article
|
||||
// doit être 0, sans division par zéro ni erreur SQL.
|
||||
func TestProductBreakdowns_FullyCoveredByReferralYieldsZeroRevenue(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_breakdown_full_referral")
|
||||
productID := newTestProduct(t, "StatsBreakdownFullReferral", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productID, 4, 40, 40, now)
|
||||
|
||||
var prodRows []models.ProductRow
|
||||
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
|
||||
t.Fatalf("TopProducts: %v", err)
|
||||
}
|
||||
if len(prodRows) != 1 {
|
||||
t.Fatalf("attendu 1 produit, got=%d", len(prodRows))
|
||||
}
|
||||
if prodRows[0].Revenue != 0 {
|
||||
t.Errorf("revenu attendu à 0 pour une commande entièrement couverte par le parrainage: got=%.2f", prodRows[0].Revenue)
|
||||
}
|
||||
}
|
||||
|
||||
// RevenueByDayLast30 doit rester cohérent avec TotalRevenue pour des
|
||||
// commandes créées aujourd'hui (dans la fenêtre des 30 derniers jours).
|
||||
func TestRevenueByDayLast30_MatchesTotalRevenue(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_revenue_by_day")
|
||||
productID := newTestProduct(t, "StatsRevenueByDay", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productID, 3, 90, 20, now) // net 70
|
||||
|
||||
totalRevenue, err := testDB.TotalRevenue(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalRevenue: %v", err)
|
||||
}
|
||||
|
||||
var dayRevRows []models.DayRevenueRow
|
||||
if err := testDB.RevenueByDayLast30(&dayRevRows, time.Time{}); err != nil {
|
||||
t.Fatalf("RevenueByDayLast30: %v", err)
|
||||
}
|
||||
sum := 0.0
|
||||
for _, r := range dayRevRows {
|
||||
sum += r.Revenue
|
||||
}
|
||||
if sum != totalRevenue {
|
||||
t.Errorf("somme RevenueByDayLast30 = %.2f, doit correspondre à TotalRevenue = %.2f", sum, totalRevenue)
|
||||
}
|
||||
}
|
||||
|
||||
// DailyProductDetailForDate ne doit inclure que les commandes du jour demandé.
|
||||
func TestDailyProductDetailForDate_OnlyIncludesGivenDate(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_daily_date_filter")
|
||||
productID := newTestProduct(t, "StatsDailyDateFilter", 100)
|
||||
today := time.Now()
|
||||
yesterday := today.AddDate(0, 0, -1)
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, today)
|
||||
newTestOrderForStats(t, username, "approved", productID, 1, 20, 0, yesterday)
|
||||
|
||||
var rows []models.DailyProductRow
|
||||
if err := testDB.DailyProductDetailForDate(&rows, today); err != nil {
|
||||
t.Fatalf("DailyProductDetailForDate: %v", err)
|
||||
}
|
||||
sum := 0.0
|
||||
for _, r := range rows {
|
||||
sum += r.Revenue
|
||||
}
|
||||
if sum != 10 {
|
||||
t.Errorf("revenu du jour ne doit inclure que la commande d'aujourd'hui: got=%.2f want=10", sum)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func firstItemID(t *testing.T, commandID int) int {
|
||||
t.Helper()
|
||||
var itemID int
|
||||
if err := testDB.GDB.Raw(`SELECT id FROM command_items WHERE command_id = ? LIMIT 1`, commandID).Scan(&itemID).Error; err != nil {
|
||||
t.Fatalf("lecture item de la commande %d: %v", commandID, err)
|
||||
}
|
||||
return itemID
|
||||
}
|
||||
|
||||
// Supprimer un item d'une commande encore active doit restituer le stock de
|
||||
// cet item.
|
||||
func TestDeleteCommandItem_RestoresStockOnActiveOrder(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delitem_active")
|
||||
productID := newTestProduct(t, "DelItemActive", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
|
||||
itemID := firstItemID(t, cmdID)
|
||||
|
||||
if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil {
|
||||
t.Fatalf("DeleteCommandItem: %v", err)
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 8 {
|
||||
t.Errorf("stock après suppression d'item sur commande active (5 initial + 3 remboursés): got=%.2f want=8", got)
|
||||
}
|
||||
|
||||
var remaining int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM command_items WHERE id = ?`, itemID).Scan(&remaining)
|
||||
if remaining != 0 {
|
||||
t.Errorf("l'item doit être supprimé: got=%d lignes restantes", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// Sur une commande déjà terminale (approuvée/livrée/annulée), le stock a
|
||||
// déjà été traité par le chemin correspondant — supprimer un item a
|
||||
// posteriori ne doit pas rembourser une seconde fois.
|
||||
func TestDeleteCommandItem_DoesNotRestoreStockOnTerminalOrder(t *testing.T) {
|
||||
for _, status := range []string{"approved", "livre", "cancelled"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delitem_"+status)
|
||||
productID := newTestProduct(t, "DelItem"+status, 5)
|
||||
cmdID := newTestCommandWithItem(t, username, status, "", productID, 3, 30)
|
||||
itemID := firstItemID(t, cmdID)
|
||||
|
||||
if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil {
|
||||
t.Fatalf("DeleteCommandItem: %v", err)
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 5 {
|
||||
t.Errorf("stock ne doit pas bouger pour une commande %q: got=%.2f want=5", status, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteCommandItem verrouille désormais le statut de la commande (FOR
|
||||
// UPDATE) avant de décider de rembourser, dans la même transaction — ce test
|
||||
// vérifie qu'une suppression d'item et une annulation complète de la même
|
||||
// commande, déclenchées en concurrence, ne remboursent le stock qu'une
|
||||
// seule fois (peu importe laquelle des deux "gagne" la course).
|
||||
func TestDeleteCommandItem_ConcurrentWithFullCancelDoesNotDoubleRefund(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delitem_concurrent")
|
||||
productID := newTestProduct(t, "DelItemConcurrent", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
|
||||
itemID := firstItemID(t, cmdID)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_ = testDB.DeleteCommandItem(cmdID, itemID)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, _ = testDB.CancelCommandAtomic(cmdID, username, "test", false)
|
||||
}()
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if got := productStock(t, productID); got != 8 {
|
||||
t.Errorf("stock après suppression d'item + annulation complète concurrentes (5 initial + 3 remboursés une seule fois attendu): got=%.2f want=8", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SetProductStock (override direct admin) ─────────────────────────────────
|
||||
|
||||
func TestSetProductStock_SetsExactValue(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "SetStockDirect", 5)
|
||||
|
||||
if err := testDB.SetProductStock(productID, 42); err != nil {
|
||||
t.Fatalf("SetProductStock: %v", err)
|
||||
}
|
||||
if got := productStock(t, productID); got != 42 {
|
||||
t.Errorf("stock après SetProductStock: got=%.2f want=42", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProductStock_RejectsUnknownProduct(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
if err := testDB.SetProductStock(-1, 10); err == nil {
|
||||
t.Fatal("attendu une erreur pour un produit inexistant")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user