chore: fix bug
Backend - Build & Lint / build (push) Has been cancelled

This commit is contained in:
Nuxgrid
2026-07-11 11:45:13 +02:00
parent f531ddcda8
commit f674b6ef80
29 changed files with 3621 additions and 313 deletions
@@ -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
}
+208
View File
@@ -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)
}
}
+137
View File
@@ -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)
}
}
+140
View File
@@ -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
}
+405
View File
@@ -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"])
}
}
+411
View File
@@ -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)
}
}
+208
View File
@@ -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)
}
}
+257
View File
@@ -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)
}
}
+116
View File
@@ -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")
}
}