chore: build
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"gestion/services"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ces tests appellent le vrai service Nominatim (réseau réel, rate-limité à
|
||||
// 1 req/s — voir services/adresses_correction.go). Contrairement aux tests
|
||||
// purs de services/adresses_correction_test.go (normalisation, décomposition,
|
||||
// scoring — sans réseau), ceux-ci vérifient le comportement de bout en bout
|
||||
// de ResolveAddress sur de vraies adresses nantaises mal écrites.
|
||||
//
|
||||
// Chaque cas a été vérifié manuellement au préalable (curl vers l'API
|
||||
// Nominatim) pour confirmer ce que la recherche directe résout déjà seule
|
||||
// (Nominatim tolère nativement la casse, les accents et certaines
|
||||
// abréviations sans point) et ce qui nécessite réellement la logique de
|
||||
// correction (variantes, décomposition structurée, repli ville+code postal).
|
||||
//
|
||||
// Un délai explicite sépare chaque cas, en plus du throttle déjà appliqué à
|
||||
// chaque requête HTTP interne (1.1s dans queryNominatim), par courtoisie
|
||||
// envers le service public.
|
||||
|
||||
const (
|
||||
nantesLatMin, nantesLatMax = 47.15, 47.28
|
||||
nantesLonMin, nantesLonMax = -1.65, -1.45
|
||||
)
|
||||
|
||||
func isWithinNantes(lat, lon float64) bool {
|
||||
return lat >= nantesLatMin && lat <= nantesLatMax && lon >= nantesLonMin && lon <= nantesLonMax
|
||||
}
|
||||
|
||||
func TestResolveAddress_RealNantesAddresses(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||
}
|
||||
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
correction := services.NewAddressCorrectionService(geoService)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
minConfidence float64
|
||||
maxConfidence float64
|
||||
}{
|
||||
{
|
||||
// Nominatim tolère nativement la casse et l'absence d'accent :
|
||||
// résolution directe (étape 1 de ResolveAddress), confiance max.
|
||||
name: "tout minuscule sans accent",
|
||||
input: "12 rue crebillon 44000 nantes",
|
||||
minConfidence: 0.90,
|
||||
maxConfidence: 1.0,
|
||||
},
|
||||
{
|
||||
// Abréviation sans point ("Pl" au lieu de "Place") — également
|
||||
// tolérée nativement par Nominatim, résolution directe.
|
||||
name: "abréviation sans point",
|
||||
input: "3 Pl Royale 44000 Nantes",
|
||||
minConfidence: 0.90,
|
||||
maxConfidence: 1.0,
|
||||
},
|
||||
{
|
||||
// Faute de frappe réaliste sur un nom de rue réel (Gambetta ->
|
||||
// Gambeta) : vérifié que la recherche Nominatim directe ET
|
||||
// toutes les variantes générées par l'algorithme (accents,
|
||||
// abréviations, décomposition structurée essais 1 et 2)
|
||||
// échouent — seul le repli ville+code postal (essai 3,
|
||||
// confiance fixe 0.30) aboutit. Documente une vraie limite :
|
||||
// l'algorithme ne corrige pas les fautes de frappe arbitraires
|
||||
// dans un nom de rue, il retombe sur "quelque part dans la
|
||||
// bonne ville".
|
||||
name: "faute de frappe non corrigible sur le nom de rue",
|
||||
input: "15 Rue Gambeta 44000 Nantes",
|
||||
minConfidence: 0.25,
|
||||
maxConfidence: 0.35,
|
||||
},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if i > 0 {
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
}
|
||||
|
||||
suggestion, err := correction.ResolveAddress(c.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAddress(%q): %v", c.input, err)
|
||||
}
|
||||
|
||||
if !isWithinNantes(suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) {
|
||||
t.Errorf("coordonnées hors de Nantes pour %q: lat=%.4f lon=%.4f",
|
||||
c.input, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
|
||||
}
|
||||
if suggestion.Confidence < c.minConfidence || suggestion.Confidence > c.maxConfidence {
|
||||
t.Errorf("confiance hors intervalle attendu pour %q: got=%.2f want=[%.2f,%.2f]",
|
||||
c.input, suggestion.Confidence, c.minConfidence, c.maxConfidence)
|
||||
}
|
||||
if suggestion.CorrectedAddress == "" {
|
||||
t.Errorf("adresse corrigée vide pour %q", c.input)
|
||||
}
|
||||
|
||||
t.Logf("%q -> %q (confiance=%.2f, source=%s, correction_appliquée=%v, lat=%.4f lon=%.4f)",
|
||||
c.input, suggestion.CorrectedAddress, suggestion.Confidence, suggestion.Source,
|
||||
suggestion.CorrectionApplied, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Une adresse totalement absurde (aucun rapport avec un lieu réel) doit
|
||||
// échouer proprement plutôt que renvoyer une coordonnée aléatoire.
|
||||
func TestResolveAddress_NonsenseAddressFailsCleanly(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
|
||||
}
|
||||
|
||||
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
correction := services.NewAddressCorrectionService(geoService)
|
||||
|
||||
_, err := correction.ResolveAddress("Xyzzyplonk Zorbaxx 00000 Nullepart")
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur pour une adresse sans aucun rapport avec un lieu réel")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// db_address.go gère une table de correspondances gérées par l'admin
|
||||
// (adresse_correction) : à chaque checkout, CheckAddress vérifie si l'adresse
|
||||
// saisie par le client correspond à une entrée connue comme invalide, et si
|
||||
// oui, substitue l'adresse correcte tout en signalant une erreur pour forcer
|
||||
// une nouvelle confirmation côté client (voir ValidateBasket).
|
||||
|
||||
func cleanupAddressCorrections(t *testing.T, ids ...int64) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
for _, id := range ids {
|
||||
testDB.GDB.Exec(`DELETE FROM adresse_correction WHERE id = ?`, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckAddress_NoMatchReturnsNilAndLeavesAddressUnchanged(t *testing.T) {
|
||||
cmd := &models.Command{DeliveryAddress: testUserPrefix + "adresse jamais enregistrée 44000 Nantes"}
|
||||
original := cmd.DeliveryAddress
|
||||
|
||||
if err := testDB.CheckAddress(cmd); err != nil {
|
||||
t.Fatalf("CheckAddress sans correspondance ne doit jamais échouer: %v", err)
|
||||
}
|
||||
if cmd.DeliveryAddress != original {
|
||||
t.Errorf("adresse ne doit pas être modifiée sans correspondance: got=%q want=%q", cmd.DeliveryAddress, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAddress_MatchSubstitutesCorrectAddressAndReturnsError(t *testing.T) {
|
||||
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
|
||||
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
|
||||
if err := testDB.AddAddress(correct, invalid); err != nil {
|
||||
t.Fatalf("AddAddress: %v", err)
|
||||
}
|
||||
var id int64
|
||||
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
|
||||
cleanupAddressCorrections(t, id)
|
||||
|
||||
cmd := &models.Command{DeliveryAddress: invalid}
|
||||
err := testDB.CheckAddress(cmd)
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur signalant la correction (pour forcer une re-confirmation client)")
|
||||
}
|
||||
if cmd.DeliveryAddress != correct {
|
||||
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||
}
|
||||
}
|
||||
|
||||
// addCorrectionForFallback enregistre une correction et retourne une fonction
|
||||
// de nettoyage à appeler via t.Cleanup par l'appelant (évite de dépendre de
|
||||
// l'ordre d'exécution entre plusieurs corrections ajoutées dans un même test).
|
||||
func addCorrectionForFallback(t *testing.T, invalid, correct string) {
|
||||
t.Helper()
|
||||
if err := testDB.AddAddress(correct, invalid); err != nil {
|
||||
t.Fatalf("AddAddress(%q -> %q): %v", invalid, correct, err)
|
||||
}
|
||||
var id int64
|
||||
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
|
||||
cleanupAddressCorrections(t, id)
|
||||
}
|
||||
|
||||
// Les quatre tests suivants couvrent le fallback normalisé de CheckAddress
|
||||
// (utils.NormalizeAddress + strings.EqualFold) : une correction enregistrée
|
||||
// par l'admin avec un texte exact donné doit continuer à s'appliquer même si
|
||||
// le client tape une variante mineure (casse, accents, espaces), plutôt que
|
||||
// d'échouer silencieusement et laisser passer une adresse non livrable.
|
||||
|
||||
func TestCheckAddress_NormalizedFallback_CaseVariantMatches(t *testing.T) {
|
||||
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
|
||||
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
|
||||
addCorrectionForFallback(t, invalid, correct)
|
||||
|
||||
cmd := &models.Command{DeliveryAddress: testUserPrefix + "12 RUE CREBILLON NANTES"}
|
||||
err := testDB.CheckAddress(cmd)
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur signalant la correction (variante de casse)")
|
||||
}
|
||||
if cmd.DeliveryAddress != correct {
|
||||
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAddress_NormalizedFallback_AccentVariantMatches(t *testing.T) {
|
||||
invalid := testUserPrefix + "10 Rue du Général Buat Nantes"
|
||||
correct := testUserPrefix + "10 Rue du Général Buat, 44000 Nantes"
|
||||
addCorrectionForFallback(t, invalid, correct)
|
||||
|
||||
// Saisie sans accent par le client, alors que la correction enregistrée
|
||||
// par l'admin en contient un.
|
||||
cmd := &models.Command{DeliveryAddress: testUserPrefix + "10 Rue du General Buat Nantes"}
|
||||
err := testDB.CheckAddress(cmd)
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur signalant la correction (variante d'accent)")
|
||||
}
|
||||
if cmd.DeliveryAddress != correct {
|
||||
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAddress_NormalizedFallback_WhitespaceVariantMatches(t *testing.T) {
|
||||
invalid := testUserPrefix + "5 Cours des 50 Otages Nantes"
|
||||
correct := testUserPrefix + "5 Cours des 50 Otages, 44000 Nantes"
|
||||
addCorrectionForFallback(t, invalid, correct)
|
||||
|
||||
cmd := &models.Command{DeliveryAddress: testUserPrefix + "5 Cours des 50 Otages Nantes "}
|
||||
err := testDB.CheckAddress(cmd)
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur signalant la correction (espaces multiples)")
|
||||
}
|
||||
if cmd.DeliveryAddress != correct {
|
||||
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAddress_NormalizedFallback_CombinedCaseAccentWhitespaceMatches(t *testing.T) {
|
||||
invalid := testUserPrefix + "8 Rue de Verdun Nantes"
|
||||
correct := testUserPrefix + "8 Rue de Verdun, 44000 Nantes"
|
||||
addCorrectionForFallback(t, invalid, correct)
|
||||
|
||||
cmd := &models.Command{DeliveryAddress: testUserPrefix + "8 RUE de verdun nantes "}
|
||||
err := testDB.CheckAddress(cmd)
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur signalant la correction (casse + espaces combinés)")
|
||||
}
|
||||
if cmd.DeliveryAddress != correct {
|
||||
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
|
||||
}
|
||||
}
|
||||
|
||||
// Le fallback compare une égalité normalisée stricte, pas une similarité
|
||||
// floue : une adresse réellement différente (même partiellement proche) ne
|
||||
// doit jamais être substituée par erreur.
|
||||
func TestCheckAddress_NormalizedFallback_DoesNotMatchDifferentAddress(t *testing.T) {
|
||||
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
|
||||
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
|
||||
addCorrectionForFallback(t, invalid, correct)
|
||||
|
||||
cmd := &models.Command{DeliveryAddress: testUserPrefix + "14 Rue Crebillon Nantes"}
|
||||
original := cmd.DeliveryAddress
|
||||
if err := testDB.CheckAddress(cmd); err != nil {
|
||||
t.Fatalf("une adresse différente ne doit pas déclencher de correction: %v", err)
|
||||
}
|
||||
if cmd.DeliveryAddress != original {
|
||||
t.Errorf("adresse ne doit pas être modifiée: got=%q want=%q", cmd.DeliveryAddress, original)
|
||||
}
|
||||
}
|
||||
|
||||
// Avec plusieurs corrections enregistrées, le fallback doit retrouver la
|
||||
// bonne entrée (pas la première venue) même via une variante normalisée.
|
||||
func TestCheckAddress_NormalizedFallback_FindsRightEntryAmongMultiple(t *testing.T) {
|
||||
invalidA := testUserPrefix + "1 Rue A Nantes"
|
||||
correctA := testUserPrefix + "1 Rue A, 44000 Nantes"
|
||||
invalidB := testUserPrefix + "2 Rue B Nantes"
|
||||
correctB := testUserPrefix + "2 Rue B, 44000 Nantes"
|
||||
addCorrectionForFallback(t, invalidA, correctA)
|
||||
addCorrectionForFallback(t, invalidB, correctB)
|
||||
|
||||
cmd := &models.Command{DeliveryAddress: testUserPrefix + "2 RUE b nantes"}
|
||||
if err := testDB.CheckAddress(cmd); err == nil {
|
||||
t.Fatal("attendu une erreur signalant la correction B")
|
||||
}
|
||||
if cmd.DeliveryAddress != correctB {
|
||||
t.Errorf("adresse corrigée: got=%q want=%q (ne doit pas confondre avec A)", cmd.DeliveryAddress, correctB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAddress_ThenAllAddressIncludesIt(t *testing.T) {
|
||||
invalid := testUserPrefix + "adresse invalide test"
|
||||
correct := testUserPrefix + "adresse correcte test"
|
||||
if err := testDB.AddAddress(correct, invalid); err != nil {
|
||||
t.Fatalf("AddAddress: %v", err)
|
||||
}
|
||||
var id int64
|
||||
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
|
||||
cleanupAddressCorrections(t, id)
|
||||
|
||||
all, err := testDB.AllAddress()
|
||||
if err != nil {
|
||||
t.Fatalf("AllAddress: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, a := range all {
|
||||
if a.InvalidAddress == invalid && a.CorrectAddress == correct {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("la correspondance ajoutée n'apparaît pas dans AllAddress")
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAddress doit cibler la correspondance exacte, sans affecter une autre
|
||||
// correspondance non liée. Note : invalid_address a une contrainte UNIQUE en
|
||||
// base (adresse_correction_invalid_address_key), donc deux corrections ne
|
||||
// peuvent jamais partager la même adresse invalide — le risque réel est
|
||||
// seulement qu'un DELETE mal ciblé touche une correspondance différente.
|
||||
func TestDeleteAddress_RemovesOnlyTargetedPairNotUnrelatedOne(t *testing.T) {
|
||||
invalidA := testUserPrefix + "adresse A"
|
||||
correctA := testUserPrefix + "correction A"
|
||||
invalidB := testUserPrefix + "adresse B"
|
||||
correctB := testUserPrefix + "correction B"
|
||||
if err := testDB.AddAddress(correctA, invalidA); err != nil {
|
||||
t.Fatalf("AddAddress A: %v", err)
|
||||
}
|
||||
if err := testDB.AddAddress(correctB, invalidB); err != nil {
|
||||
t.Fatalf("AddAddress B: %v", err)
|
||||
}
|
||||
var idA, idB int64
|
||||
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidA).Scan(&idA)
|
||||
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidB).Scan(&idB)
|
||||
cleanupAddressCorrections(t, idA, idB)
|
||||
|
||||
if err := testDB.DeleteAddress(invalidA, correctA); err != nil {
|
||||
t.Fatalf("DeleteAddress: %v", err)
|
||||
}
|
||||
|
||||
all, err := testDB.AllAddress()
|
||||
if err != nil {
|
||||
t.Fatalf("AllAddress: %v", err)
|
||||
}
|
||||
var stillHasA, stillHasB bool
|
||||
for _, a := range all {
|
||||
if a.InvalidAddress == invalidA && a.CorrectAddress == correctA {
|
||||
stillHasA = true
|
||||
}
|
||||
if a.InvalidAddress == invalidB && a.CorrectAddress == correctB {
|
||||
stillHasB = true
|
||||
}
|
||||
}
|
||||
if stillHasA {
|
||||
t.Error("la correspondance ciblée (A) doit être supprimée")
|
||||
}
|
||||
if !stillHasB {
|
||||
t.Error("l'autre correspondance (B), non ciblée, ne doit pas être supprimée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func alertContext(username, role string, body []byte, alertID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/livreur/alert", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
if username != "" {
|
||||
c.Set("username", username)
|
||||
}
|
||||
c.Set("role", role)
|
||||
if alertID != 0 {
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", alertID)}}
|
||||
}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func createTestAlert(t *testing.T, username, message string) int {
|
||||
t.Helper()
|
||||
alert, err := testDB.CreateAlert(username, message)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAlert: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE id = ?`, alert.ID)
|
||||
})
|
||||
return alert.ID
|
||||
}
|
||||
|
||||
// ── AlertPolice ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAlertPolice_LivreurCreatesAlert(t *testing.T) {
|
||||
livreur := testUserPrefix + "alert_create_livreur"
|
||||
body, _ := json.Marshal(map[string]string{"message": "Contrôle en cours"})
|
||||
c, rec := alertContext(livreur, "livreur", body, 0)
|
||||
handlers.AlertPolice(c)
|
||||
t.Cleanup(func() { testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE username = ?`, livreur) })
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
AlertID int `json:"alert_id"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.User != livreur {
|
||||
t.Errorf("user: got=%q want=%q", resp.User, livreur)
|
||||
}
|
||||
|
||||
alert, err := testDB.GetAlertPolicy(resp.AlertID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlertPolicy: %v", err)
|
||||
}
|
||||
if alert.Message != "Contrôle en cours" || alert.Status != "true" {
|
||||
t.Errorf("alerte créée: message=%q status=%q", alert.Message, alert.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertPolice_NonLivreurForbidden(t *testing.T) {
|
||||
for _, role := range []string{"client", "admin", "cabine"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]string{"message": "test"})
|
||||
c, rec := alertContext(testUserPrefix+"alert_forbidden_"+role, role, body, 0)
|
||||
handlers.AlertPolice(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("le rôle %q ne doit pas pouvoir déclencher une alerte police: got=%d", role, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetAlert ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetAlert_LivreurCanViewOwnAlert(t *testing.T) {
|
||||
livreur := testUserPrefix + "alert_view_own"
|
||||
alertID := createTestAlert(t, livreur, "test")
|
||||
|
||||
c, rec := alertContext(livreur, "livreur", nil, alertID)
|
||||
handlers.GetAlert(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAlert_LivreurCannotViewOthersAlert(t *testing.T) {
|
||||
owner := testUserPrefix + "alert_view_owner"
|
||||
intruder := testUserPrefix + "alert_view_intruder"
|
||||
alertID := createTestAlert(t, owner, "test")
|
||||
|
||||
c, rec := alertContext(intruder, "livreur", nil, alertID)
|
||||
handlers.GetAlert(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("un livreur ne doit pas pouvoir consulter l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAlert_AdminCanViewAnyAlert(t *testing.T) {
|
||||
owner := testUserPrefix + "alert_view_admin_owner"
|
||||
alertID := createTestAlert(t, owner, "test")
|
||||
|
||||
c, rec := alertContext(testUserPrefix+"alert_view_admin", "admin", nil, alertID)
|
||||
handlers.GetAlert(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("un admin doit pouvoir consulter n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── EndAlert ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestEndAlert_OwnerCanEnd(t *testing.T) {
|
||||
livreur := testUserPrefix + "alert_end_owner"
|
||||
alertID := createTestAlert(t, livreur, "test")
|
||||
|
||||
c, rec := alertContext(livreur, "livreur", nil, alertID)
|
||||
handlers.EndAlert(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
alert, _ := testDB.GetAlertPolicy(alertID)
|
||||
if alert.Status != "false" {
|
||||
t.Errorf("statut après EndAlert: got=%q want=false", alert.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndAlert_NonOwnerLivreurRejected(t *testing.T) {
|
||||
owner := testUserPrefix + "alert_end_owner2"
|
||||
intruder := testUserPrefix + "alert_end_intruder"
|
||||
alertID := createTestAlert(t, owner, "test")
|
||||
|
||||
c, rec := alertContext(intruder, "livreur", nil, alertID)
|
||||
handlers.EndAlert(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("un livreur tiers ne doit pas pouvoir terminer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
alert, _ := testDB.GetAlertPolicy(alertID)
|
||||
if alert.Status != "true" {
|
||||
t.Errorf("l'alerte ne doit pas être terminée par un intrus: got=%q want=true", alert.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteAlert ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Corrigé : un livreur ne peut supprimer que ses propres alertes (comme
|
||||
// EndAlert) ; un admin garde l'accès complet sans restriction de propriétaire.
|
||||
|
||||
func TestDeleteAlert_OwnerLivreurCanDeleteOwnAlert(t *testing.T) {
|
||||
owner := testUserPrefix + "alert_delete_owner_ok"
|
||||
alertID := createTestAlert(t, owner, "test")
|
||||
|
||||
c, rec := alertContext(owner, "livreur", nil, alertID)
|
||||
handlers.DeleteAlert(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("le propriétaire doit pouvoir supprimer sa propre alerte: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
|
||||
t.Error("l'alerte doit être supprimée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAlert_NonOwnerLivreurRejected(t *testing.T) {
|
||||
owner := testUserPrefix + "alert_delete_owner"
|
||||
intruder := testUserPrefix + "alert_delete_intruder"
|
||||
alertID := createTestAlert(t, owner, "test")
|
||||
|
||||
c, rec := alertContext(intruder, "livreur", nil, alertID)
|
||||
handlers.DeleteAlert(c)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("un livreur tiers ne doit pas pouvoir supprimer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := testDB.GetAlertPolicy(alertID); err != nil {
|
||||
t.Error("l'alerte ne doit pas être supprimée par un intrus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAlert_AdminCanDeleteAnyAlertRegardlessOfOwner(t *testing.T) {
|
||||
owner := testUserPrefix + "alert_delete_admin_owner"
|
||||
alertID := createTestAlert(t, owner, "test")
|
||||
|
||||
c, rec := alertContext(testUserPrefix+"alert_delete_admin", "admin", nil, alertID)
|
||||
handlers.DeleteAlert(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("un admin doit pouvoir supprimer n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
|
||||
t.Error("l'alerte doit être supprimée par l'admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAlert_NonLivreurNonAdminForbidden(t *testing.T) {
|
||||
owner := testUserPrefix + "alert_delete_forbidden_owner"
|
||||
alertID := createTestAlert(t, owner, "test")
|
||||
|
||||
c, rec := alertContext(testUserPrefix+"alert_delete_forbidden_cabine", "cabine", nil, alertID)
|
||||
handlers.DeleteAlert(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("le rôle cabine ne doit pas pouvoir supprimer une alerte: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Listing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetMyAlerts_ReturnsOnlyOwnAlerts(t *testing.T) {
|
||||
mine := testUserPrefix + "alert_mine"
|
||||
other := testUserPrefix + "alert_other"
|
||||
createTestAlert(t, mine, "à moi 1")
|
||||
createTestAlert(t, mine, "à moi 2")
|
||||
createTestAlert(t, other, "pas à moi")
|
||||
|
||||
c, rec := alertContext(mine, "livreur", nil, 0)
|
||||
handlers.GetMyAlerts(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Count != 2 {
|
||||
t.Errorf("nombre d'alertes du livreur: got=%d want=2", resp.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveAlerts_ExcludesEndedAlerts(t *testing.T) {
|
||||
livreur := testUserPrefix + "alert_active_filter"
|
||||
activeID := createTestAlert(t, livreur, "active")
|
||||
endedID := createTestAlert(t, livreur, "terminée")
|
||||
if err := testDB.EndAlert(endedID); err != nil {
|
||||
t.Fatalf("EndAlert (setup): %v", err)
|
||||
}
|
||||
|
||||
alerts, err := testDB.GetActiveAlerts()
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveAlerts: %v", err)
|
||||
}
|
||||
var foundActive, foundEnded bool
|
||||
for _, a := range alerts {
|
||||
if a.ID == activeID {
|
||||
foundActive = true
|
||||
}
|
||||
if a.ID == endedID {
|
||||
foundEnded = true
|
||||
}
|
||||
}
|
||||
if !foundActive {
|
||||
t.Error("l'alerte active doit apparaître dans GetActiveAlerts")
|
||||
}
|
||||
if foundEnded {
|
||||
t.Error("l'alerte terminée ne doit pas apparaître dans GetActiveAlerts")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func cmdContext(method, username, role string, body []byte, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
reader = bytes.NewReader(body)
|
||||
} else {
|
||||
reader = bytes.NewReader([]byte{})
|
||||
}
|
||||
req := httptest.NewRequest(method, "/api/v1/commands", reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
if username != "" {
|
||||
c.Set("username", username)
|
||||
}
|
||||
c.Set("role", role)
|
||||
if commandID != 0 {
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||
}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// ── CancelCommandByClient (HTTP layer) ───────────────────────────────────
|
||||
|
||||
func TestCancelCommandByClient_RejectsNonClientRole(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := cmdContext(http.MethodPost, testUserPrefix+"cancel_role", "admin", nil, 1)
|
||||
handlers.CancelCommandByClient(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role admin doit être refusé: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandByClient_InvalidCommandIDReturns400(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_badid")
|
||||
c, rec := cmdContext(http.MethodPost, username, "client", nil, 0)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
|
||||
handlers.CancelCommandByClient(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandByClient_UnknownCommandReturns404(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_404")
|
||||
c, rec := cmdContext(http.MethodPost, username, "client", nil, 99999999)
|
||||
handlers.CancelCommandByClient(c)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandByClient_WrongOwnerReturns403(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "cancel_owner")
|
||||
intruder := newTestClient(t, "cancel_intruder")
|
||||
productID := newTestProduct(t, "CancelWrongOwner", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
|
||||
|
||||
c, rec := cmdContext(http.MethodPost, intruder, "client", nil, cmdID)
|
||||
handlers.CancelCommandByClient(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("un autre client ne doit pas pouvoir annuler: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandByClient_SuccessNoPenaltyWhenNoLivreur(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_ok_nopenalty")
|
||||
productID := newTestProduct(t, "CancelOkNoPenalty", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
|
||||
handlers.CancelCommandByClient(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("annulation sans livreur doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "cancelled" {
|
||||
t.Errorf("statut après annulation: got=%s want=cancelled", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandByClient_TerminalStatusReturns400(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_terminal")
|
||||
productID := newTestProduct(t, "CancelTerminal", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
|
||||
handlers.CancelCommandByClient(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("annulation d'une commande livrée doit être rejetée: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCommandByClient_ConfirmationRequiredReturns409WithPenaltyWarning(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_confirm")
|
||||
livreur := "cancel_confirm_livreur"
|
||||
productID := newTestProduct(t, "CancelConfirm", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", livreur, productID, 1, 10)
|
||||
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
|
||||
t.Fatalf("SetCommandETA: %v", err)
|
||||
}
|
||||
|
||||
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
|
||||
handlers.CancelCommandByClient(c)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("annulation tardive sans force doit demander confirmation: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"will_apply":true`)) {
|
||||
t.Errorf("la réponse doit avertir d'une pénalité à venir: body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Statut inchangé tant que non confirmé.
|
||||
if got := commandStatus(t, cmdID); got != "en_route" {
|
||||
t.Errorf("statut ne doit pas changer avant confirmation: got=%s want=en_route", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetMyCancellationHistory ──────────────────────────────────────────────
|
||||
|
||||
func TestGetMyCancellationHistory_RejectsNonClientRole(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := cmdContext(http.MethodGet, testUserPrefix+"hist_role", "livreur", nil, 0)
|
||||
handlers.GetMyCancellationHistory(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMyCancellationHistory_ReturnsHistoryAndTotalPenalties(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "hist_ok")
|
||||
c, rec := cmdContext(http.MethodGet, username, "client", nil, 0)
|
||||
handlers.GetMyCancellationHistory(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("historique doit réussir pour un client: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"total_penalties"`)) {
|
||||
t.Errorf("la réponse doit inclure total_penalties: body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetAllCancelledOrders ─────────────────────────────────────────────────
|
||||
|
||||
func TestGetAllCancelledOrders_RejectsNonAdminNonCabine(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_role", "livreur", nil, 0)
|
||||
handlers.GetAllCancelledOrders(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllCancelledOrders_InvalidLimitFallsBackToDefault(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_limit", "admin", nil, 0)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=not-a-number", nil)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", testUserPrefix+"allcancel_limit")
|
||||
c.Set("role", "admin")
|
||||
handlers.GetAllCancelledOrders(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("limit invalide doit quand même réussir avec un défaut: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllCancelledOrders_LimitCapsAt500(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=99999", nil)
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", testUserPrefix+"allcancel_cap")
|
||||
c.Set("role", "admin")
|
||||
handlers.GetAllCancelledOrders(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("limit énorme doit quand même réussir (plafonné): got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteCommandByCabine ─────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteCommandByCabine_RejectsNonCabineNonAdmin(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_role", "client", nil, 1)
|
||||
handlers.DeleteCommandByCabine(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCommandByCabine_UnknownCommandReturns404(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_404", "cabine", nil, 99999999)
|
||||
handlers.DeleteCommandByCabine(c)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCommandByCabine_SuccessDeletesCommand(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delcab_ok")
|
||||
productID := newTestProduct(t, "DelCabOk", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_ok_actor", "cabine", nil, cmdID)
|
||||
handlers.DeleteCommandByCabine(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("suppression par cabine doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
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: got=%d lignes restantes", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ── validateReason (testé indirectement via CancelCommandByClient) ───────
|
||||
|
||||
func TestCancelCommandByClient_BlankReasonDefaultsToStandardMessage(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reason_blank")
|
||||
productID := newTestProduct(t, "ReasonBlank", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
body := []byte(`{"reason":""}`)
|
||||
c, rec := cmdContext(http.MethodPost, username, "client", body, cmdID)
|
||||
handlers.CancelCommandByClient(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("annulation avec reason vide doit réussir (validateReason doit fournir un défaut, pas rejeter): got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "cancelled" {
|
||||
t.Errorf("statut après annulation avec raison vide: got=%s want=cancelled", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package tests
|
||||
|
||||
import "testing"
|
||||
|
||||
// commandAddressState lit adresse/proposed_address/address_proposal_status
|
||||
// directement pour vérifier le flux propose -> respond.
|
||||
type commandAddressState struct {
|
||||
Adresse string `gorm:"column:adresse"`
|
||||
ProposedAddress string `gorm:"column:proposed_address"`
|
||||
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||||
}
|
||||
|
||||
func getCommandAddressState(t *testing.T, commandID int) commandAddressState {
|
||||
t.Helper()
|
||||
var s commandAddressState
|
||||
if err := testDB.GDB.Raw(
|
||||
`SELECT adresse, COALESCE(proposed_address, '') as proposed_address,
|
||||
COALESCE(address_proposal_status, '') as address_proposal_status
|
||||
FROM commandes WHERE id = ?`, commandID,
|
||||
).Scan(&s).Error; err != nil {
|
||||
t.Fatalf("getCommandAddressState: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── UpdateCommandAddress (modification directe admin) ───────────────────────
|
||||
|
||||
func TestUpdateCommandAddress_UpdatesAddress(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "upd_addr_ok")
|
||||
productID := newTestProduct(t, "UpdAddrOk", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.UpdateCommandAddress(cmdID, "42 Nouvelle Adresse, 44000 Nantes"); err != nil {
|
||||
t.Fatalf("UpdateCommandAddress: %v", err)
|
||||
}
|
||||
if got := getCommandAddressState(t, cmdID).Adresse; got != "42 Nouvelle Adresse, 44000 Nantes" {
|
||||
t.Errorf("adresse après mise à jour: got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCommandAddress_RejectsEmptyAddress(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "upd_addr_empty")
|
||||
productID := newTestProduct(t, "UpdAddrEmpty", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
before := getCommandAddressState(t, cmdID).Adresse
|
||||
|
||||
if err := testDB.UpdateCommandAddress(cmdID, " "); err == nil {
|
||||
t.Fatal("attendu un rejet pour une adresse vide/blanche")
|
||||
}
|
||||
if got := getCommandAddressState(t, cmdID).Adresse; got != before {
|
||||
t.Errorf("adresse ne doit pas changer sur un rejet: got=%q want=%q", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCommandAddress_RejectsUnknownCommand(t *testing.T) {
|
||||
if err := testDB.UpdateCommandAddress(999999999, "1 rue inexistante"); err == nil {
|
||||
t.Fatal("attendu une erreur pour une commande inexistante")
|
||||
}
|
||||
}
|
||||
|
||||
// Note : db.UpdateCommandAddress lui-même n'interdit pas de modifier l'adresse
|
||||
// d'une commande terminée — cette règle ("livre/approved/cancelled interdits")
|
||||
// est uniquement appliquée par le handler HTTP (UpdateCommandAddress dans
|
||||
// handlers/commands.go), pas par la fonction DB. Ce test documente ce fait
|
||||
// explicitement pour qu'un futur appelant direct de la fonction DB (ex. un
|
||||
// script, un worker) ne suppose pas à tort que la protection est là.
|
||||
func TestUpdateCommandAddress_DBFunctionAloneDoesNotBlockTerminalStatuses(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "upd_addr_terminal")
|
||||
productID := newTestProduct(t, "UpdAddrTerminal", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "approved", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.UpdateCommandAddress(cmdID, "Adresse modifiée après coup"); err != nil {
|
||||
t.Fatalf("la fonction DB seule n'impose pas la restriction de statut (attendu, voir commentaire): %v", err)
|
||||
}
|
||||
if got := getCommandAddressState(t, cmdID).Adresse; got != "Adresse modifiée après coup" {
|
||||
t.Errorf("adresse: got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ProposeAddressChange / RespondToAddressProposal ─────────────────────────
|
||||
|
||||
func TestProposeAddressChange_SetsProposedAddressAndPendingStatus(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "propose_addr_ok")
|
||||
productID := newTestProduct(t, "ProposeAddrOk", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.ProposeAddressChange(cmdID, "Nouvelle adresse proposée, 44000 Nantes", "admin_test"); err != nil {
|
||||
t.Fatalf("ProposeAddressChange: %v", err)
|
||||
}
|
||||
|
||||
s := getCommandAddressState(t, cmdID)
|
||||
if s.ProposedAddress != "Nouvelle adresse proposée, 44000 Nantes" {
|
||||
t.Errorf("proposed_address: got=%q", s.ProposedAddress)
|
||||
}
|
||||
if s.AddressProposalStatus != "pending" {
|
||||
t.Errorf("address_proposal_status: got=%q want=pending", s.AddressProposalStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondToAddressProposal_AcceptedAppliesProposedAddress(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "respond_addr_accept")
|
||||
productID := newTestProduct(t, "RespondAddrAccept", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée acceptée", "admin_test"); err != nil {
|
||||
t.Fatalf("ProposeAddressChange: %v", err)
|
||||
}
|
||||
|
||||
if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil {
|
||||
t.Fatalf("RespondToAddressProposal (accepté): %v", err)
|
||||
}
|
||||
|
||||
s := getCommandAddressState(t, cmdID)
|
||||
if s.Adresse != "Adresse proposée acceptée" {
|
||||
t.Errorf("adresse de livraison après acceptation: got=%q want=%q", s.Adresse, "Adresse proposée acceptée")
|
||||
}
|
||||
if s.ProposedAddress != "" {
|
||||
t.Errorf("proposed_address doit être vidé après réponse: got=%q", s.ProposedAddress)
|
||||
}
|
||||
if s.AddressProposalStatus != "accepted" {
|
||||
t.Errorf("address_proposal_status: got=%q want=accepted", s.AddressProposalStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondToAddressProposal_RejectedKeepsOriginalAddress(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "respond_addr_reject")
|
||||
productID := newTestProduct(t, "RespondAddrReject", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||
original := getCommandAddressState(t, cmdID).Adresse
|
||||
|
||||
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée refusée", "admin_test"); err != nil {
|
||||
t.Fatalf("ProposeAddressChange: %v", err)
|
||||
}
|
||||
if err := testDB.RespondToAddressProposal(cmdID, username, false); err != nil {
|
||||
t.Fatalf("RespondToAddressProposal (refusé): %v", err)
|
||||
}
|
||||
|
||||
s := getCommandAddressState(t, cmdID)
|
||||
if s.Adresse != original {
|
||||
t.Errorf("l'adresse de livraison ne doit pas changer sur un refus: got=%q want=%q", s.Adresse, original)
|
||||
}
|
||||
if s.ProposedAddress != "" {
|
||||
t.Errorf("proposed_address doit être vidé même en cas de refus: got=%q", s.ProposedAddress)
|
||||
}
|
||||
if s.AddressProposalStatus != "rejected" {
|
||||
t.Errorf("address_proposal_status: got=%q want=rejected", s.AddressProposalStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondToAddressProposal_FailsWhenNoProposalPending(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "respond_addr_none")
|
||||
productID := newTestProduct(t, "RespondAddrNone", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.RespondToAddressProposal(cmdID, username, true); err == nil {
|
||||
t.Fatal("attendu une erreur : aucune proposition en attente")
|
||||
}
|
||||
}
|
||||
|
||||
// La proposition est liée au client propriétaire de la commande : un autre
|
||||
// client ne doit pas pouvoir y répondre à sa place.
|
||||
func TestRespondToAddressProposal_WrongClientCannotRespond(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "respond_addr_owner")
|
||||
intruder := newTestClient(t, "respond_addr_intruder")
|
||||
productID := newTestProduct(t, "RespondAddrIntruder", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "assigned", "", productID, 1, 10)
|
||||
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil {
|
||||
t.Fatalf("ProposeAddressChange: %v", err)
|
||||
}
|
||||
|
||||
if err := testDB.RespondToAddressProposal(cmdID, intruder, true); err == nil {
|
||||
t.Fatal("un client tiers ne doit pas pouvoir répondre à la proposition d'un autre client")
|
||||
}
|
||||
|
||||
s := getCommandAddressState(t, cmdID)
|
||||
if s.AddressProposalStatus != "pending" {
|
||||
t.Errorf("la proposition doit rester en attente après une tentative d'un intrus: got=%q want=pending", s.AddressProposalStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// Rejeu (double-tap) : une fois traitée, la même proposition ne doit pas
|
||||
// pouvoir être acceptée/refusée une seconde fois.
|
||||
func TestRespondToAddressProposal_DoubleRespondFails(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "respond_addr_double")
|
||||
productID := newTestProduct(t, "RespondAddrDouble", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
|
||||
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil {
|
||||
t.Fatalf("ProposeAddressChange: %v", err)
|
||||
}
|
||||
|
||||
if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil {
|
||||
t.Fatalf("1ère réponse: %v", err)
|
||||
}
|
||||
if err := testDB.RespondToAddressProposal(cmdID, username, false); err == nil {
|
||||
t.Fatal("une 2e réponse sur une proposition déjà traitée doit échouer")
|
||||
}
|
||||
|
||||
// La 2e tentative (rejet) ne doit pas être appliquée par-dessus la 1ère (acceptation).
|
||||
if got := getCommandAddressState(t, cmdID).AddressProposalStatus; got != "accepted" {
|
||||
t.Errorf("le statut doit rester celui de la 1ère réponse: got=%q want=accepted", got)
|
||||
}
|
||||
}
|
||||
@@ -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,222 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newTestLivreur crée un utilisateur role=livreur réel (AssignDeliveryPerson
|
||||
// vérifie son existence/rôle en base, pas juste le contexte gin) et
|
||||
// programme son nettoyage.
|
||||
func newTestLivreur(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
username := testUserPrefix + name
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO users (username, password, role) VALUES (?, 'x', 'livreur') ON CONFLICT (username) DO NOTHING`,
|
||||
username,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création livreur test %q: %v", username, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM users WHERE username = ?`, username)
|
||||
})
|
||||
return username
|
||||
}
|
||||
|
||||
func getAllCommandsContext(role string, query url.Values) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands?"+query.Encode(), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", testUserPrefix+"gac_admin")
|
||||
c.Set("role", role)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// ── GetAllCommands ────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetAllCommands_RejectsNonAdminNonCabine(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := getAllCommandsContext("livreur", url.Values{})
|
||||
handlers.GetAllCommands(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllCommands_FiltersByStatusAndUsername(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "gac_filter")
|
||||
productID := newTestProduct(t, "GACFilter", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
otherUser := newTestClient(t, "gac_filter_other")
|
||||
newTestCommandWithItem(t, otherUser, "cancelled", "", productID, 1, 10)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("status", "pending")
|
||||
q.Set("username", username)
|
||||
c, rec := getAllCommandsContext("admin", q)
|
||||
handlers.GetAllCommands(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("requête filtrée doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(fmt.Sprintf(`"id":%d`, cmdID))) {
|
||||
t.Errorf("la commande filtrée doit apparaître dans le résultat: body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllCommands_AllSentinelReturnsEverything(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "gac_all")
|
||||
productID := newTestProduct(t, "GACAll", 10)
|
||||
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands/all/all", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", testUserPrefix+"gac_admin")
|
||||
c.Set("role", "admin")
|
||||
c.Params = gin.Params{{Key: "status", Value: "all"}, {Key: "username", Value: "all"}}
|
||||
handlers.GetAllCommands(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("sentinel 'all' doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── AssignDeliveryPerson ──────────────────────────────────────────────────
|
||||
|
||||
func assignContext(role string, body []byte, commandID int, livreurUsername string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/assign", 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("geoService", ensureTestGeoService())
|
||||
c.Set("username", testUserPrefix+"assign_staff")
|
||||
c.Set("role", role)
|
||||
params := gin.Params{{Key: "command_id", Value: fmt.Sprintf("%d", commandID)}}
|
||||
if livreurUsername != "" {
|
||||
params = append(params, gin.Param{Key: "username", Value: livreurUsername})
|
||||
}
|
||||
c.Params = params
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func TestAssignDeliveryPerson_RejectsNonAdminNonCabine(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := assignContext("client", nil, 1, "someone")
|
||||
handlers.AssignDeliveryPerson(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignDeliveryPerson_SupportsCommandIDParam(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "assign_cmdid")
|
||||
livreur := newTestLivreur(t, "assign_cmdid_livreur")
|
||||
productID := newTestProduct(t, "AssignCmdID", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
c, rec := assignContext("admin", nil, cmdID, livreur)
|
||||
handlers.AssignDeliveryPerson(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("assignation via :command_id doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignDeliveryPerson_SupportsIDParamFallback(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "assign_id")
|
||||
livreur := newTestLivreur(t, "assign_id_livreur")
|
||||
productID := newTestProduct(t, "AssignID", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/cabine/assign", bytes.NewReader([]byte(fmt.Sprintf(`{"livreur_username":%q}`, livreur))))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("geoService", ensureTestGeoService())
|
||||
c.Set("username", testUserPrefix+"assign_staff")
|
||||
c.Set("role", "cabine")
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
|
||||
handlers.AssignDeliveryPerson(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("assignation via :id (fallback cabine) doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetCommandItemsWithDetails ────────────────────────────────────────────
|
||||
|
||||
func itemsDetailedContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/items", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
if setUsername {
|
||||
c.Set("username", username)
|
||||
}
|
||||
c.Set("role", role)
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func TestGetCommandItemsWithDetails_UnauthenticatedReturns401(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := itemsDetailedContext("", "client", 1, false)
|
||||
handlers.GetCommandItemsWithDetails(c)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommandItemsWithDetails_IDORBlockedForOtherClient(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "items_owner")
|
||||
intruder := newTestClient(t, "items_intruder")
|
||||
productID := newTestProduct(t, "ItemsIDOR", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
|
||||
|
||||
c, rec := itemsDetailedContext(intruder, "client", cmdID, true)
|
||||
handlers.GetCommandItemsWithDetails(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("un autre client ne doit pas accéder aux items: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommandItemsWithDetails_OwnerCanAccess(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "items_owner_ok")
|
||||
productID := newTestProduct(t, "ItemsOwnerOk", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 2, 20)
|
||||
|
||||
c, rec := itemsDetailedContext(owner, "client", cmdID, true)
|
||||
handlers.GetCommandItemsWithDetails(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("le propriétaire doit accéder à ses items: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCommandItemsWithDetails_NoItemsReturns404(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := itemsDetailedContext(testUserPrefix+"items_admin", "admin", 99999999, true)
|
||||
handlers.GetCommandItemsWithDetails(c)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("commande sans item doit retourner 404: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UpdateDeliveryStatus (handlers/deleviry.go) refuse de valider une livraison
|
||||
// (statut "livre") si le livreur se trouve à plus de 350m de la destination
|
||||
// (contrôle anti-fraude — seuil relevé de 100m à 350m à la demande explicite,
|
||||
// pour tolérer l'imprécision GPS réelle en zone urbaine/immeuble).
|
||||
|
||||
const earthRadiusMeters = 6371000.0
|
||||
|
||||
// destinationPointNorthOf renvoie un point situé à "meters" au nord de
|
||||
// (lat, lon) — même longitude, donc distance ≈ purement le delta de latitude
|
||||
// (formule identique à utils.CalculateDistance pour ce cas particulier).
|
||||
func destinationPointNorthOf(lat, lon, meters float64) (float64, float64) {
|
||||
latOffsetRad := meters / earthRadiusMeters
|
||||
latOffsetDeg := latOffsetRad * (180 / math.Pi)
|
||||
return lat + latOffsetDeg, lon
|
||||
}
|
||||
|
||||
func setCommandDestination(t *testing.T, commandID int, lat, lon float64) {
|
||||
t.Helper()
|
||||
if err := testDB.GDB.Exec(
|
||||
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
|
||||
lat, lon, commandID,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("setCommandDestination: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// deliveryStatusContextJSON est la variante de deliveryStatusContext (voir
|
||||
// penalty_test.go) qui accepte un corps JSON arbitraire — nécessaire ici pour
|
||||
// pouvoir passer latitude/longitude, que l'helper existant ne supporte pas.
|
||||
func deliveryStatusContextJSON(username string, commandID int, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/livreur/deliveries/%d/status", commandID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", username)
|
||||
c.Set("role", "livreur")
|
||||
return c, rec
|
||||
}
|
||||
|
||||
const nantesLat, nantesLon = 47.2184, -1.5536
|
||||
|
||||
func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreur := newTestClient(t, "gps_livreur_within")
|
||||
client := newTestClient(t, "gps_client_within")
|
||||
productID := newTestProduct(t, "GPSWithin", 10)
|
||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
|
||||
setCommandDestination(t, cmdID, nantesLat, nantesLon)
|
||||
|
||||
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200) // 200m < 350m
|
||||
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
|
||||
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
|
||||
handlers.UpdateDeliveryStatus(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "livre" {
|
||||
t.Errorf("statut après validation à 200m: got=%s want=livre", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDeliveryStatus_GPS_BeyondThresholdRejectsValidation(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreur := newTestClient(t, "gps_livreur_beyond")
|
||||
client := newTestClient(t, "gps_client_beyond")
|
||||
productID := newTestProduct(t, "GPSBeyond", 10)
|
||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
|
||||
setCommandDestination(t, cmdID, nantesLat, nantesLon)
|
||||
|
||||
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 400) // 400m > 350m
|
||||
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
|
||||
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
|
||||
handlers.UpdateDeliveryStatus(c)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "en_route" {
|
||||
t.Errorf("le statut ne doit pas passer à 'livre' au-delà de 350m: got=%s want=en_route", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Preuve directe du changement demandé : une distance de 150m, qui aurait
|
||||
// échoué sous l'ancien seuil de 100m, doit maintenant réussir sous 350m.
|
||||
func TestUpdateDeliveryStatus_GPS_150Meters_PassesUnderNewThreshold(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreur := newTestClient(t, "gps_livreur_150m")
|
||||
client := newTestClient(t, "gps_client_150m")
|
||||
productID := newTestProduct(t, "GPS150m", 10)
|
||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
|
||||
setCommandDestination(t, cmdID, nantesLat, nantesLon)
|
||||
|
||||
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 150)
|
||||
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
|
||||
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
|
||||
handlers.UpdateDeliveryStatus(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("150m doit être accepté sous le nouveau seuil de 350m: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "livre" {
|
||||
t.Errorf("statut après validation à 150m: got=%s want=livre", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDeliveryStatus_GPS_MissingCoordinatesRejected(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreur := newTestClient(t, "gps_livreur_missing_coords")
|
||||
client := newTestClient(t, "gps_client_missing_coords")
|
||||
productID := newTestProduct(t, "GPSMissingCoords", 10)
|
||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
|
||||
setCommandDestination(t, cmdID, nantesLat, nantesLon)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"status": "livre"}) // latitude/longitude absents (zéro)
|
||||
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
|
||||
handlers.UpdateDeliveryStatus(c)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status HTTP sans coordonnées: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "en_route" {
|
||||
t.Errorf("le statut ne doit pas changer sans coordonnées GPS: got=%s want=en_route", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Si la commande n'a pas de coordonnées de destination enregistrées (adresse
|
||||
// non géocodée), la validation GPS est ignorée plutôt que de bloquer le
|
||||
// livreur indéfiniment.
|
||||
func TestUpdateDeliveryStatus_GPS_MissingDestinationCoordinatesSkipsValidation(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreur := newTestClient(t, "gps_livreur_no_dest")
|
||||
client := newTestClient(t, "gps_client_no_dest")
|
||||
productID := newTestProduct(t, "GPSNoDest", 10)
|
||||
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
|
||||
// Pas d'appel à setCommandDestination : dest_latitude/dest_longitude restent à 0/NULL.
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": 48.8566, "longitude": 2.3522}) // Paris, sans rapport
|
||||
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
|
||||
handlers.UpdateDeliveryStatus(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("sans destination enregistrée, la validation GPS doit être ignorée: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "livre" {
|
||||
t.Errorf("statut: got=%s want=livre", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDeliveryStatus_RejectsWhenNotAssignedToThisLivreur(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
assignedLivreur := newTestClient(t, "gps_assigned_livreur")
|
||||
intruder := newTestClient(t, "gps_intruder_livreur")
|
||||
client := newTestClient(t, "gps_client_wrong_livreur")
|
||||
productID := newTestProduct(t, "GPSWrongLivreur", 10)
|
||||
cmdID := newTestCommandWithItem(t, client, "en_route", assignedLivreur, productID, 1, 10)
|
||||
setCommandDestination(t, cmdID, nantesLat, nantesLon)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": nantesLat, "longitude": nantesLon})
|
||||
c, rec := deliveryStatusContextJSON(intruder, cmdID, body)
|
||||
handlers.UpdateDeliveryStatus(c)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("un livreur non assigné doit être rejeté: got=%d want=%d body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "en_route" {
|
||||
t.Errorf("le statut ne doit pas changer: got=%s want=en_route", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestProductWithCategory crée un produit de test avec une catégorie
|
||||
// personnalisée (contrairement à newTestProduct qui pose toujours "test") —
|
||||
// nécessaire ici pour distinguer les catégories dans le routage par livreur.
|
||||
func newTestProductWithCategory(t *testing.T, name, category 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 (?, ?, '', ?) RETURNING id`,
|
||||
fullName, category, 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
|
||||
}
|
||||
|
||||
// setLivreurStatus place directement en Redis le statut d'un livreur, comme
|
||||
// le ferait l'app livreur en production (clé "delivery:status:{username}").
|
||||
func setLivreurStatus(t *testing.T, username, status string) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(models.DeliveryPersonStatus{
|
||||
Username: username,
|
||||
Status: status,
|
||||
LastUpdate: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal DeliveryPersonStatus: %v", err)
|
||||
}
|
||||
key := "delivery:status:" + username
|
||||
if err := db.Redis.Set(db.RedisCtx, key, data, 0).Err(); err != nil {
|
||||
t.Fatalf("setLivreurStatus: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Redis.Del(db.RedisCtx, key)
|
||||
})
|
||||
}
|
||||
|
||||
func setDeliveryModeSettings(t *testing.T, mode models.DeliveryModeConfig) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(mode)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal delivery_mode: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO app_settings (key, value) VALUES ('delivery_mode', ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
string(data),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("setDeliveryModeSettings: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'delivery_mode'`)
|
||||
})
|
||||
}
|
||||
|
||||
func containsUsername(list []string, username string) bool {
|
||||
for _, u := range list {
|
||||
if u == username {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── GetCommandCategories ─────────────────────────────────────────────────────
|
||||
|
||||
func TestGetCommandCategories_ReturnsDistinctProductCategories(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delivmode_categories")
|
||||
productA := newTestProductWithCategory(t, "CatA", "cat_a", 10)
|
||||
productB := newTestProductWithCategory(t, "CatB", "cat_b", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10)
|
||||
testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`,
|
||||
cmdID, productB,
|
||||
)
|
||||
|
||||
categories, err := testDB.GetCommandCategories(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandCategories: %v", err)
|
||||
}
|
||||
if len(categories) != 2 || !containsUsername(categories, "cat_a") || !containsUsername(categories, "cat_b") {
|
||||
t.Errorf("catégories: got=%v want=[cat_a cat_b]", categories)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetEligibleDeliverymenForCommand ─────────────────────────────────────────
|
||||
|
||||
func TestGetEligibleDeliverymenForCommand_SingleModeReturnsAllActive(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delivmode_single")
|
||||
productID := newTestProductWithCategory(t, "Single", "cat_a", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
livreurA := testUserPrefix + "delivmode_single_a"
|
||||
livreurB := testUserPrefix + "delivmode_single_b"
|
||||
setLivreurStatus(t, livreurA, "available")
|
||||
setLivreurStatus(t, livreurB, "available")
|
||||
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"})
|
||||
|
||||
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||
}
|
||||
if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) {
|
||||
t.Errorf("mode single doit renvoyer tous les livreurs actifs: got=%v", eligible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEligibleDeliverymenForCommand_CategoryBasedFiltersToMatchingRoute(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delivmode_filter")
|
||||
productID := newTestProductWithCategory(t, "Filter", "cat_a", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
livreurA := testUserPrefix + "delivmode_filter_a"
|
||||
livreurB := testUserPrefix + "delivmode_filter_b"
|
||||
setLivreurStatus(t, livreurA, "available")
|
||||
setLivreurStatus(t, livreurB, "available")
|
||||
setDeliveryModeSettings(t, models.DeliveryModeConfig{
|
||||
Mode: "category_based",
|
||||
CategoryRoutes: []models.CategoryRoute{
|
||||
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}},
|
||||
{DeliverymanUsername: livreurB, Categories: []string{"cat_b"}},
|
||||
},
|
||||
})
|
||||
|
||||
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||
}
|
||||
if !containsUsername(eligible, livreurA) {
|
||||
t.Errorf("livreurA (cat_a) doit être éligible: got=%v", eligible)
|
||||
}
|
||||
if containsUsername(eligible, livreurB) {
|
||||
t.Errorf("livreurB (cat_b, non commandée) ne doit pas être éligible: got=%v", eligible)
|
||||
}
|
||||
}
|
||||
|
||||
// Cas limite documenté explicitement dans le modèle métier : une commande
|
||||
// mixte (catégories relevant de livreurs différents) doit renvoyer l'UNION
|
||||
// des livreurs éligibles, pas une intersection (aucun livreur unique ne gère
|
||||
// forcément toutes les catégories à la fois).
|
||||
func TestGetEligibleDeliverymenForCommand_MixedCategoryCommand_ReturnsUnion(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delivmode_mixed")
|
||||
productA := newTestProductWithCategory(t, "MixedA", "cat_a", 10)
|
||||
productB := newTestProductWithCategory(t, "MixedB", "cat_b", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10)
|
||||
testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`,
|
||||
cmdID, productB,
|
||||
)
|
||||
|
||||
livreurA := testUserPrefix + "delivmode_mixed_a"
|
||||
livreurB := testUserPrefix + "delivmode_mixed_b"
|
||||
setLivreurStatus(t, livreurA, "available")
|
||||
setLivreurStatus(t, livreurB, "available")
|
||||
setDeliveryModeSettings(t, models.DeliveryModeConfig{
|
||||
Mode: "category_based",
|
||||
CategoryRoutes: []models.CategoryRoute{
|
||||
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}},
|
||||
{DeliverymanUsername: livreurB, Categories: []string{"cat_b"}},
|
||||
},
|
||||
})
|
||||
|
||||
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||
}
|
||||
if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) {
|
||||
t.Errorf("commande mixte cat_a+cat_b doit renvoyer l'union des deux livreurs: got=%v", eligible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEligibleDeliverymenForCommand_NoRouteMatchesFallsBackToAllActive(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delivmode_nomatch")
|
||||
productID := newTestProductWithCategory(t, "NoMatch", "cat_c", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
livreurA := testUserPrefix + "delivmode_nomatch_a"
|
||||
setLivreurStatus(t, livreurA, "available")
|
||||
setDeliveryModeSettings(t, models.DeliveryModeConfig{
|
||||
Mode: "category_based",
|
||||
CategoryRoutes: []models.CategoryRoute{
|
||||
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, // ne couvre pas cat_c
|
||||
},
|
||||
})
|
||||
|
||||
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||
}
|
||||
if !containsUsername(eligible, livreurA) {
|
||||
t.Errorf("aucune route ne couvre cat_c -> repli sur tous les livreurs actifs: got=%v", eligible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEligibleDeliverymenForCommand_EmptyCategoryRoutesFallsBackToAllActive(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delivmode_emptyroutes")
|
||||
productID := newTestProductWithCategory(t, "EmptyRoutes", "cat_a", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
livreurA := testUserPrefix + "delivmode_emptyroutes_a"
|
||||
setLivreurStatus(t, livreurA, "available")
|
||||
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "category_based", CategoryRoutes: []models.CategoryRoute{}})
|
||||
|
||||
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||
}
|
||||
if !containsUsername(eligible, livreurA) {
|
||||
t.Errorf("category_based sans route configurée -> repli sur tous les livreurs actifs: got=%v", eligible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEligibleDeliverymenForCommand_OfflineLivreurNeverEligible(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delivmode_offline")
|
||||
productID := newTestProductWithCategory(t, "Offline", "cat_a", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
onlineLivreur := testUserPrefix + "delivmode_offline_online"
|
||||
offlineLivreur := testUserPrefix + "delivmode_offline_offline"
|
||||
setLivreurStatus(t, onlineLivreur, "available")
|
||||
setLivreurStatus(t, offlineLivreur, "offline")
|
||||
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"})
|
||||
|
||||
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
|
||||
}
|
||||
if containsUsername(eligible, offlineLivreur) {
|
||||
t.Errorf("un livreur offline ne doit jamais être éligible: got=%v", eligible)
|
||||
}
|
||||
if !containsUsername(eligible, onlineLivreur) {
|
||||
t.Errorf("le livreur en ligne doit être éligible: got=%v", eligible)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gestion/db"
|
||||
"gestion/handlers"
|
||||
"gestion/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// db.Redis n'est initialisé qu'après TestMain (db.InitRedis) — un var
|
||||
// package-level serait construit trop tôt, d'où cette init paresseuse.
|
||||
var testGeoService *services.GeoService
|
||||
|
||||
// ensureTestGeoService initialise paresseusement le GeoService partagé
|
||||
// (db.Redis n'existe qu'après TestMain) — réutilisé par les autres fichiers
|
||||
// de tests qui ont besoin de "geoService" dans le contexte gin.
|
||||
func ensureTestGeoService() *services.GeoService {
|
||||
if testGeoService == nil {
|
||||
testGeoService = services.NewGeoService(db.Redis, db.RedisCtx)
|
||||
}
|
||||
return testGeoService
|
||||
}
|
||||
|
||||
func etaContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/eta", bytes.NewReader(nil))
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("geoService", ensureTestGeoService())
|
||||
if setUsername {
|
||||
c.Set("username", username)
|
||||
}
|
||||
c.Set("role", role)
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func TestGetOrderETA_UnauthenticatedReturns401(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := etaContext("", "client", 1, false)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrderETA_InvalidCommandIDReturns400(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := etaContext(testUserPrefix+"eta_badid", "client", 0, true)
|
||||
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrderETA_UnknownCommandReturns404(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := etaContext(testUserPrefix+"eta_404", "client", 99999999, true)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("commande inconnue doit retourner 404: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrderETA_ClientAccessingAnotherClientCommandReturns403(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "eta_owner")
|
||||
intruder := newTestClient(t, "eta_intruder")
|
||||
productID := newTestProduct(t, "EtaOwner", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur", productID, 1, 10)
|
||||
|
||||
c, rec := etaContext(intruder, "client", cmdID, true)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("un autre client ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrderETA_LivreurNotAssignedReturns403(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "eta_lv_owner")
|
||||
productID := newTestProduct(t, "EtaLvOwner", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_real_livreur", productID, 1, 10)
|
||||
|
||||
c, rec := etaContext("eta_other_livreur", "livreur", cmdID, true)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("un livreur non assigné ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrderETA_AdminBypassesOwnershipChecks(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "eta_admin_owner")
|
||||
productID := newTestProduct(t, "EtaAdminOwner", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
|
||||
|
||||
c, rec := etaContext(testUserPrefix+"eta_admin", "admin", cmdID, true)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("admin doit pouvoir accéder à l'ETA de n'importe quelle commande: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrderETA_DeliveredStatusReturnsEtaUnavailable(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "eta_delivered")
|
||||
productID := newTestProduct(t, "EtaDelivered", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "livre", "eta_livreur_d", productID, 1, 10)
|
||||
|
||||
c, rec := etaContext(owner, "client", cmdID, true)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("statut livre doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||
t.Errorf("eta_available doit être false pour une commande livrée: body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrderETA_PendingStatusReturnsEtaUnavailable(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "eta_pending")
|
||||
productID := newTestProduct(t, "EtaPending", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
|
||||
|
||||
c, rec := etaContext(owner, "client", cmdID, true)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("statut pending doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||
t.Errorf("eta_available doit être false pour une commande pending: body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrderETA_ArrivedStatusReturnsEtaUnavailable(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "eta_arrived")
|
||||
productID := newTestProduct(t, "EtaArrived", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "arrived", "eta_livreur_a", productID, 1, 10)
|
||||
|
||||
c, rec := etaContext(owner, "client", cmdID, true)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("statut arrived doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||
t.Errorf("eta_available doit être false quand le livreur est arrivé: body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrderETA_NoLivreurAssignedReturnsEtaUnavailable(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "eta_nolivreur")
|
||||
productID := newTestProduct(t, "EtaNoLivreur", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "en_route", "", productID, 1, 10)
|
||||
|
||||
c, rec := etaContext(owner, "client", cmdID, true)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("sans livreur assigné doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||
t.Errorf("eta_available doit être false sans livreur assigné: body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Sans coordonnées de destination et sans cache Redis préalable, le
|
||||
// handler doit tomber sur returnStaleOrUnavailable plutôt que planter.
|
||||
func TestGetOrderETA_MissingDestinationCoordsFallsBackGracefully(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "eta_nodest")
|
||||
productID := newTestProduct(t, "EtaNoDest", 10)
|
||||
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur_nodest", productID, 1, 10)
|
||||
|
||||
c, rec := etaContext(owner, "client", cmdID, true)
|
||||
handlers.GetOrderETA(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("sans coordonnées destination doit quand même retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
|
||||
t.Errorf("eta_available doit être false sans coordonnées destination: body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"gestion/services"
|
||||
)
|
||||
|
||||
// Deux points à Nantes séparés d'environ 1.1km à vol d'oiseau (Haversine).
|
||||
var (
|
||||
nantesCentre = services.Coordinates{Latitude: 47.2184, Longitude: -1.5536}
|
||||
nantesProche = services.Coordinates{Latitude: 47.2280, Longitude: -1.5536}
|
||||
)
|
||||
|
||||
func TestCalculateDistance_HaversineCorrectness(t *testing.T) {
|
||||
dist := services.CalculateDistance(nantesCentre, nantesProche)
|
||||
// ~0.0096 rad de latitude ≈ 1.067km — tolérance large pour la formule.
|
||||
if dist < 0.9 || dist > 1.3 {
|
||||
t.Errorf("distance Haversine hors plage attendue: got=%.3fkm want≈1.07km", dist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDistance_SamePointIsZero(t *testing.T) {
|
||||
dist := services.CalculateDistance(nantesCentre, nantesCentre)
|
||||
if dist != 0 {
|
||||
t.Errorf("distance entre un point et lui-même doit être 0: got=%.4f", dist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateETA_UnderPointOneKmReturnsMinETA(t *testing.T) {
|
||||
eta := services.CalculateETA(0.05)
|
||||
if eta != services.MinETA {
|
||||
t.Errorf("distance < 0.1km doit retourner MinETA: got=%d want=%d", eta, services.MinETA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateETA_AppliesTwentyPercentTrafficMargin(t *testing.T) {
|
||||
// 25km à 25km/h = 60min ; +20% marge = 72min (dans les bornes [MinETA, MaxETA]).
|
||||
eta := services.CalculateETA(25)
|
||||
want := 72
|
||||
if eta != want {
|
||||
t.Errorf("ETA avec marge trafic 20%%: got=%d want=%d", eta, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateETA_ClampsToMaxETA(t *testing.T) {
|
||||
eta := services.CalculateETA(1000)
|
||||
if eta != services.MaxETA {
|
||||
t.Errorf("très longue distance doit être plafonnée à MaxETA: got=%d want=%d", eta, services.MaxETA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateETA_ClampsToMinETA(t *testing.T) {
|
||||
// distance faible mais non nulle, donnant un temps de trajet < MinETA
|
||||
// après calcul (pas la branche <0.1km, une distance différente).
|
||||
eta := services.CalculateETA(0.5)
|
||||
if eta < services.MinETA {
|
||||
t.Errorf("ETA ne doit jamais être inférieur à MinETA: got=%d want>=%d", eta, services.MinETA)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans clé API TomTom configurée (cas de cet environnement de test),
|
||||
// CalculateETAWithTomTom doit retomber sur le calcul local identique à
|
||||
// CalculateDistance+CalculateETA.
|
||||
func TestCalculateETAWithTomTom_FallsBackToLocalCalcWithoutAPIKey(t *testing.T) {
|
||||
etaMinutes, distanceKm, err := services.CalculateETAWithTomTom(nantesCentre, nantesProche)
|
||||
if err != nil {
|
||||
t.Fatalf("fallback local ne doit pas retourner d'erreur: %v", err)
|
||||
}
|
||||
wantDistance := services.CalculateDistance(nantesCentre, nantesProche)
|
||||
wantETA := services.CalculateETA(wantDistance)
|
||||
if math.Abs(distanceKm-wantDistance) > 0.0001 {
|
||||
t.Errorf("distance fallback: got=%.4f want=%.4f", distanceKm, wantDistance)
|
||||
}
|
||||
if etaMinutes != wantETA {
|
||||
t.Errorf("eta fallback: got=%d want=%d", etaMinutes, wantETA)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package tests
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetLastDeliveryCoords_NoPreviousDeliveryReturnsError(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
_, _, err := testDB.GetLastDeliveryCoords(testUserPrefix + "coords_nolivraison")
|
||||
if err == nil {
|
||||
t.Fatal("sans livraison précédente, une erreur est attendue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLastDeliveryCoords_FallsBackToDBWhenNoCache(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "coords_db")
|
||||
livreur := testUserPrefix + "coords_db_livreur"
|
||||
productID := newTestProduct(t, "CoordsDB", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
|
||||
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 47.2184, dest_longitude = -1.5536, updated_at = NOW() WHERE id = ?`, cmdID)
|
||||
|
||||
lat, lon, err := testDB.GetLastDeliveryCoords(livreur)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLastDeliveryCoords: %v", err)
|
||||
}
|
||||
if lat != 47.2184 || lon != -1.5536 {
|
||||
t.Errorf("coordonnées depuis la DB: got=(%.4f,%.4f) want=(47.2184,-1.5536)", lat, lon)
|
||||
}
|
||||
}
|
||||
|
||||
// Une fois les coordonnées lues depuis la DB, elles sont mises en cache
|
||||
// Redis — un second appel doit renvoyer la valeur cachée même si la DB
|
||||
// change entretemps (TTL non expiré).
|
||||
func TestGetLastDeliveryCoords_CachesResultInRedis(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "coords_cache")
|
||||
livreur := testUserPrefix + "coords_cache_livreur"
|
||||
productID := newTestProduct(t, "CoordsCache", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
|
||||
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 48.8566, dest_longitude = 2.3522, updated_at = NOW() WHERE id = ?`, cmdID)
|
||||
|
||||
lat1, _, err := testDB.GetLastDeliveryCoords(livreur)
|
||||
if err != nil {
|
||||
t.Fatalf("premier appel: %v", err)
|
||||
}
|
||||
if lat1 != 48.8566 {
|
||||
t.Fatalf("premier appel doit lire la DB: got lat=%.4f want=48.8566", lat1)
|
||||
}
|
||||
|
||||
// Change la valeur en DB — un cache-hit doit ignorer ce changement.
|
||||
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 0, dest_longitude = 0 WHERE id = ?`, cmdID)
|
||||
|
||||
lat2, lon2, err := testDB.GetLastDeliveryCoords(livreur)
|
||||
if err != nil {
|
||||
t.Fatalf("second appel (cache attendu): %v", err)
|
||||
}
|
||||
if lat2 != 48.8566 || lon2 != 2.3522 {
|
||||
t.Errorf("second appel doit retourner la valeur cachée, pas la DB modifiée: got=(%.4f,%.4f) want=(48.8566,2.3522)", lat2, lon2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const notificationsScheduledKey = "notifications:scheduled"
|
||||
|
||||
// ScheduleETANotifications programme un rappel 5min et 3min avant l'arrivée
|
||||
// estimée — mais seulement si l'ETA le justifie (voir bornes ci-dessous).
|
||||
func TestScheduleETANotifications_SchedulesBothForLongETA(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_long")
|
||||
productID := newTestProduct(t, "EtaNotifLong", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
before := time.Now()
|
||||
if err := testDB.ScheduleETANotifications(cmdID, 10); err != nil {
|
||||
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||
}
|
||||
|
||||
score5, err := getScheduledScore(t, cmdID, "5min")
|
||||
if err != nil {
|
||||
t.Fatalf("notification 5min absente: %v", err)
|
||||
}
|
||||
score3, err := getScheduledScore(t, cmdID, "3min")
|
||||
if err != nil {
|
||||
t.Fatalf("notification 3min absente: %v", err)
|
||||
}
|
||||
|
||||
wantAt5 := before.Add(5 * time.Minute).Unix() // arrivée dans 10min - 5min = dans 5min
|
||||
wantAt3 := before.Add(7 * time.Minute).Unix() // arrivée dans 10min - 3min = dans 7min
|
||||
if diff := abs64(score5 - wantAt5); diff > 2 {
|
||||
t.Errorf("score notification 5min: got=%d want≈%d (écart %ds)", score5, wantAt5, diff)
|
||||
}
|
||||
if diff := abs64(score3 - wantAt3); diff > 2 {
|
||||
t.Errorf("score notification 3min: got=%d want≈%d (écart %ds)", score3, wantAt3, diff)
|
||||
}
|
||||
if score3 <= score5 {
|
||||
t.Errorf("la notification 3min doit être programmée après la 5min (plus proche de l'arrivée): score5=%d score3=%d", score5, score3)
|
||||
}
|
||||
}
|
||||
|
||||
// À la limite exacte (ETA = 5 min), un rappel "5 minutes avant l'arrivée"
|
||||
// se déclencherait immédiatement (redondant) — il n'est donc volontairement
|
||||
// pas programmé (condition stricte ">5", pas ">=5"). Seul le rappel 3min
|
||||
// reste pertinent.
|
||||
func TestScheduleETANotifications_ExactlyFiveMinutes_SkipsFiveMinReminder(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_five")
|
||||
productID := newTestProduct(t, "EtaNotifFive", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.ScheduleETANotifications(cmdID, 5); err != nil {
|
||||
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||
}
|
||||
|
||||
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
|
||||
t.Error("aucune notification 5min ne doit être programmée quand ETA=5min exactement")
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
|
||||
t.Errorf("la notification 3min doit être programmée quand ETA=5min: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// À ETA = 3 min, même le rappel "3 minutes avant" serait immédiat : aucune
|
||||
// notification ne doit être programmée.
|
||||
func TestScheduleETANotifications_ExactlyThreeMinutes_SchedulesNothing(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_three")
|
||||
productID := newTestProduct(t, "EtaNotifThree", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.ScheduleETANotifications(cmdID, 3); err != nil {
|
||||
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||
}
|
||||
|
||||
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
|
||||
t.Error("aucune notification 5min ne doit être programmée pour ETA=3min")
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
|
||||
t.Error("aucune notification 3min ne doit être programmée pour ETA=3min (serait immédiate)")
|
||||
}
|
||||
}
|
||||
|
||||
// Une ETA très courte (MinETA=3min, plancher de tout le système) ne doit
|
||||
// jamais programmer de notification de rappel — cohérent avec le cas ci-dessus.
|
||||
func TestScheduleETANotifications_VeryShortETASchedulesNothing(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_short")
|
||||
productID := newTestProduct(t, "EtaNotifShort", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.ScheduleETANotifications(cmdID, 1); err != nil {
|
||||
t.Fatalf("ScheduleETANotifications: %v", err)
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
|
||||
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
|
||||
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
|
||||
}
|
||||
}
|
||||
|
||||
// SetCommandETA (appelée par UpdateDeliveryStatus au passage en "en_route")
|
||||
// déclenche automatiquement ScheduleETANotifications — vérifie l'intégration
|
||||
// complète, pas seulement la fonction isolée.
|
||||
func TestSetCommandETA_TriggersScheduledNotifications(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_integration")
|
||||
productID := newTestProduct(t, "EtaNotifIntegration", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETA(cmdID, 15); err != nil {
|
||||
t.Fatalf("SetCommandETA: %v", err)
|
||||
}
|
||||
|
||||
if _, err := getScheduledScore(t, cmdID, "5min"); err != nil {
|
||||
t.Errorf("SetCommandETA doit programmer une notification 5min: %v", err)
|
||||
}
|
||||
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
|
||||
t.Errorf("SetCommandETA doit programmer une notification 3min: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Le message envoyé au client doit contenir une indication de temps lisible,
|
||||
// pas juste le statut brut.
|
||||
func TestSendETANotification_MessageContainsReadableTime(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_notif_message")
|
||||
productID := newTestProduct(t, "EtaNotifMessage", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
|
||||
|
||||
channel := fmt.Sprintf("notifications:command:%d", cmdID)
|
||||
pubsub := db.Redis.Subscribe(db.RedisCtx, channel)
|
||||
defer pubsub.Close()
|
||||
// Consommer le message de confirmation d'abonnement avant de publier.
|
||||
if _, err := pubsub.Receive(db.RedisCtx); err != nil {
|
||||
t.Fatalf("abonnement pubsub: %v", err)
|
||||
}
|
||||
|
||||
testDB.SendETANotification(cmdID, "5min")
|
||||
|
||||
select {
|
||||
case msg := <-pubsub.Channel():
|
||||
if msg.Payload == "" {
|
||||
t.Fatal("message de notification vide")
|
||||
}
|
||||
if !strings.Contains(msg.Payload, "5min") {
|
||||
t.Errorf("le message doit indiquer le temps restant (%q): %q", "5min", msg.Payload)
|
||||
}
|
||||
t.Logf("message reçu: %q", msg.Payload)
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("aucun message reçu sur le canal de notification dans le délai imparti")
|
||||
}
|
||||
}
|
||||
|
||||
func abs64(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// getScheduledScore lit le score (timestamp Unix) d'une notification
|
||||
// programmée pour "{commandID}:{suffix}" dans le sorted set Redis.
|
||||
func getScheduledScore(t *testing.T, commandID int, suffix string) (int64, error) {
|
||||
t.Helper()
|
||||
member := fmt.Sprintf("%d:%s", commandID, suffix)
|
||||
score, err := db.Redis.ZScore(db.RedisCtx, notificationsScheduledKey, member).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(score), nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/handlers"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Les clients ont signalé ne jamais voir le temps de livraison (notifications,
|
||||
// app mobile, site web). Ces tests reproduisent le chemin réel : une commande
|
||||
// est assignée automatiquement (le worker de queue appelle
|
||||
// SetCommandETAWithDetails, PAS SetCommandETA), puis un client consulte le
|
||||
// suivi de sa commande — exactement ce que fait l'app mobile / le site web.
|
||||
|
||||
func etaTestContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/x/tracking", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", username)
|
||||
c.Set("role", "client")
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// SetCommandETAWithDetails est le chemin utilisé par le worker
|
||||
// d'auto-assignation/réoptimisation de queue (db/redis_queue_optimization.go,
|
||||
// db/redis_queue_assignment.go) — de loin le plus emprunté en production.
|
||||
// Il doit remplir "eta_minutes", pas seulement "total_eta_minutes", car
|
||||
// c'est le champ que l'app mobile et le site web lisent (confirmé dans
|
||||
// mobile/src/screens/client/OrderTrackingScreen.tsx et api.ts des deux
|
||||
// frontends).
|
||||
func TestSetCommandETAWithDetails_WritesEtaMinutesField(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_details_field")
|
||||
productID := newTestProduct(t, "EtaDetailsField", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETAWithDetails(cmdID, 22, 1); err != nil {
|
||||
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||
}
|
||||
|
||||
etaData, err := testDB.GetCommandETA(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandETA: %v", err)
|
||||
}
|
||||
|
||||
got, ok := etaData["eta_minutes"]
|
||||
if !ok || got == "" {
|
||||
t.Errorf(`champ "eta_minutes" absent après SetCommandETAWithDetails (contenu: %v) — `+
|
||||
`c'est le champ lu par le mobile et le site web, d'où l'absence de temps affiché`, etaData)
|
||||
} else if got != "22" {
|
||||
t.Errorf(`"eta_minutes" = %q, want "22"`, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Reproduction bout-en-bout du symptôme signalé : après une assignation
|
||||
// automatique (SetCommandETAWithDetails), le client consulte le suivi de sa
|
||||
// commande (GetCommandTracking, l'endpoint utilisé par l'app mobile et le
|
||||
// site web) — la réponse doit exposer eta.eta_minutes.
|
||||
func TestGetCommandTracking_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_tracking_client")
|
||||
productID := newTestProduct(t, "EtaTrackingClient", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA2", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETAWithDetails(cmdID, 17, 2); err != nil {
|
||||
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||
}
|
||||
|
||||
c, rec := etaTestContext(username, cmdID)
|
||||
handlers.GetCommandTracking(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
ETA map[string]any `json:"eta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
|
||||
etaMinutesRaw, ok := resp.ETA["eta_minutes"]
|
||||
if !ok {
|
||||
t.Fatalf(`la réponse de /tracking n'expose pas "eta.eta_minutes" (contenu eta: %v) — `+
|
||||
`reproduit exactement le bug signalé par les clients`, resp.ETA)
|
||||
}
|
||||
etaMinutesStr, _ := etaMinutesRaw.(string)
|
||||
if got, _ := strconv.Atoi(etaMinutesStr); got != 17 {
|
||||
t.Errorf("eta.eta_minutes: got=%v want=17", etaMinutesRaw)
|
||||
}
|
||||
}
|
||||
|
||||
// Même reproduction via GetCommandStatus (autre endpoint de suivi, utilisé
|
||||
// par l'app mobile pour le statut temps réel).
|
||||
func TestGetCommandStatus_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_status_client")
|
||||
productID := newTestProduct(t, "EtaStatusClient", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA3", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETAWithDetails(cmdID, 9, 1); err != nil {
|
||||
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||
}
|
||||
|
||||
c, rec := etaTestContext(username, cmdID)
|
||||
handlers.GetCommandStatus(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
ETA map[string]any `json:"eta"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v", err)
|
||||
}
|
||||
if _, ok := resp.ETA["eta_minutes"]; !ok {
|
||||
t.Fatalf(`GetCommandStatus n'expose pas "eta.eta_minutes" (contenu: %v)`, resp.ETA)
|
||||
}
|
||||
}
|
||||
|
||||
// SetCommandETA (chemin utilisé par UpdateDeliveryStatus côté livreur) doit
|
||||
// lui aussi rester lisible par les lecteurs qui attendent "total_eta_minutes"
|
||||
// (handlers/deleviry.go, validation_deleviry.go, geoloca.go).
|
||||
func TestSetCommandETA_AlsoWritesTotalEtaMinutesField(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_simple_field")
|
||||
productID := newTestProduct(t, "EtaSimpleField", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA4", productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
|
||||
t.Fatalf("SetCommandETA: %v", err)
|
||||
}
|
||||
|
||||
etaData, err := testDB.GetCommandETA(cmdID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCommandETA: %v", err)
|
||||
}
|
||||
if got, ok := etaData["total_eta_minutes"]; !ok || got != "14" {
|
||||
t.Errorf(`"total_eta_minutes" = %q (présent=%v), want "14"`, got, ok)
|
||||
}
|
||||
if got, ok := etaData["eta_minutes"]; !ok || got != "14" {
|
||||
t.Errorf(`"eta_minutes" = %q (présent=%v), want "14"`, got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeliverymanLocationForCommand (vue admin/cabine) lisait l'ETA via
|
||||
// Redis.Get sur une clé qui est en réalité un hash (HSet) — l'erreur
|
||||
// WRONGTYPE était silencieusement ignorée et etaMinutes restait toujours à 0.
|
||||
func TestGetDeliverymanLocationForCommand_ExposesEtaMinutes(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "eta_admin_view_client")
|
||||
productID := newTestProduct(t, "EtaAdminView", 10)
|
||||
livreurUsername := testUserPrefix + "eta_admin_view_livreur"
|
||||
cmdID := newTestCommandWithItem(t, username, "en_route", livreurUsername, productID, 1, 10)
|
||||
|
||||
if err := testDB.SetCommandETAWithDetails(cmdID, 12, 1); err != nil {
|
||||
t.Fatalf("SetCommandETAWithDetails: %v", err)
|
||||
}
|
||||
// Position GPS du livreur, requise par le handler avant de lire l'ETA.
|
||||
if err := testDB.UpdateDeliveryPersonLocation(livreurUsername, 47.2148, -1.5584); err != nil {
|
||||
t.Fatalf("UpdateDeliveryPersonLocation: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", "admin_test")
|
||||
c.Set("role", "admin")
|
||||
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(cmdID)}}
|
||||
|
||||
handlers.GetDeliverymanLocationForCommand(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
ETA struct {
|
||||
Minutes float64 `json:"minutes"`
|
||||
HasETA bool `json:"has_eta"`
|
||||
} `json:"eta"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !resp.Data.ETA.HasETA {
|
||||
t.Errorf("has_eta devrait être true, ETA pourtant définie via SetCommandETAWithDetails")
|
||||
}
|
||||
if resp.Data.ETA.Minutes != 12 {
|
||||
t.Errorf("data.eta.minutes: got=%.0f want=12", resp.Data.ETA.Minutes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/handlers"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newTestDeliveredOrder crée une commande livrée/approuvée assignée à un
|
||||
// livreur avec une date de mise à jour contrôlée (GetMyDeliveryStats groupe
|
||||
// par updated_at, pas created_at).
|
||||
func newTestDeliveredOrder(t *testing.T, livreurUsername, clientUsername, status string, productID int, quantite, prix, referralUsed float64, updatedAt time.Time) {
|
||||
t.Helper()
|
||||
var cmdID int
|
||||
if err := testDB.GDB.Raw(
|
||||
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, referral_used, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`,
|
||||
clientUsername, status, livreurUsername, prix, referralUsed, updatedAt, updatedAt,
|
||||
).Scan(&cmdID).Error; err != nil {
|
||||
t.Fatalf("création commande livrée test: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||
VALUES (?, ?, 'item test', ?, ?, 'delivered')`,
|
||||
cmdID, productID, quantite, prix,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création item commande livrée test: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func livreurStatsContext(username string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/livreur/stats", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", username)
|
||||
c.Set("role", "livreur")
|
||||
return c, rec
|
||||
}
|
||||
|
||||
type deliveryStatsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
TodayCount int `json:"today_count"`
|
||||
TodayRevenue float64 `json:"today_revenue"`
|
||||
}
|
||||
|
||||
// today_count/today_revenue ne doivent compter que les livraisons du jour
|
||||
// courant (livre/approved), nettes du crédit de parrainage — pas les jours
|
||||
// précédents, même récents (voir by_day/by_week qui eux les agrègent).
|
||||
func TestGetMyDeliveryStats_TodayCountAndRevenue(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreurUsername := newTestClient(t, "stats_livreur_today")
|
||||
clientUsername := newTestClient(t, "stats_livreur_today_client")
|
||||
productID := newTestProduct(t, "StatsLivreurToday", 100)
|
||||
|
||||
now := time.Now()
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
|
||||
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 2, 40, 10, now) // net 30, aujourd'hui
|
||||
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 1, 25, 0, yesterday) // hier, ne doit pas compter dans "today"
|
||||
|
||||
c, rec := livreurStatsContext(livreurUsername)
|
||||
handlers.GetMyDeliveryStats(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp deliveryStatsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success=false, body=%s", rec.Body.String())
|
||||
}
|
||||
if resp.TodayCount != 1 {
|
||||
t.Errorf("today_count: got=%d want=1 (la commande d'hier ne doit pas compter)", resp.TodayCount)
|
||||
}
|
||||
if resp.TodayRevenue != 30 {
|
||||
t.Errorf("today_revenue: got=%.2f want=30 (40 - 10 de parrainage)", resp.TodayRevenue)
|
||||
}
|
||||
}
|
||||
|
||||
// Aucune livraison aujourd'hui : today_count/today_revenue doivent être 0,
|
||||
// pas une absence de champ (voir le bug initial où le seul indicateur de
|
||||
// "livraisons du jour" était l'absence de ligne dans by_day).
|
||||
func TestGetMyDeliveryStats_TodayCountZeroWhenNoDeliveryToday(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreurUsername := newTestClient(t, "stats_livreur_none")
|
||||
|
||||
c, rec := livreurStatsContext(livreurUsername)
|
||||
handlers.GetMyDeliveryStats(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp deliveryStatsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if resp.TodayCount != 0 {
|
||||
t.Errorf("today_count: got=%d want=0", resp.TodayCount)
|
||||
}
|
||||
if resp.TodayRevenue != 0 {
|
||||
t.Errorf("today_revenue: got=%.2f want=0", resp.TodayRevenue)
|
||||
}
|
||||
}
|
||||
|
||||
// Seules les commandes assignées à CE livreur doivent être comptées.
|
||||
func TestGetMyDeliveryStats_OnlyCountsOwnDeliveries(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
livreurA := newTestClient(t, "stats_livreur_a")
|
||||
livreurB := newTestClient(t, "stats_livreur_b")
|
||||
client := newTestClient(t, "stats_livreur_shared_client")
|
||||
productID := newTestProduct(t, "StatsLivreurIsolation", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestDeliveredOrder(t, livreurA, client, "approved", productID, 1, 20, 0, now)
|
||||
newTestDeliveredOrder(t, livreurB, client, "approved", productID, 1, 999, 0, now)
|
||||
|
||||
c, rec := livreurStatsContext(livreurA)
|
||||
handlers.GetMyDeliveryStats(c)
|
||||
|
||||
var resp deliveryStatsResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v", err)
|
||||
}
|
||||
if resp.TodayCount != 1 || resp.TodayRevenue != 20 {
|
||||
t.Errorf("stats livreur A ne doivent refléter que ses propres livraisons: got count=%d revenue=%.2f want count=1 revenue=20", resp.TodayCount, resp.TodayRevenue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package tests regroupe les tests de bout en bout de la gestion de stock,
|
||||
// écrits contre l'API publique des paquets db/ et handlers/ (aucun accès à
|
||||
// leurs symboles non exportés) — voir docker-compose.yml pour la base de
|
||||
// test locale nécessaire pour les exécuter (`go test ./tests/...`).
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// testDB est l'instance partagée par tous les tests de ce paquet. Toutes les
|
||||
// données créées utilisent un préfixe dédié (testUserPrefix / testProductPrefix)
|
||||
// et sont nettoyées avant et après chaque test, ce qui rend la suite sans
|
||||
// danger même si elle tourne contre une base partagée.
|
||||
var testDB *db.Database
|
||||
|
||||
const (
|
||||
testUserPrefix = "stocktest_"
|
||||
testProductPrefix = "TESTSTOCK_"
|
||||
)
|
||||
|
||||
// testPhoneCounter garantit un numéro de téléphone unique par client de test
|
||||
// (colonne UNIQUE sur clients.telephone).
|
||||
var testPhoneCounter int64
|
||||
|
||||
func nextTestPhone() string {
|
||||
n := atomic.AddInt64(&testPhoneCounter, 1)
|
||||
return fmt.Sprintf("+3361%09d", n)
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
testDB = db.InitDB()
|
||||
db.InitRedis()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// cleanupStockTestData supprime toutes les données créées par les tests de
|
||||
// gestion de stock (identifiées par leur préfixe), dans le bon ordre pour
|
||||
// respecter les contraintes de clé étrangère.
|
||||
func cleanupStockTestData(t *testing.T) {
|
||||
t.Helper()
|
||||
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username LIKE ?)`, testUserPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM commandes WHERE username LIKE ?`, testUserPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM baskets WHERE username LIKE ?`, testUserPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM clients WHERE username LIKE ?`, testUserPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name LIKE ?)`, testProductPrefix+"%")
|
||||
testDB.GDB.Exec(`DELETE FROM products WHERE name LIKE ?`, testProductPrefix+"%")
|
||||
}
|
||||
|
||||
// newTestProduct crée un produit de test avec un stock initial donné et un
|
||||
// prix actif pour quantity=1, et programme son nettoyage en fin de test.
|
||||
func newTestProduct(t *testing.T, name string, stock float64) int {
|
||||
t.Helper()
|
||||
fullName := testProductPrefix + name
|
||||
var id int
|
||||
if err := testDB.GDB.Raw(
|
||||
`INSERT INTO products (name, category, description, stock) VALUES (?, 'test', '', ?) RETURNING id`,
|
||||
fullName, stock,
|
||||
).Scan(&id).Error; err != nil {
|
||||
t.Fatalf("création produit test %q: %v", fullName, err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`,
|
||||
id,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création prix produit test %q: %v", fullName, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id)
|
||||
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id)
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
// productStock relit le stock courant d'un produit directement en base.
|
||||
func productStock(t *testing.T, productID int) float64 {
|
||||
t.Helper()
|
||||
var stock float64
|
||||
if err := testDB.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, productID).Scan(&stock).Error; err != nil {
|
||||
t.Fatalf("lecture stock produit %d: %v", productID, err)
|
||||
}
|
||||
return stock
|
||||
}
|
||||
|
||||
// newTestClient crée un client de test et programme son nettoyage en fin de test.
|
||||
func newTestClient(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
username := testUserPrefix + name
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO clients (username, password, nom, prenom, telephone) VALUES (?, 'x', 'T', 'C', ?)
|
||||
ON CONFLICT (username) DO NOTHING`,
|
||||
username, nextTestPhone(),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création client test %q: %v", username, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
|
||||
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username = ?)`, username)
|
||||
testDB.GDB.Exec(`DELETE FROM commandes WHERE username = ?`, username)
|
||||
testDB.GDB.Exec(`DELETE FROM clients WHERE username = ?`, username)
|
||||
})
|
||||
return username
|
||||
}
|
||||
|
||||
// newTestCommandWithItem crée directement une commande avec un item (en
|
||||
// contournant le checkout), pour tester isolément les chemins d'annulation
|
||||
// et de remboursement de stock. Retourne l'ID de la commande créée.
|
||||
func newTestCommandWithItem(t *testing.T, username, status, livreurAssign string, productID int, quantite float64, prix float64) int {
|
||||
t.Helper()
|
||||
var cmdID int
|
||||
if err := testDB.GDB.Raw(
|
||||
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, created_at, updated_at)
|
||||
VALUES (?, ?, NULLIF(?, ''), 'Adresse test', ?, NOW(), NOW()) RETURNING id`,
|
||||
username, status, livreurAssign, prix,
|
||||
).Scan(&cmdID).Error; err != nil {
|
||||
t.Fatalf("création commande test: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
|
||||
cmdID, productID, quantite, prix,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création item test: %v", err)
|
||||
}
|
||||
return cmdID
|
||||
}
|
||||
|
||||
func commandStatus(t *testing.T, commandID int) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&status)
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
"gestion/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Un MinIO local (docker) sert de backend S3 réel pour ces tests — pas de
|
||||
// mock : mêmes chemins de code que RustFS en production (SDK AWS v2,
|
||||
// UsePathStyle=true).
|
||||
var testS3Service *services.S3Service
|
||||
|
||||
func getTestS3Service(t *testing.T) *services.S3Service {
|
||||
t.Helper()
|
||||
if testS3Service == nil {
|
||||
s3, err := services.NewS3Service(
|
||||
"us-east-1", "test-products", "http://localhost:9500",
|
||||
services.S3Credentials{S3KeyId: "testadmin", S3AccessKey: "testpassword123"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewS3Service: %v", err)
|
||||
}
|
||||
testS3Service = s3
|
||||
}
|
||||
return testS3Service
|
||||
}
|
||||
|
||||
// tiny1x1PNG est un PNG valide minimal (1x1 pixel transparent), pour que
|
||||
// la détection MIME réelle (mimetype.DetectReader) le reconnaisse comme
|
||||
// image/png.
|
||||
var tiny1x1PNG = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
|
||||
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
||||
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
func uploadMediaRequest(fileType, fileName string, fileContent []byte) (*bytes.Buffer, string) {
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
w.WriteField("type", fileType)
|
||||
part, _ := w.CreateFormFile("file", fileName)
|
||||
part.Write(fileContent)
|
||||
w.Close()
|
||||
return body, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func mediaContext(t *testing.T, role string, body *bytes.Buffer, contentType string, productID int, mediaID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products/media", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("s3Service", getTestS3Service(t))
|
||||
c.Set("username", testUserPrefix+"media_admin")
|
||||
c.Set("role", role)
|
||||
params := gin.Params{}
|
||||
if productID != 0 {
|
||||
params = append(params, gin.Param{Key: "id", Value: fmt.Sprintf("%d", productID)})
|
||||
}
|
||||
if mediaID != 0 {
|
||||
params = append(params, gin.Param{Key: "media_id", Value: fmt.Sprintf("%d", mediaID)})
|
||||
}
|
||||
c.Params = params
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// ── UploadMedia ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestUploadMedia_RejectsNonAdmin(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "MediaRoleCheck", 5)
|
||||
body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
|
||||
c, rec := mediaContext(t, "cabine", body, ct, productID, 0)
|
||||
handlers.UploadMedia(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role cabine doit être refusé pour UploadMedia: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMedia_RejectsInvalidType(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "MediaBadType", 5)
|
||||
body, ct := uploadMediaRequest("audio", "photo.png", tiny1x1PNG)
|
||||
c, rec := mediaContext(t, "admin", body, ct, productID, 0)
|
||||
handlers.UploadMedia(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("type 'audio' invalide doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMedia_RejectsMimeMismatch(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "MediaMimeMismatch", 5)
|
||||
// Contenu texte brut mais annoncé comme "image" — le sniff MIME réel doit le détecter.
|
||||
body, ct := uploadMediaRequest("image", "fake.png", []byte("ceci n'est pas une image, juste du texte brut"))
|
||||
c, rec := mediaContext(t, "admin", body, ct, productID, 0)
|
||||
handlers.UploadMedia(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("contenu non-image envoyé comme type=image doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMedia_UnknownProductReturns404(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
|
||||
c, rec := mediaContext(t, "admin", body, ct, 99999999, 0)
|
||||
handlers.UploadMedia(c)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("produit inconnu doit retourner 404: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadMedia_SuccessUploadsToS3AndCreatesMediaRow(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "MediaUploadOk", 5)
|
||||
body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
|
||||
c, rec := mediaContext(t, "admin", body, ct, productID, 0)
|
||||
handlers.UploadMedia(c)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("upload valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
media, err := testDB.GetMediaByProductID(productID)
|
||||
if err != nil || len(media) != 1 {
|
||||
t.Fatalf("un média doit être créé en base: err=%v count=%d", err, len(media))
|
||||
}
|
||||
|
||||
// Vérifie que le fichier existe réellement sur MinIO (pas juste en DB).
|
||||
rc, _, err := getTestS3Service(t).GetFile(t.Context(), media[0].Key)
|
||||
if err != nil {
|
||||
t.Fatalf("le fichier doit être récupérable depuis S3: %v", err)
|
||||
}
|
||||
rc.Close()
|
||||
|
||||
t.Cleanup(func() {
|
||||
getTestS3Service(t).DeleteFile(media[0].Key)
|
||||
})
|
||||
}
|
||||
|
||||
// ── DeleteMedia ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteMedia_RejectsNonAdminNonCabine(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := mediaContext(t, "livreur", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, 1)
|
||||
handlers.DeleteMedia(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMedia_UnknownMediaReturns404(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := mediaContext(t, "admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, 99999999)
|
||||
handlers.DeleteMedia(c)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("média inconnu doit retourner 404: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMedia_SuccessDeletesFromS3AndDB(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "MediaDeleteOk", 5)
|
||||
|
||||
uploadBody, uploadCT := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
|
||||
uc, urec := mediaContext(t, "admin", uploadBody, uploadCT, productID, 0)
|
||||
handlers.UploadMedia(uc)
|
||||
if urec.Code != http.StatusCreated {
|
||||
t.Fatalf("upload préalable doit réussir: got=%d body=%s", urec.Code, urec.Body.String())
|
||||
}
|
||||
|
||||
media, err := testDB.GetMediaByProductID(productID)
|
||||
if err != nil || len(media) != 1 {
|
||||
t.Fatalf("un média doit exister avant suppression: err=%v count=%d", err, len(media))
|
||||
}
|
||||
mediaID := media[0].ID
|
||||
key := media[0].Key
|
||||
|
||||
c, rec := mediaContext(t, "admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, mediaID)
|
||||
handlers.DeleteMedia(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("suppression doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
remaining, _ := testDB.GetMediaByProductID(productID)
|
||||
if len(remaining) != 0 {
|
||||
t.Errorf("le média doit être supprimé en base: got=%d lignes restantes", len(remaining))
|
||||
}
|
||||
|
||||
if _, _, err := getTestS3Service(t).GetFile(t.Context(), key); err == nil {
|
||||
t.Error("le fichier doit être supprimé de S3, mais il est toujours récupérable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── CreateProduct avec média réel (intégration multipart) ───────────────
|
||||
//
|
||||
// Contrairement à UploadMedia (qui écrit sur S3/RustFS), le chemin média de
|
||||
// CreateProduct écrit sur le DISQUE LOCAL du serveur (c.SaveUploadedFile
|
||||
// vers "uploads/<type>s/...") et ne renseigne jamais media.Key — donc ce
|
||||
// fichier n'est pas récupérable via S3Service.GetFile ni via ServeMedia
|
||||
// (qui lit par clé S3). C'est une incohérence d'architecture entre les deux
|
||||
// chemins de code, pas juste une différence de test : à signaler séparément
|
||||
// pour décision (stockage local perdu au redéploiement si le conteneur n'a
|
||||
// pas de volume persistant sur "uploads/", et média invisible pour
|
||||
// DeleteMedia's nettoyage S3). Ce test vérifie donc le comportement réel
|
||||
// actuel (fichier sur disque local), pas un comportement souhaité en S3.
|
||||
func TestCreateProduct_WithMediaSavesFileToLocalDisk(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
cat := newTestCategory(t, "CatWithMedia")
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
w.WriteField("name", testProductPrefix+"CPMedia")
|
||||
w.WriteField("category", cat)
|
||||
w.WriteField("description", "d")
|
||||
w.WriteField("stock", "5")
|
||||
w.WriteField("prices[0][quantity]", "1")
|
||||
w.WriteField("prices[0][price]", "5")
|
||||
part, _ := w.CreateFormFile("media", "photo.png")
|
||||
part.Write(tiny1x1PNG)
|
||||
w.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products", body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("s3Service", getTestS3Service(t))
|
||||
c.Set("username", testUserPrefix+"media_admin")
|
||||
c.Set("role", "admin")
|
||||
|
||||
handlers.CreateProduct(c)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("création produit avec média doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var productID int
|
||||
testDB.GDB.Raw(`SELECT id FROM products WHERE name = ?`, testProductPrefix+"CPMedia").Scan(&productID)
|
||||
if productID == 0 {
|
||||
t.Fatalf("le produit doit être créé")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
|
||||
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
|
||||
})
|
||||
|
||||
media, err := testDB.GetMediaByProductID(productID)
|
||||
if err != nil || len(media) != 1 {
|
||||
t.Fatalf("un média doit être associé au produit: err=%v count=%d", err, len(media))
|
||||
}
|
||||
if media[0].Key != "" {
|
||||
t.Errorf("comportement actuel: media.Key doit être vide (pas de clé S3) pour le chemin CreateProduct: got=%q", media[0].Key)
|
||||
}
|
||||
localPath := strings.TrimPrefix(media[0].URL, "/")
|
||||
if _, err := os.Stat(localPath); err != nil {
|
||||
t.Errorf("le fichier doit exister sur le disque local à %q: %v", localPath, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
os.Remove(localPath)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SetClientParrainAndCredit lie un parrain à un client ET crédite le parrain
|
||||
// dans une seule transaction — la doc métier avertit explicitement que sans
|
||||
// cette atomicité, un crédit peut être appliqué sans lien enregistré, ou
|
||||
// l'inverse (lien enregistré sans jamais créditer le parrain).
|
||||
|
||||
func TestSetClientParrainAndCredit_LinksAndCreditsAtomically(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
client := newTestClient(t, "parrain_ok_client")
|
||||
parrain := newTestClient(t, "parrain_ok_parrain")
|
||||
setClientReferralBalance(t, parrain, 5)
|
||||
|
||||
if err := testDB.SetClientParrainAndCredit(client, parrain, 10); err != nil {
|
||||
t.Fatalf("SetClientParrainAndCredit: %v", err)
|
||||
}
|
||||
|
||||
got, err := testDB.GetClientParrain(client)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientParrain: %v", err)
|
||||
}
|
||||
if got != parrain {
|
||||
t.Errorf("parrain enregistré: got=%q want=%q", got, parrain)
|
||||
}
|
||||
if bal := referralBalance(t, parrain); bal != 15 {
|
||||
t.Errorf("solde du parrain après crédit (5 + 10): got=%.2f want=15", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Un client qui a déjà un parrain ne doit jamais pouvoir en changer via cette
|
||||
// fonction (contrainte "parrain déjà défini") — et surtout, le nouveau
|
||||
// parrain proposé ne doit recevoir aucun crédit si le lien est refusé.
|
||||
func TestSetClientParrainAndCredit_RejectsIfClientAlreadyHasParrain(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
client := newTestClient(t, "parrain_already_client")
|
||||
firstParrain := newTestClient(t, "parrain_already_first")
|
||||
secondParrain := newTestClient(t, "parrain_already_second")
|
||||
|
||||
if err := testDB.SetClientParrainAndCredit(client, firstParrain, 10); err != nil {
|
||||
t.Fatalf("1er lien: %v", err)
|
||||
}
|
||||
|
||||
if err := testDB.SetClientParrainAndCredit(client, secondParrain, 10); err == nil {
|
||||
t.Fatal("attendu un rejet : le client a déjà un parrain")
|
||||
}
|
||||
|
||||
if got, _ := testDB.GetClientParrain(client); got != firstParrain {
|
||||
t.Errorf("le parrain enregistré ne doit pas changer: got=%q want=%q", got, firstParrain)
|
||||
}
|
||||
if bal := referralBalance(t, secondParrain); bal != 0 {
|
||||
t.Errorf("le second parrain (lien refusé) ne doit recevoir aucun crédit: got=%.2f want=0", bal)
|
||||
}
|
||||
if bal := referralBalance(t, firstParrain); bal != 10 {
|
||||
t.Errorf("le premier parrain garde son crédit initial, pas de second crédit: got=%.2f want=10", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Scénario exact mis en garde par la doc métier : si le parrain indiqué
|
||||
// n'existe pas, le crédit échoue — et le lien parrain (première moitié de la
|
||||
// transaction) doit être annulé avec, pas laissé enregistré tout seul.
|
||||
func TestSetClientParrainAndCredit_RejectsUnknownParrain_RollsBackLink(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
client := newTestClient(t, "parrain_unknown_client")
|
||||
unknownParrain := testUserPrefix + "does_not_exist_parrain"
|
||||
|
||||
if err := testDB.SetClientParrainAndCredit(client, unknownParrain, 10); err == nil {
|
||||
t.Fatal("attendu une erreur : le parrain n'existe pas")
|
||||
}
|
||||
|
||||
got, err := testDB.GetClientParrain(client)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientParrain: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("le lien parrain ne doit PAS être enregistré si le crédit échoue (rollback complet): got=%q want=\"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Quand le parrainage est désactivé (ReferralEnabled=false côté handler), le
|
||||
// montant crédité vaut 0 — la liaison doit tout de même réussir sans tenter
|
||||
// de créditer personne.
|
||||
func TestSetClientParrainAndCredit_ZeroCreditLinksWithoutCrediting(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
client := newTestClient(t, "parrain_zero_client")
|
||||
parrain := newTestClient(t, "parrain_zero_parrain")
|
||||
|
||||
if err := testDB.SetClientParrainAndCredit(client, parrain, 0); err != nil {
|
||||
t.Fatalf("SetClientParrainAndCredit avec montant nul: %v", err)
|
||||
}
|
||||
|
||||
if got, _ := testDB.GetClientParrain(client); got != parrain {
|
||||
t.Errorf("le lien doit être enregistré même sans crédit: got=%q want=%q", got, parrain)
|
||||
}
|
||||
if bal := referralBalance(t, parrain); bal != 0 {
|
||||
t.Errorf("aucun crédit ne doit être appliqué avec un montant nul: got=%.2f want=0", bal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetClientParrainAndCredit_RejectsUnknownClient(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
parrain := newTestClient(t, "parrain_unknown_client_target")
|
||||
unknownClient := testUserPrefix + "does_not_exist_client"
|
||||
|
||||
if err := testDB.SetClientParrainAndCredit(unknownClient, parrain, 10); err == nil {
|
||||
t.Fatal("attendu une erreur : le client cible n'existe pas")
|
||||
}
|
||||
if bal := referralBalance(t, parrain); bal != 0 {
|
||||
t.Errorf("le parrain ne doit pas être crédité si le client cible est introuvable: got=%.2f want=0", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// Deux tentatives concurrentes de parrainage sur le MÊME client (deux
|
||||
// parrains différents) ne doivent en laisser passer qu'une seule — la
|
||||
// condition "parrain IS NULL OR parrain = ''" de l'UPDATE sérialise
|
||||
// naturellement les deux tentatives au niveau de la ligne.
|
||||
func TestSetClientParrainAndCredit_ConcurrentSetOnSameClientOnlyOneSucceeds(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
client := newTestClient(t, "parrain_concurrent_client")
|
||||
parrainA := newTestClient(t, "parrain_concurrent_a")
|
||||
parrainB := newTestClient(t, "parrain_concurrent_b")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs[0] = testDB.SetClientParrainAndCredit(client, parrainA, 10)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs[1] = testDB.SetClientParrainAndCredit(client, parrainB, 10)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Errorf("une seule tentative concurrente de parrainage doit réussir: got=%d succès", successCount)
|
||||
}
|
||||
|
||||
finalParrain, _ := testDB.GetClientParrain(client)
|
||||
if finalParrain != parrainA && finalParrain != parrainB {
|
||||
t.Fatalf("parrain final inattendu: %q", finalParrain)
|
||||
}
|
||||
|
||||
balA := referralBalance(t, parrainA)
|
||||
balB := referralBalance(t, parrainB)
|
||||
if (balA == 10) == (balB == 10) {
|
||||
t.Errorf("exactement un des deux parrains doit être crédité de 10, pas les deux ni aucun: balA=%.2f balB=%.2f", balA, balB)
|
||||
}
|
||||
}
|
||||
@@ -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,256 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
// ── ApproveDeliveryAtomicByStaff : confirmation de réception par admin/cabine
|
||||
// à la place du client. Contrairement au chemin client (ApproveDeliveryAtomic),
|
||||
// aucune vérification de propriétaire n'est faite ici (le staff agit au nom
|
||||
// du client) — mais la contrainte de statut ('livre' uniquement) est
|
||||
// identique, et la double-approbation renvoie une VRAIE erreur (pas un no-op
|
||||
// silencieux comme côté client).
|
||||
|
||||
func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "staff_approve_ok")
|
||||
productID := newTestProduct(t, "StaffApproveOk", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
pts, _, clientOut, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test")
|
||||
if err != nil {
|
||||
t.Fatalf("ApproveDeliveryAtomicByStaff: %v", err)
|
||||
}
|
||||
if pts != 6 {
|
||||
t.Errorf("points retournés: got=%d want=6", pts)
|
||||
}
|
||||
if clientOut != username {
|
||||
t.Errorf("client retourné: got=%q want=%q", clientOut, username)
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "approved" {
|
||||
t.Errorf("statut après confirmation staff: got=%s want=approved", got)
|
||||
}
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
|
||||
t.Errorf("points_extra après confirmation staff: got=%d want=6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveDeliveryAtomicByStaff_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
|
||||
for _, status := range []string{"pending", "assigned", "en_route", "arrived"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
username := newTestClient(t, "staff_reject_"+status)
|
||||
productID := newTestProduct(t, "StaffReject"+status, 20)
|
||||
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
|
||||
|
||||
if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err == nil {
|
||||
t.Fatalf("attendu un rejet pour une commande en statut %q", status)
|
||||
}
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 0 {
|
||||
t.Errorf("aucun point ne doit être crédité (statut=%s): got=%d want=0", status, got)
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != status {
|
||||
t.Errorf("le statut ne doit pas changer: got=%s want=%s", got, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Contrairement à ApproveDeliveryAtomic (client), une seconde confirmation
|
||||
// staff sur une commande déjà approuvée renvoie une VRAIE erreur, pas un
|
||||
// no-op silencieux — divergence de comportement à documenter explicitement.
|
||||
func TestApproveDeliveryAtomicByStaff_DoubleApprove_ReturnsErrorAndDoesNotDoublePoints(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "staff_double")
|
||||
productID := newTestProduct(t, "StaffDouble", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err != nil {
|
||||
t.Fatalf("1ère confirmation: %v", err)
|
||||
}
|
||||
if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err == nil {
|
||||
t.Fatal("la 2e confirmation sur une commande déjà approuvée doit renvoyer une erreur (contrairement au chemin client)")
|
||||
}
|
||||
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
|
||||
t.Errorf("points après double confirmation staff: got=%d want=6 (un seul crédit)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveDeliveryAtomicByStaff_ConcurrentApprove_CreditsPointsOnlyOnce(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "staff_concurrent")
|
||||
productID := newTestProduct(t, "StaffConcurrent", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
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.ApproveDeliveryAtomicByStaff(cmdID, "admin_test")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Errorf("une seule confirmation concurrente doit réussir: got=%d succès", successCount)
|
||||
}
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
|
||||
t.Errorf("points après confirmations concurrentes: got=%d want=6 (un seul crédit)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le staff n'est pas le client : aucune vérification de propriétaire n'est
|
||||
// faite (comportement voulu, à la différence du chemin client).
|
||||
func TestApproveDeliveryAtomicByStaff_NoOwnershipCheck_AnyStaffCanConfirmAnyClient(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "staff_no_owner_check")
|
||||
productID := newTestProduct(t, "StaffNoOwnerCheck", 20)
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
if _, _, clientOut, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "un_autre_membre_staff"); err != nil {
|
||||
t.Fatalf("un membre du staff quelconque doit pouvoir confirmer la réception: %v", err)
|
||||
} else if clientOut != username {
|
||||
t.Errorf("client retourné: got=%q want=%q", clientOut, username)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ValidateDeliveryAtomic : validation admin en masse ──────────────────────
|
||||
//
|
||||
// Divergence de règle métier volontaire (confirmée) : contrairement aux deux
|
||||
// autres chemins d'approbation (client et staff), qui exigent tous deux le
|
||||
// statut 'livre', ValidateDeliveryAtomic accepte "pending", "assigned",
|
||||
// "en_route" ET "livre" — c'est un override admin assumé pour régulariser une
|
||||
// commande gérée hors flux normal, pas un bug. Les tests suivants documentent
|
||||
// ce comportement réel pour qu'une future régression involontaire soit détectée.
|
||||
|
||||
func TestValidateDeliveryAtomic_AcceptsAllDocumentedStatusesAndCreditsPoints(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
|
||||
for _, status := range []string{"pending", "assigned", "en_route", "livre"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
username := newTestClient(t, "validate_status_"+status)
|
||||
productID := newTestProduct(t, "ValidateStatus"+status, 20)
|
||||
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
|
||||
|
||||
pts, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeliveryAtomic depuis le statut %q: %v", status, err)
|
||||
}
|
||||
if pts != 6 {
|
||||
t.Errorf("points depuis statut %q: got=%d want=6", status, pts)
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "approved" {
|
||||
t.Errorf("statut final: got=%s want=approved", got)
|
||||
}
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
|
||||
t.Errorf("points_extra depuis statut %q: got=%d want=6", status, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeliveryAtomic_RejectsStatusOutsideAllowedList(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
for _, status := range []string{"cancelled", "pending_payment", "arrived"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
username := newTestClient(t, "validate_invalid_"+status)
|
||||
productID := newTestProduct(t, "ValidateInvalid"+status, 20)
|
||||
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
|
||||
|
||||
if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err == nil {
|
||||
t.Fatalf("statut %q ne fait pas partie de la liste autorisée, attendu un rejet", status)
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != status {
|
||||
t.Errorf("le statut ne doit pas changer: got=%s want=%s", got, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ici aussi la double-validation renvoie une vraie erreur ("commande déjà
|
||||
// approuvée"), pas un no-op silencieux.
|
||||
func TestValidateDeliveryAtomic_DoubleValidate_ReturnsErrorAndDoesNotDoublePoints(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "validate_double")
|
||||
productID := newTestProduct(t, "ValidateDouble", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err != nil {
|
||||
t.Fatalf("1ère validation: %v", err)
|
||||
}
|
||||
if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err == nil {
|
||||
t.Fatal("la 2e validation sur une commande déjà approuvée doit renvoyer une erreur")
|
||||
}
|
||||
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
|
||||
t.Errorf("points après double validation: got=%d want=6 (un seul crédit)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeliveryAtomic_ConcurrentValidate_CreditsPointsOnlyOnce(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "validate_concurrent")
|
||||
productID := newTestProduct(t, "ValidateConcurrent", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
n := 3
|
||||
errs := make([]error, n)
|
||||
ptsResults := make([]int, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ptsResults[idx], errs[idx] = testDB.ValidateDeliveryAtomic(cmdID, "admin_test")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
for i := range n {
|
||||
if errs[i] == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Errorf("une seule validation concurrente doit réussir: got=%d succès", successCount)
|
||||
}
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
|
||||
t.Errorf("points après validations concurrentes: got=%d want=6 (un seul crédit)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"gestion/models"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// setPointsPoolsSettings remplace la configuration des pools de points pour la
|
||||
// durée du test (table app_settings, clé "points_pools"), et restaure l'état
|
||||
// par défaut en fin de test.
|
||||
func setPointsPoolsSettings(t *testing.T, pools []models.PointsPool) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(pools)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pools: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO app_settings (key, value) VALUES ('points_pools', ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
string(data),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("setPointsPoolsSettings: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_pools'`)
|
||||
})
|
||||
}
|
||||
|
||||
// setPointsRewardSettings configure le seuil de récompense globale (déduction
|
||||
// de points lors de la consommation d'un article récompense).
|
||||
func setPointsRewardSettings(t *testing.T, reward models.PointsReward) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(reward)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal points_reward: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO app_settings (key, value) VALUES ('points_reward', ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
string(data),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("setPointsRewardSettings: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_reward'`)
|
||||
})
|
||||
}
|
||||
|
||||
// insertRewardCommandItem ajoute directement un item récompense à une
|
||||
// commande déjà créée (contourne le checkout, pour isoler le calcul de points).
|
||||
func insertRewardCommandItem(t *testing.T, commandID, productID int, quantite, prix float64, poolKey string) {
|
||||
t.Helper()
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status, is_reward, reward_pool_key)
|
||||
VALUES (?, ?, 'item reward test', ?, ?, 'pending', true, ?)`,
|
||||
commandID, productID, quantite, prix, poolKey,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insertRewardCommandItem: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func clientPointsExtra(t *testing.T, username string) map[string]int {
|
||||
t.Helper()
|
||||
extra, _, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
// ── CalculateAndAddPointsForCommandTx : logique bas niveau ──────────────────
|
||||
|
||||
func TestCalculateAndAddPointsForCommandTx_CreditsPointsPerPoolFromTiers(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "points_tiers_ok")
|
||||
productID := newTestProduct(t, "PointsTiersOk", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{
|
||||
{Min: 30, Max: 50, Points: 1},
|
||||
{Min: 60, Max: 0, Points: 5},
|
||||
}},
|
||||
})
|
||||
// Total commande = 60€ -> palier "60 et plus" = 5 points.
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60)
|
||||
|
||||
err := testDB.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
|
||||
}
|
||||
if pts != 5 {
|
||||
t.Errorf("points calculés: got=%d want=5", pts)
|
||||
}
|
||||
if cat != "Pool Test" {
|
||||
t.Errorf("catégorie: got=%q want=%q", cat, "Pool Test")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transaction: %v", err)
|
||||
}
|
||||
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 5 {
|
||||
t.Errorf("points_extra[pool_0] après crédit: got=%d want=5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAndAddPointsForCommandTx_NoPoolsConfigured_ReturnsZero(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "points_no_pools")
|
||||
productID := newTestProduct(t, "PointsNoPools", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{}) // aucun pool configuré
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60)
|
||||
|
||||
testDB.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
|
||||
}
|
||||
if pts != 0 || cat != "" {
|
||||
t.Errorf("sans pool configuré: got pts=%d cat=%q, want 0/\"\"", pts, cat)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if got := clientPointsExtra(t, username); len(got) != 0 {
|
||||
t.Errorf("points_extra ne doit pas bouger sans pool configuré: got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un pool existe mais aucune de ses catégories ne correspond à la catégorie
|
||||
// des produits commandés ("test", posée par newTestProduct) -> 0 point.
|
||||
func TestCalculateAndAddPointsForCommandTx_CategoryNotInAnyPool_ReturnsZero(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "points_cat_mismatch")
|
||||
productID := newTestProduct(t, "PointsCatMismatch", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Autre Catégorie", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{
|
||||
{Min: 30, Max: 0, Points: 5},
|
||||
}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60)
|
||||
|
||||
testDB.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
|
||||
}
|
||||
if pts != 0 {
|
||||
t.Errorf("catégorie hors pool: got pts=%d want=0", pts)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if got := clientPointsExtra(t, username); len(got) != 0 {
|
||||
t.Errorf("points_extra ne doit pas bouger si aucune catégorie ne matche: got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Deux pools indépendants : seul celui dont la catégorie correspond aux
|
||||
// produits de la commande doit recevoir des points.
|
||||
func TestCalculateAndAddPointsForCommandTx_MultiplePoolsIndependent(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "points_multi_pool")
|
||||
productID := newTestProduct(t, "PointsMultiPool", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Match", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 7}}},
|
||||
{Key: "pool_1", Name: "Pool No Match", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{{Min: 30, Max: 0, Points: 99}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 2, 20)
|
||||
|
||||
testDB.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
|
||||
}
|
||||
if pts != 7 {
|
||||
t.Errorf("total points (seul pool_0 doit contribuer): got=%d want=7", pts)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
extra := clientPointsExtra(t, username)
|
||||
if extra["pool_0"] != 7 {
|
||||
t.Errorf("pool_0: got=%d want=7", extra["pool_0"])
|
||||
}
|
||||
if extra["pool_1"] != 0 {
|
||||
t.Errorf("pool_1 ne doit recevoir aucun point (catégorie non matchée): got=%d want=0", extra["pool_1"])
|
||||
}
|
||||
}
|
||||
|
||||
// Un article récompense présent dans la commande déduit "threshold" points du
|
||||
// pool correspondant, en plus des points gagnés par les articles payants de
|
||||
// la même commande.
|
||||
func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoints(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "points_reward_deduct")
|
||||
paidProductID := newTestProduct(t, "PointsRewardDeductPaid", 20)
|
||||
rewardProductID := newTestProduct(t, "PointsRewardDeductFree", 5)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 3}}},
|
||||
})
|
||||
setPointsRewardSettings(t, models.PointsReward{Threshold: 20, Type: "free_product"})
|
||||
setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande
|
||||
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10)
|
||||
insertRewardCommandItem(t, cmdID, rewardProductID, 1, 0, "pool_0")
|
||||
|
||||
testDB.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
|
||||
}
|
||||
if pts != 3 {
|
||||
t.Errorf("points gagnés sur l'article payant: got=%d want=3", pts)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// 25 (initial) + 3 (gagnés) - 20 (seuil déduit pour la récompense consommée) = 8.
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 8 {
|
||||
t.Errorf("points_extra[pool_0] après crédit + déduction récompense: got=%d want=8", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Propriété documentée du design : cette fonction bas niveau n'a aucune garde
|
||||
// d'idempotence intégrée — appeler deux fois pour la même commande double les
|
||||
// points. C'est le rôle de l'appelant (ApproveDeliveryAtomic, via son
|
||||
// verrou de transition de statut livre->approved) d'empêcher un second appel.
|
||||
func TestCalculateAndAddPointsForCommandTx_CalledTwice_DoublesPoints(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "points_called_twice")
|
||||
productID := newTestProduct(t, "PointsCalledTwice", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 4}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
testDB.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
_, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
|
||||
if err != nil {
|
||||
t.Fatalf("appel %d: %v", i+1, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 8 {
|
||||
t.Errorf("deux appels bruts doublent les points (4+4): got=%d want=8 — ceci documente pourquoi ApproveDeliveryAtomic doit rester le seul appelant", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ApproveDeliveryAtomic : la vraie règle métier "points uniquement à
|
||||
// l'approbation, jamais avant" ────────────────────────────────────────────
|
||||
|
||||
func TestApproveDeliveryAtomic_CreditsPointsExactlyOnApproval(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "approve_credits_points")
|
||||
productID := newTestProduct(t, "ApproveCreditsPoints", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
pts, _, err := testDB.ApproveDeliveryAtomic(cmdID, username)
|
||||
if err != nil {
|
||||
t.Fatalf("ApproveDeliveryAtomic: %v", err)
|
||||
}
|
||||
if pts != 6 {
|
||||
t.Errorf("points retournés par l'approbation: got=%d want=6", pts)
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "approved" {
|
||||
t.Errorf("statut après approbation: got=%s want=approved", got)
|
||||
}
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
|
||||
t.Errorf("points_extra après approbation: got=%d want=6", got)
|
||||
}
|
||||
}
|
||||
|
||||
// La règle centrale : tant que la commande n'est pas "livre", l'approbation
|
||||
// doit être rejetée et AUCUN point ne doit être crédité.
|
||||
func TestApproveDeliveryAtomic_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
|
||||
for _, status := range []string{"pending", "assigned", "en_route", "arrived"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
username := newTestClient(t, "approve_reject_"+status)
|
||||
productID := newTestProduct(t, "ApproveReject"+status, 20)
|
||||
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
|
||||
|
||||
if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err == nil {
|
||||
t.Fatalf("attendu un rejet pour une commande en statut %q", status)
|
||||
}
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 0 {
|
||||
t.Errorf("aucun point ne doit être crédité pour une commande non 'livre' (statut=%s): got=%d want=0", status, got)
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != status {
|
||||
t.Errorf("le statut ne doit pas changer sur une approbation rejetée: got=%s want=%s", got, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Double approbation (retry réseau / double-tap client) : la seconde doit
|
||||
// être un no-op silencieux, jamais un second crédit de points.
|
||||
func TestApproveDeliveryAtomic_DoubleApprove_DoesNotDoublePoints(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "approve_double")
|
||||
productID := newTestProduct(t, "ApproveDouble", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err != nil {
|
||||
t.Fatalf("1ère approbation: %v", err)
|
||||
}
|
||||
pts2, _, err := testDB.ApproveDeliveryAtomic(cmdID, username)
|
||||
if err != nil {
|
||||
t.Fatalf("2e approbation (doit être idempotente, pas une erreur): %v", err)
|
||||
}
|
||||
if pts2 != 0 {
|
||||
t.Errorf("2e approbation ne doit rapporter aucun point: got=%d want=0", pts2)
|
||||
}
|
||||
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
|
||||
t.Errorf("points après double approbation (doivent rester crédités une seule fois): got=%d want=6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveDeliveryAtomic_ConcurrentApprove_CreditsPointsOnlyOnce(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "approve_concurrent")
|
||||
productID := newTestProduct(t, "ApproveConcurrent", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
n := 3
|
||||
errs := make([]error, n)
|
||||
ptsResults := make([]int, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ptsResults[idx], _, errs[idx] = testDB.ApproveDeliveryAtomic(cmdID, username)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// ApproveDeliveryAtomic traite une commande déjà approuvée comme un no-op
|
||||
// idempotent (err=nil, pts=0), pas comme une erreur — donc le critère de
|
||||
// "vrai succès" est pts>0 (crédit réellement appliqué), pas err==nil.
|
||||
freshCreditCount := 0
|
||||
for i := range n {
|
||||
if errs[i] != nil {
|
||||
t.Errorf("appel %d: erreur inattendue: %v", i, errs[i])
|
||||
continue
|
||||
}
|
||||
if ptsResults[i] > 0 {
|
||||
freshCreditCount++
|
||||
}
|
||||
}
|
||||
if freshCreditCount != 1 {
|
||||
t.Errorf("une seule approbation concurrente doit réellement créditer des points: got=%d", freshCreditCount)
|
||||
}
|
||||
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
|
||||
t.Errorf("points après approbations concurrentes: got=%d want=6 (un seul crédit)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveDeliveryAtomic_WrongOwnerRejected(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
owner := newTestClient(t, "approve_owner")
|
||||
intruder := newTestClient(t, "approve_intruder")
|
||||
productID := newTestProduct(t, "ApproveWrongOwner", 20)
|
||||
setPointsPoolsSettings(t, []models.PointsPool{
|
||||
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
|
||||
})
|
||||
cmdID := newTestCommandWithItem(t, owner, "livre", "", productID, 1, 10)
|
||||
|
||||
if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, intruder); err == nil {
|
||||
t.Fatal("un client ne doit pas pouvoir approuver la commande d'un autre client")
|
||||
}
|
||||
if got := clientPointsExtra(t, intruder)["pool_0"]; got != 0 {
|
||||
t.Errorf("l'intrus ne doit recevoir aucun point: got=%d want=0", got)
|
||||
}
|
||||
if got := clientPointsExtra(t, owner)["pool_0"]; got != 0 {
|
||||
t.Errorf("le propriétaire ne doit pas non plus recevoir de point tant que ce n'est pas lui qui approuve: got=%d want=0", got)
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "livre" {
|
||||
t.Errorf("statut ne doit pas changer sur une tentative d'un intrus: got=%s want=livre", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setClientReferralBalance fixe directement le solde de crédit parrainage
|
||||
// d'un client de test (contourne le flux normal de parrainage/checkout pour
|
||||
// tester isolément le débit/crédit).
|
||||
func setClientReferralBalance(t *testing.T, username string, amount float64) {
|
||||
t.Helper()
|
||||
if err := testDB.GDB.Exec(
|
||||
`UPDATE clients SET referral_balance = ? WHERE username = ?`, amount, username,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("setClientReferralBalance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func referralBalance(t *testing.T, username string) float64 {
|
||||
t.Helper()
|
||||
balance, err := testDB.GetClientReferralBalance(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientReferralBalance: %v", err)
|
||||
}
|
||||
return balance
|
||||
}
|
||||
|
||||
// ── DebitReferralBalance ─────────────────────────────────────────────────────
|
||||
|
||||
func TestDebitReferralBalance_SucceedsWithSufficientBalance(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_debit_ok")
|
||||
setClientReferralBalance(t, username, 50)
|
||||
|
||||
if err := testDB.DebitReferralBalance(username, 30); err != nil {
|
||||
t.Fatalf("DebitReferralBalance: %v", err)
|
||||
}
|
||||
|
||||
if got := referralBalance(t, username); got != 20 {
|
||||
t.Errorf("solde après débit (50 - 30): got=%.2f want=20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebitReferralBalance_FailsWithInsufficientBalance(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_debit_insuff")
|
||||
setClientReferralBalance(t, username, 10)
|
||||
|
||||
if err := testDB.DebitReferralBalance(username, 30); err == nil {
|
||||
t.Fatal("attendu une erreur (solde 10€ insuffisant pour débiter 30€)")
|
||||
}
|
||||
|
||||
if got := referralBalance(t, username); got != 10 {
|
||||
t.Errorf("solde ne doit pas bouger si le débit échoue: got=%.2f want=10", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un montant nul ou négatif est un no-op silencieux (cas "pas de crédit
|
||||
// parrainage utilisé" au checkout) — ne doit jamais faire échouer ni modifier
|
||||
// le solde.
|
||||
func TestDebitReferralBalance_ZeroOrNegativeAmountIsNoop(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_debit_zero")
|
||||
setClientReferralBalance(t, username, 15)
|
||||
|
||||
if err := testDB.DebitReferralBalance(username, 0); err != nil {
|
||||
t.Errorf("montant nul ne doit jamais échouer: %v", err)
|
||||
}
|
||||
if err := testDB.DebitReferralBalance(username, -5); err != nil {
|
||||
t.Errorf("montant négatif ne doit jamais échouer: %v", err)
|
||||
}
|
||||
if got := referralBalance(t, username); got != 15 {
|
||||
t.Errorf("solde ne doit pas bouger sur un débit nul/négatif: got=%.2f want=15", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Trois débits concurrents pour un solde qui ne permet qu'un seul d'entre eux
|
||||
// ne doivent en laisser passer qu'un seul (verrou FOR UPDATE) — même classe de
|
||||
// bug que le double-submit checkout, appliquée au solde de parrainage.
|
||||
func TestDebitReferralBalance_ConcurrentDebitsDoNotOverspend(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_debit_concurrent")
|
||||
setClientReferralBalance(t, username, 30)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
n := 3
|
||||
errs := make([]error, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
errs[idx] = testDB.DebitReferralBalance(username, 30)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Errorf("un seul débit concurrent de 30€ sur un solde de 30€ doit réussir: got=%d succès", successCount)
|
||||
}
|
||||
if got := referralBalance(t, username); got != 0 {
|
||||
t.Errorf("solde final après débits concurrents: got=%.2f want=0 (un seul débit appliqué)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── CreditClientReferral ─────────────────────────────────────────────────────
|
||||
|
||||
func TestCreditClientReferral_AddsAmount(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_credit_ok")
|
||||
setClientReferralBalance(t, username, 10)
|
||||
|
||||
if err := testDB.CreditClientReferral(username, 25); err != nil {
|
||||
t.Fatalf("CreditClientReferral: %v", err)
|
||||
}
|
||||
if got := referralBalance(t, username); got != 35 {
|
||||
t.Errorf("solde après crédit (10 + 25): got=%.2f want=35", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditClientReferral_RejectsNonPositiveAmount(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_credit_negative")
|
||||
setClientReferralBalance(t, username, 10)
|
||||
|
||||
if err := testDB.CreditClientReferral(username, 0); err == nil {
|
||||
t.Error("un crédit de montant nul doit être rejeté")
|
||||
}
|
||||
if err := testDB.CreditClientReferral(username, -5); err == nil {
|
||||
t.Error("un crédit de montant négatif doit être rejeté")
|
||||
}
|
||||
if got := referralBalance(t, username); got != 10 {
|
||||
t.Errorf("solde ne doit pas bouger sur un crédit rejeté: got=%.2f want=10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditClientReferral_FailsForUnknownClient(t *testing.T) {
|
||||
if err := testDB.CreditClientReferral(testUserPrefix+"does_not_exist", 10); err == nil {
|
||||
t.Fatal("attendu une erreur pour un client inexistant")
|
||||
}
|
||||
}
|
||||
|
||||
// ── ResetClientReferralBalance ───────────────────────────────────────────────
|
||||
|
||||
func TestResetClientReferralBalance_SetsToZero(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_reset")
|
||||
setClientReferralBalance(t, username, 42)
|
||||
|
||||
if err := testDB.ResetClientReferralBalance(username); err != nil {
|
||||
t.Fatalf("ResetClientReferralBalance: %v", err)
|
||||
}
|
||||
if got := referralBalance(t, username); got != 0 {
|
||||
t.Errorf("solde après reset: got=%.2f want=0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Séquence complète débit -> checkout, reproduisant exactement le flux du
|
||||
// handler ValidateBasket (handlers/panier.go) : débit AVANT la création de la
|
||||
// commande, puis re-crédit compensatoire si CreateCommandWithAddress échoue.
|
||||
// Sans ce re-crédit, un client perdrait sèchement son crédit de parrainage
|
||||
// sur un checkout qui a pourtant échoué (violation explicite de la doc métier).
|
||||
|
||||
func TestReferral_DebitThenCheckoutFailure_RecreditsBalance(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_checkout_fail")
|
||||
productID := newTestProduct(t, "ReferralCheckoutFail", 1)
|
||||
setClientReferralBalance(t, username, 20)
|
||||
|
||||
insertNormalBasketRow(t, username, productID, 5, 50) // 5 demandés pour 1 en stock -> échec garanti
|
||||
|
||||
referralUsed := 20.0
|
||||
if err := testDB.DebitReferralBalance(username, referralUsed); err != nil {
|
||||
t.Fatalf("DebitReferralBalance: %v", err)
|
||||
}
|
||||
if got := referralBalance(t, username); got != 0 {
|
||||
t.Fatalf("précondition: solde débité: got=%.2f want=0", got)
|
||||
}
|
||||
|
||||
_, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err == nil {
|
||||
t.Fatal("attendu un échec de checkout (stock insuffisant)")
|
||||
}
|
||||
// Reproduction exacte de la compensation faite par le handler en cas d'échec.
|
||||
if err := testDB.CreditClientReferral(username, referralUsed); err != nil {
|
||||
t.Fatalf("CreditClientReferral (compensation): %v", err)
|
||||
}
|
||||
|
||||
if got := referralBalance(t, username); got != 20 {
|
||||
t.Errorf("le crédit parrainage doit être intégralement restauré après échec du checkout: got=%.2f want=20", got)
|
||||
}
|
||||
if got := productStock(t, productID); got != 1 {
|
||||
t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferral_DebitThenCheckoutSuccess_BalanceStaysDebited(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_checkout_ok")
|
||||
productID := newTestProduct(t, "ReferralCheckoutOk", 10)
|
||||
setClientReferralBalance(t, username, 20)
|
||||
|
||||
insertNormalBasketRow(t, username, productID, 3, 30)
|
||||
|
||||
if err := testDB.DebitReferralBalance(username, 20); err != nil {
|
||||
t.Fatalf("DebitReferralBalance: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
if got := referralBalance(t, username); got != 0 {
|
||||
t.Errorf("le crédit parrainage reste débité après un checkout réussi: got=%.2f want=0", got)
|
||||
}
|
||||
if got := productStock(t, productID); got != 7 {
|
||||
t.Errorf("stock après checkout réussi: got=%.2f want=7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Combine les deux règles demandées explicitement : réclamation de
|
||||
// récompense (même produit en reward + en achat normal) ET utilisation du
|
||||
// crédit de parrainage sur la même commande.
|
||||
func TestReferral_CombinedWithSameProductRewardAndNormal_AllInvariantsHold(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "referral_combined_reward")
|
||||
productID := newTestProduct(t, "ReferralCombinedReward", 20)
|
||||
setClientReferralBalance(t, username, 15)
|
||||
|
||||
insertRewardBasketRow(t, username, productID, 1)
|
||||
insertNormalBasketRow(t, username, productID, 4, 40)
|
||||
|
||||
if err := testDB.DebitReferralBalance(username, 15); err != nil {
|
||||
t.Fatalf("DebitReferralBalance: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
// 20 initial - 1 (reward) - 4 (normal) = 15.
|
||||
if got := productStock(t, productID); got != 15 {
|
||||
t.Errorf("stock après checkout combiné (reward+normal même produit + parrainage): got=%.2f want=15", got)
|
||||
}
|
||||
if got := referralBalance(t, username); got != 0 {
|
||||
t.Errorf("crédit parrainage débité et non restauré après succès: got=%.2f want=0", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"gestion/db"
|
||||
"gestion/handlers"
|
||||
"gestion/models"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/points/claim", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", username)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
|
||||
t.Helper()
|
||||
settings := db.DefaultSettings()
|
||||
settings.PointsReward = reward
|
||||
if err := testDB.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Flux complet réel : POST /points/claim avec un seuil atteint doit ajouter
|
||||
// le produit récompense configuré au panier et décompter la récompense.
|
||||
func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_flow")
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Type: "free_product",
|
||||
Description: "Un produit offert",
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
RemainingRewards int `json:"remaining_rewards"`
|
||||
ProductAdded bool `json:"product_added"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !resp.Success || !resp.ProductAdded {
|
||||
t.Fatalf("réclamation devrait réussir avec produit ajouté: %+v", resp)
|
||||
}
|
||||
if resp.RemainingRewards != 0 {
|
||||
t.Errorf("remaining_rewards: got=%d want=0", resp.RemainingRewards)
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 1 || rows[0].ProductID != rewardProductID {
|
||||
t.Errorf("le produit récompense doit être dans le panier: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_below")
|
||||
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Type: "free_product",
|
||||
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 5)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusConflict, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Si un item récompense configuré par l'admin pointe vers un produit
|
||||
// supprimé/inexistant, la réclamation entière doit échouer — la récompense
|
||||
// ne doit pas être consommée sans qu'aucun produit ne soit livré au client
|
||||
// (ClaimPoolReward + AddRewardsToBasket sont maintenant dans la même
|
||||
// transaction via ClaimPoolRewardAndAddToBasket).
|
||||
func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_http_missing_product")
|
||||
|
||||
configureRewardSettings(t, &models.PointsReward{
|
||||
Threshold: 20,
|
||||
Type: "free_product",
|
||||
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
|
||||
})
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
|
||||
c, rec := claimRewardContext(username, body)
|
||||
handlers.ClaimMyReward(c)
|
||||
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("la réclamation ne doit pas réussir avec un produit récompense invalide, body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 0 {
|
||||
t.Errorf("la récompense ne doit PAS être consommée si le produit est introuvable: got redeemed=%d want=0", redeemed["pool_0"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setClientPoolPoints fixe directement les points cumulés d'un client pour un
|
||||
// pool donné (contourne le flux normal d'accumulation pour tester isolément
|
||||
// la réclamation de récompense).
|
||||
func setClientPoolPoints(t *testing.T, username, poolKey string, points int) {
|
||||
t.Helper()
|
||||
if err := testDB.GDB.Exec(
|
||||
`UPDATE clients SET points_extra = jsonb_set(COALESCE(points_extra, '{}'::jsonb), ARRAY[?], to_jsonb(?::int)) WHERE username = ?`,
|
||||
poolKey, points, username,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("setClientPoolPoints: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type rewardBasketRow struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
RewardPoolKey string `gorm:"column:reward_pool_key"`
|
||||
}
|
||||
|
||||
func basketRewardItems(t *testing.T, username string) []rewardBasketRow {
|
||||
t.Helper()
|
||||
var rows []rewardBasketRow
|
||||
if err := testDB.GDB.Raw(
|
||||
`SELECT product_id, quantity, price, is_reward, reward_pool_key
|
||||
FROM baskets WHERE username = ? AND is_reward = true`, username,
|
||||
).Scan(&rows).Error; err != nil {
|
||||
t.Fatalf("lecture panier récompense: %v", err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// ── ClaimPoolReward : seuil, atomicité, épuisement ──────────────────────────
|
||||
|
||||
func TestClaimPoolReward_BelowThresholdFails(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_below_threshold")
|
||||
setClientPoolPoints(t, username, "pool_0", 19)
|
||||
|
||||
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
|
||||
t.Fatal("attendu une erreur : 19 points < seuil 20")
|
||||
} else if !strings.Contains(err.Error(), "pas de récompense disponible") {
|
||||
t.Errorf("message d'erreur inattendu: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimPoolReward_ExactlyAtThresholdSucceeds(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_exact_threshold")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimPoolReward: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Errorf("remaining: got=%d want=0 (1 récompense gagnée, 1 réclamée)", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimPoolReward_MultipleRewardsEarned(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_multiple")
|
||||
setClientPoolPoints(t, username, "pool_0", 45) // 45/20 = 2 récompenses gagnées
|
||||
|
||||
remaining1, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("1er claim: %v", err)
|
||||
}
|
||||
if remaining1 != 1 {
|
||||
t.Errorf("après 1er claim: got=%d want=1", remaining1)
|
||||
}
|
||||
|
||||
remaining2, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("2e claim: %v", err)
|
||||
}
|
||||
if remaining2 != 0 {
|
||||
t.Errorf("après 2e claim: got=%d want=0", remaining2)
|
||||
}
|
||||
|
||||
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
|
||||
t.Fatal("3e claim: attendu une erreur (récompenses épuisées)")
|
||||
}
|
||||
}
|
||||
|
||||
// Trois réclamations concurrentes pour un client n'ayant droit qu'à UNE seule
|
||||
// récompense ne doivent en laisser passer qu'une seule (verrou FOR UPDATE).
|
||||
func TestClaimPoolReward_ConcurrentClaimsDoNotOverclaim(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_concurrent")
|
||||
setClientPoolPoints(t, username, "pool_0", 20) // 1 seule récompense disponible
|
||||
|
||||
var wg sync.WaitGroup
|
||||
n := 3
|
||||
errs := make([]error, n)
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
_, errs[idx] = testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Errorf("un seul claim concurrent doit réussir: got=%d succès", successCount)
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 1 {
|
||||
t.Errorf("compteur redeemed après claims concurrents: got=%d want=1", redeemed["pool_0"])
|
||||
}
|
||||
}
|
||||
|
||||
// Les pools sont indépendants : les points d'un pool ne doivent pas permettre
|
||||
// de réclamer une récompense sur un autre pool.
|
||||
func TestClaimPoolReward_PoolsAreIndependent(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_pool_isolation")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
// pool_1 n'a aucun point.
|
||||
|
||||
if _, err := testDB.ClaimPoolReward(username, "pool_1", 20); err == nil {
|
||||
t.Fatal("attendu une erreur : aucun point sur pool_1")
|
||||
}
|
||||
if remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
|
||||
t.Errorf("pool_0 devrait rester réclamable: %v", err)
|
||||
} else if remaining != 0 {
|
||||
t.Errorf("remaining pool_0: got=%d want=0", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ClaimPoolRewardAndAddToBasket : atomicité réclamation + livraison ───────
|
||||
|
||||
func TestClaimPoolRewardAndAddToBasket_SucceedsAndDecrementsAvailable(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_combined_ok")
|
||||
productID := newTestProduct(t, "RewardCombinedOk", 5)
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
remaining, added, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
|
||||
[]models.RewardItem{{ProductID: productID, Quantity: 1, Price: 10}})
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimPoolRewardAndAddToBasket: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Errorf("remaining: got=%d want=0", remaining)
|
||||
}
|
||||
if len(added) != 1 {
|
||||
t.Fatalf("articles ajoutés: got=%d want=1", len(added))
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 1 {
|
||||
t.Errorf("redeemed: got=%d want=1", redeemed["pool_0"])
|
||||
}
|
||||
}
|
||||
|
||||
// Si le produit récompense est introuvable, ni la récompense ni le panier ne
|
||||
// doivent être modifiés (rollback complet de la transaction combinée).
|
||||
func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_combined_rollback")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
_, _, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
|
||||
[]models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}})
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur pour un produit récompense inexistant")
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 0 {
|
||||
t.Errorf("la récompense ne doit pas être consommée si l'ajout au panier échoue: got redeemed=%d want=0", redeemed["pool_0"])
|
||||
}
|
||||
if rows := basketRewardItems(t, username); len(rows) != 0 {
|
||||
t.Errorf("aucun article récompense ne doit rester en panier: got=%d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// ── AddRewardsToBasket : flags et remplacement ──────────────────────────────
|
||||
|
||||
func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_basket_flags")
|
||||
productID := newTestProduct(t, "RewardBasketFlags", 20)
|
||||
|
||||
items := []models.RewardItem{{ProductID: productID, Quantity: 2, Price: 15.0}}
|
||||
added, err := testDB.AddRewardsToBasket(username, items, "pool_0")
|
||||
if err != nil {
|
||||
t.Fatalf("AddRewardsToBasket: %v", err)
|
||||
}
|
||||
if len(added) != 1 {
|
||||
t.Fatalf("nombre d'articles ajoutés: got=%d want=1", len(added))
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("articles récompense en base: got=%d want=1", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if !row.IsReward {
|
||||
t.Error("is_reward doit être true")
|
||||
}
|
||||
if row.RewardPoolKey != "pool_0" {
|
||||
t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0")
|
||||
}
|
||||
if row.Price != 0 {
|
||||
t.Errorf("prix affiché doit être 0 (gratuit): got=%.2f", row.Price)
|
||||
}
|
||||
if row.Quantity != 2 {
|
||||
t.Errorf("quantité: got=%.2f want=2", row.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// Réclamer une nouvelle récompense doit remplacer les articles récompense
|
||||
// précédents, pas les cumuler (évite d'accumuler indéfiniment des articles
|
||||
// gratuits si le client reclique plusieurs fois).
|
||||
func TestAddRewardsToBasket_ReplacesPreviousRewardItems(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_basket_replace")
|
||||
productA := newTestProduct(t, "RewardReplaceA", 20)
|
||||
productB := newTestProduct(t, "RewardReplaceB", 20)
|
||||
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productA, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
|
||||
t.Fatalf("1er AddRewardsToBasket: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productB, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
|
||||
t.Fatalf("2e AddRewardsToBasket: %v", err)
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("un seul article récompense doit rester après remplacement: got=%d", len(rows))
|
||||
}
|
||||
if rows[0].ProductID != productB {
|
||||
t.Errorf("l'article récompense restant doit être le dernier réclamé: got=%d want=%d", rows[0].ProductID, productB)
|
||||
}
|
||||
}
|
||||
|
||||
// Un article récompense pointant vers un produit inexistant doit faire
|
||||
// échouer l'ajout, sans rien insérer du tout (transaction).
|
||||
func TestAddRewardsToBasket_FailsOnUnknownProduct(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_basket_unknown")
|
||||
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}}, "pool_0"); err == nil {
|
||||
t.Fatal("attendu une erreur pour un produit inexistant")
|
||||
}
|
||||
|
||||
rows := basketRewardItems(t, username)
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("aucun article ne doit être ajouté si le produit est introuvable: got=%d", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chaîne complète : réclamation -> panier -> checkout -> stock ────────────
|
||||
|
||||
// C'est le scénario demandé explicitement : vérifier que le stock est bien
|
||||
// déduit pour un article obtenu par récompense, exactement comme un article
|
||||
// payant (règle métier explicite : les récompenses ne sont jamais exclues du
|
||||
// décompte de stock).
|
||||
func TestRewardClaim_FullChain_DecrementsStockAtCheckout(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_full_chain")
|
||||
rewardProductID := newTestProduct(t, "RewardFullChainFree", 5)
|
||||
paidProductID := newTestProduct(t, "RewardFullChainPaid", 10)
|
||||
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
|
||||
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("ClaimPoolReward: %v", err)
|
||||
}
|
||||
if remaining != 0 {
|
||||
t.Errorf("remaining: got=%d want=0", remaining)
|
||||
}
|
||||
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
|
||||
t.Fatalf("AddRewardsToBasket: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(username, paidProductID, 3); err != nil {
|
||||
t.Fatalf("AddToBasket (article payant): %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
if got := productStock(t, rewardProductID); got != 3 {
|
||||
t.Errorf("stock article récompense après checkout (5 initial - 2 offerts): got=%.2f want=3", got)
|
||||
}
|
||||
if got := productStock(t, paidProductID); got != 7 {
|
||||
t.Errorf("stock article payant après checkout (10 initial - 3 achetés): got=%.2f want=7", got)
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 1 {
|
||||
t.Errorf("compteur de récompenses réclamées après checkout: got=%d want=1", redeemed["pool_0"])
|
||||
}
|
||||
}
|
||||
|
||||
// Si le checkout échoue (stock insuffisant sur l'article payant du même
|
||||
// panier), l'article récompense ne doit pas non plus voir son stock décrémenté
|
||||
// (rollback complet, cohérent avec le comportement déjà vérifié pour les
|
||||
// articles payants).
|
||||
func TestRewardClaim_CheckoutFailure_DoesNotDecrementRewardStock(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_checkout_fail")
|
||||
rewardProductID := newTestProduct(t, "RewardCheckoutFailFree", 5)
|
||||
shortProductID := newTestProduct(t, "RewardCheckoutFailShort", 1)
|
||||
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
|
||||
t.Fatalf("ClaimPoolReward: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
|
||||
t.Fatalf("AddRewardsToBasket: %v", err)
|
||||
}
|
||||
// Article payant en rupture pour forcer l'échec du checkout.
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) VALUES (?, ?, 5, 50, false, CURRENT_TIMESTAMP)`,
|
||||
username, shortProductID,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insertion panier insuffisant: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
|
||||
t.Fatal("attendu un échec de checkout (stock insuffisant sur l'article payant)")
|
||||
}
|
||||
|
||||
if got := productStock(t, rewardProductID); got != 5 {
|
||||
t.Errorf("stock article récompense ne doit pas bouger si le checkout échoue: got=%.2f want=5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ResetClientRedeemed (admin) ──────────────────────────────────────────────
|
||||
|
||||
func TestResetClientRedeemed_SpecificPool(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_reset_specific")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
setClientPoolPoints(t, username, "pool_1", 20)
|
||||
testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
testDB.ClaimPoolReward(username, "pool_1", 20)
|
||||
|
||||
if err := testDB.ResetClientRedeemed(username, "pool_0"); err != nil {
|
||||
t.Fatalf("ResetClientRedeemed: %v", err)
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if redeemed["pool_0"] != 0 {
|
||||
t.Errorf("pool_0 doit être remis à zéro: got=%d", redeemed["pool_0"])
|
||||
}
|
||||
if redeemed["pool_1"] != 1 {
|
||||
t.Errorf("pool_1 ne doit pas être affecté: got=%d want=1", redeemed["pool_1"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetClientRedeemed_AllPools(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_reset_all")
|
||||
setClientPoolPoints(t, username, "pool_0", 20)
|
||||
setClientPoolPoints(t, username, "pool_1", 20)
|
||||
testDB.ClaimPoolReward(username, "pool_0", 20)
|
||||
testDB.ClaimPoolReward(username, "pool_1", 20)
|
||||
|
||||
if err := testDB.ResetClientRedeemed(username, ""); err != nil {
|
||||
t.Fatalf("ResetClientRedeemed: %v", err)
|
||||
}
|
||||
|
||||
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientPointsAndRewards: %v", err)
|
||||
}
|
||||
if len(redeemed) != 0 {
|
||||
t.Errorf("tous les pools doivent être remis à zéro: got=%v", redeemed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
"gestion/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func adminStatsContext() (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/stats", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// insertStatsCommand insère une commande directement avec un created_at
|
||||
// choisi, pour contrôler précisément le jour de semaine/heure agrégés.
|
||||
func insertStatsCommand(t *testing.T, username, status, createdAt string) {
|
||||
t.Helper()
|
||||
testDB.GDB.Exec(
|
||||
`INSERT INTO commandes (username, status, adresse, total_prix, created_at, updated_at)
|
||||
VALUES (?, ?, 'Adresse stats test', 10, ?::timestamp, ?::timestamp)`,
|
||||
username, status, createdAt, createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
func TestGetAdminStats_ZeroOrdersAvgPerDayIsZeroNoPanic(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
// Table commandes peut contenir des données d'autres tests, mais aucune
|
||||
// n'utilise ce username isolé — on vérifie juste l'absence de panic/NaN
|
||||
// et que la structure de réponse est bien formée avec les vraies
|
||||
// données actuelles de la base de test (qui peut être non-vide).
|
||||
c, rec := adminStatsContext()
|
||||
handlers.GetAdminStats(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Summary struct {
|
||||
AvgPerDay float64 `json:"avg_per_day"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("réponse JSON invalide: %v", err)
|
||||
}
|
||||
if resp.Summary.AvgPerDay < 0 {
|
||||
t.Errorf("avg_per_day ne doit jamais être négatif: got=%f", resp.Summary.AvgPerDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdminStats_PeakWeekdayMatchesBusiestDay(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_peak")
|
||||
// 2024-01-08 est un lundi, 2024-01-07 un dimanche (DOW Postgres: 0=dimanche).
|
||||
insertStatsCommand(t, username, "pending", "2024-01-08 10:00:00")
|
||||
insertStatsCommand(t, username, "pending", "2024-01-08 11:00:00")
|
||||
insertStatsCommand(t, username, "pending", "2024-01-08 12:00:00")
|
||||
insertStatsCommand(t, username, "pending", "2024-01-07 10:00:00")
|
||||
|
||||
c, rec := adminStatsContext()
|
||||
handlers.GetAdminStats(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Summary struct {
|
||||
PeakWeekday string `json:"peak_weekday"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("réponse JSON invalide: %v", err)
|
||||
}
|
||||
if resp.Summary.PeakWeekday != "Lundi" {
|
||||
t.Errorf("peak_weekday doit être le jour avec le plus de commandes: got=%q want=Lundi", resp.Summary.PeakWeekday)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdminStats_ByQuantitySortedDescendingByTotalOrders(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_byqty")
|
||||
prodLow := newTestProduct(t, "ByQtyLow", 100)
|
||||
prodHigh := newTestProduct(t, "ByQtyHigh", 100)
|
||||
|
||||
// prodHigh: 3 commandes ; prodLow: 1 commande.
|
||||
for range 3 {
|
||||
newTestCommandWithItem(t, username, "pending", "", prodHigh, 1, 10)
|
||||
}
|
||||
newTestCommandWithItem(t, username, "pending", "", prodLow, 1, 10)
|
||||
|
||||
c, rec := adminStatsContext()
|
||||
handlers.GetAdminStats(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
ByQuantity []struct {
|
||||
ProductID int `json:"product_id"`
|
||||
TotalOrders int `json:"total_orders"`
|
||||
} `json:"by_quantity"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("réponse JSON invalide: %v", err)
|
||||
}
|
||||
// Vérifie l'ordre décroissant global (pas seulement nos deux produits,
|
||||
// d'autres tests peuvent avoir laissé des données) et que prodHigh
|
||||
// apparaît avant prodLow.
|
||||
highIdx, lowIdx := -1, -1
|
||||
for i, r := range resp.ByQuantity {
|
||||
if r.ProductID == prodHigh {
|
||||
highIdx = i
|
||||
}
|
||||
if r.ProductID == prodLow {
|
||||
lowIdx = i
|
||||
}
|
||||
if i > 0 && r.TotalOrders > resp.ByQuantity[i-1].TotalOrders {
|
||||
t.Errorf("by_quantity doit être trié par total_orders décroissant: rupture à l'index %d", i)
|
||||
}
|
||||
}
|
||||
if highIdx == -1 || lowIdx == -1 {
|
||||
t.Fatalf("les deux produits de test doivent apparaître dans by_quantity: highIdx=%d lowIdx=%d", highIdx, lowIdx)
|
||||
}
|
||||
if highIdx >= lowIdx {
|
||||
t.Errorf("le produit avec le plus de commandes doit apparaître avant: highIdx=%d lowIdx=%d", highIdx, lowIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdersAndRevenueByHour_CountsNonCancelledRevenueOnlyApproved(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_hour")
|
||||
insertStatsCommand(t, username, "approved", "2024-01-08 14:00:00")
|
||||
insertStatsCommand(t, username, "pending", "2024-01-08 14:30:00")
|
||||
insertStatsCommand(t, username, "cancelled", "2024-01-08 14:45:00")
|
||||
testDB.GDB.Exec(`UPDATE commandes SET total_prix = 25 WHERE username = ? AND status = 'approved'`, username)
|
||||
|
||||
var hourRows []models.HourRow
|
||||
if err := testDB.OrdersAndRevenueByHour(&hourRows, testDB.ReadResetAt("stats_reset_heures_at")); err != nil {
|
||||
t.Fatalf("OrdersAndRevenueByHour: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, r := range hourRows {
|
||||
if r.Hour == 14 {
|
||||
found = true
|
||||
if r.Count != 2 {
|
||||
t.Errorf("count à 14h doit exclure la commande annulée: got=%d want=2", r.Count)
|
||||
}
|
||||
if r.Revenue != 25 {
|
||||
t.Errorf("revenue à 14h ne doit compter que les commandes approuvées: got=%.2f want=25", r.Revenue)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("aucune ligne pour l'heure 14 trouvée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func statsSectionContext(section string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/stats/reset", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Params = gin.Params{{Key: "section", Value: section}}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func TestResetAdminStats_RejectsInvalidSection(t *testing.T) {
|
||||
c, rec := statsSectionContext("section-inexistante")
|
||||
handlers.ResetAdminStats(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("section invalide doit retourner 400: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAdminStats_SuccessResetsSection(t *testing.T) {
|
||||
c, rec := statsSectionContext("commandes")
|
||||
handlers.ResetAdminStats(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("reset d'une section valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"success":true`)) {
|
||||
t.Errorf("réponse doit indiquer success: body=%s", rec.Body.String())
|
||||
}
|
||||
resetAt := testDB.ReadResetAt("stats_reset_commandes_at")
|
||||
if resetAt.IsZero() {
|
||||
t.Error("le timestamp de reset doit être renseigné après ResetAdminStats")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMyDeliveryStats_RejectsNonLivreurRole(t *testing.T) {
|
||||
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", testUserPrefix+"stats_role")
|
||||
c.Set("role", "client")
|
||||
handlers.GetMyDeliveryStats(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResetAdminStat déplace le point de coupure temporel utilisé par toutes les
|
||||
// requêtes de stats (TotalOrders, TotalRevenue, ActiveDaysLast30, ...) : les
|
||||
// commandes créées AVANT le reset doivent disparaître des totaux, celles
|
||||
// créées APRÈS doivent rester visibles. C'est le mécanisme central derrière
|
||||
// le bouton "reset stats" de l'admin — jamais testé jusqu'ici.
|
||||
|
||||
const statsResetTestKey = "stats_reset_commandes_at"
|
||||
|
||||
func cleanupStatsResetKey(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = ?`, key)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResetAdminStat_TotalOrdersExcludesOrdersBeforeReset(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
cleanupStatsResetKey(t, statsResetTestKey)
|
||||
username := newTestClient(t, "stat_reset_orders")
|
||||
productID := newTestProduct(t, "StatResetOrders", 10)
|
||||
|
||||
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
time.Sleep(1100 * time.Millisecond) // RFC3339 stocké sans fraction de seconde : marge nécessaire
|
||||
|
||||
if err := testDB.ResetAdminStat(statsResetTestKey); err != nil {
|
||||
t.Fatalf("ResetAdminStat: %v", err)
|
||||
}
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
|
||||
totalWithoutFilter, err := testDB.TotalOrders(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalOrders(zero): %v", err)
|
||||
}
|
||||
if totalWithoutFilter != 2 {
|
||||
t.Fatalf("précondition: 2 commandes doivent exister sans filtre: got=%d", totalWithoutFilter)
|
||||
}
|
||||
|
||||
resetAt := testDB.ReadResetAt(statsResetTestKey)
|
||||
if resetAt.IsZero() {
|
||||
t.Fatal("ReadResetAt ne doit pas être zero après ResetAdminStat")
|
||||
}
|
||||
|
||||
totalAfterReset, err := testDB.TotalOrders(resetAt)
|
||||
if err != nil {
|
||||
t.Fatalf("TotalOrders(resetAt): %v", err)
|
||||
}
|
||||
if totalAfterReset != 1 {
|
||||
t.Errorf("après reset, seule la commande créée après doit être comptée: got=%d want=1", totalAfterReset)
|
||||
}
|
||||
}
|
||||
|
||||
// Chaque section de stats a sa propre clé de reset (commandes, revenus,
|
||||
// produits, heures, jours, doses) — réinitialiser l'une ne doit jamais
|
||||
// affecter les autres.
|
||||
func TestResetAdminStat_DifferentSectionsAreIndependent(t *testing.T) {
|
||||
cleanupStatsResetKey(t, "stats_reset_commandes_at_test_indep")
|
||||
cleanupStatsResetKey(t, "stats_reset_revenus_at_test_indep")
|
||||
|
||||
if err := testDB.ResetAdminStat("stats_reset_commandes_at_test_indep"); err != nil {
|
||||
t.Fatalf("ResetAdminStat (commandes): %v", err)
|
||||
}
|
||||
|
||||
if got := testDB.ReadResetAt("stats_reset_revenus_at_test_indep"); !got.IsZero() {
|
||||
t.Errorf("réinitialiser la section commandes ne doit pas créer de reset pour revenus: got=%v", got)
|
||||
}
|
||||
if got := testDB.ReadResetAt("stats_reset_commandes_at_test_indep"); got.IsZero() {
|
||||
t.Error("la section commandes doit bien avoir une date de reset")
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveDaysLast30 (stat secondaire) doit respecter le même filtre de reset
|
||||
// que TotalOrders : un jour dont l'unique commande a été passée avant le
|
||||
// reset ne doit plus compter comme jour actif.
|
||||
func TestActiveDaysLast30_RespectsResetFilter(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
cleanupStatsResetKey(t, statsResetTestKey)
|
||||
username := newTestClient(t, "stat_reset_active_days")
|
||||
productID := newTestProduct(t, "StatResetActiveDays", 10)
|
||||
|
||||
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
if err := testDB.ResetAdminStat(statsResetTestKey); err != nil {
|
||||
t.Fatalf("ResetAdminStat: %v", err)
|
||||
}
|
||||
resetAt := testDB.ReadResetAt(statsResetTestKey)
|
||||
|
||||
activeDays, err := testDB.ActiveDaysLast30(resetAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ActiveDaysLast30: %v", err)
|
||||
}
|
||||
if activeDays != 0 {
|
||||
t.Errorf("aucun jour actif ne doit être compté (seule commande antérieure au reset): got=%d want=0", activeDays)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/models"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestOrderForStats crée directement une commande (+ un item) avec un
|
||||
// statut, un total, un crédit de parrainage utilisé et une date de création
|
||||
// contrôlés, pour tester isolément les calculs de stats admin.
|
||||
func newTestOrderForStats(t *testing.T, username, status string, productID int, quantite, prix, referralUsed float64, createdAt time.Time) int {
|
||||
t.Helper()
|
||||
var cmdID int
|
||||
if err := testDB.GDB.Raw(
|
||||
`INSERT INTO commandes (username, status, adresse, total_prix, referral_used, created_at, updated_at)
|
||||
VALUES (?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`,
|
||||
username, status, prix, referralUsed, createdAt, createdAt,
|
||||
).Scan(&cmdID).Error; err != nil {
|
||||
t.Fatalf("création commande stats test: %v", err)
|
||||
}
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
|
||||
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
|
||||
cmdID, productID, quantite, prix,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("création item stats test: %v", err)
|
||||
}
|
||||
return cmdID
|
||||
}
|
||||
|
||||
// TotalRevenue ne compte que les commandes approuvées, nettes du crédit de
|
||||
// parrainage utilisé — les commandes annulées ou encore en cours sont exclues.
|
||||
func TestTotalRevenue_NetsReferralAndExcludesNonApproved(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_total_revenue")
|
||||
productID := newTestProduct(t, "StatsTotalRevenue", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productID, 5, 100, 30, now) // net 70
|
||||
newTestOrderForStats(t, username, "approved", productID, 2, 50, 0, now) // net 50
|
||||
newTestOrderForStats(t, username, "cancelled", productID, 9, 999, 0, now) // exclue
|
||||
newTestOrderForStats(t, username, "pending", productID, 9, 999, 0, now) // exclue (pas encore approuvée)
|
||||
|
||||
total, err := testDB.TotalRevenue(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalRevenue: %v", err)
|
||||
}
|
||||
if total != 120 {
|
||||
t.Errorf("TotalRevenue: got=%.2f want=120 (70+50, parrainage déduit, annulée/pending exclues)", total)
|
||||
}
|
||||
}
|
||||
|
||||
// TotalOrders exclut uniquement les commandes annulées (contrairement à
|
||||
// TotalRevenue, il compte aussi les commandes non encore approuvées).
|
||||
func TestTotalOrders_ExcludesOnlyCancelled(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_total_orders")
|
||||
productID := newTestProduct(t, "StatsTotalOrders", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "pending", productID, 1, 10, 0, now)
|
||||
newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, now)
|
||||
newTestOrderForStats(t, username, "cancelled", productID, 1, 10, 0, now)
|
||||
|
||||
total, err := testDB.TotalOrders(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalOrders: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("TotalOrders: got=%d want=2 (pending+approved, cancelled exclue)", total)
|
||||
}
|
||||
}
|
||||
|
||||
// Régression du bug corrigé : le revenu par produit/catégorie (TopProducts,
|
||||
// QuantityBreakdown, DailyProductDetailForDate) doit rester cohérent avec
|
||||
// TotalRevenue même quand une commande approuvée a utilisé du crédit de
|
||||
// parrainage — avant le fix, ces trois vues sommaient ci.prix brut sans
|
||||
// déduire referral_used, produisant un total supérieur au résumé global.
|
||||
func TestProductBreakdowns_RevenueMatchesTotalRevenue_WithReferralUsed(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_breakdown_referral")
|
||||
productA := newTestProduct(t, "StatsBreakdownA", 100)
|
||||
productB := newTestProduct(t, "StatsBreakdownB", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productA, 5, 100, 30, now) // net 70
|
||||
newTestOrderForStats(t, username, "approved", productB, 2, 50, 0, now) // net 50
|
||||
|
||||
totalRevenue, err := testDB.TotalRevenue(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalRevenue: %v", err)
|
||||
}
|
||||
if totalRevenue != 120 {
|
||||
t.Fatalf("précondition TotalRevenue: got=%.2f want=120", totalRevenue)
|
||||
}
|
||||
|
||||
var prodRows []models.ProductRow
|
||||
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
|
||||
t.Fatalf("TopProducts: %v", err)
|
||||
}
|
||||
sumTop := 0.0
|
||||
for _, r := range prodRows {
|
||||
sumTop += r.Revenue
|
||||
}
|
||||
if sumTop != totalRevenue {
|
||||
t.Errorf("somme TopProducts.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumTop, totalRevenue)
|
||||
}
|
||||
|
||||
var qtyRows []models.QuantityBreakdownRow
|
||||
if err := testDB.QuantityBreakdown(&qtyRows, time.Time{}); err != nil {
|
||||
t.Fatalf("QuantityBreakdown: %v", err)
|
||||
}
|
||||
sumQty := 0.0
|
||||
for _, r := range qtyRows {
|
||||
sumQty += r.Revenue
|
||||
}
|
||||
if sumQty != totalRevenue {
|
||||
t.Errorf("somme QuantityBreakdown.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumQty, totalRevenue)
|
||||
}
|
||||
|
||||
var dailyRows []models.DailyProductRow
|
||||
if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil {
|
||||
t.Fatalf("DailyProductDetailForDate: %v", err)
|
||||
}
|
||||
sumDaily := 0.0
|
||||
for _, r := range dailyRows {
|
||||
sumDaily += r.Revenue
|
||||
}
|
||||
if sumDaily != totalRevenue {
|
||||
t.Errorf("somme DailyProductDetailForDate.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumDaily, totalRevenue)
|
||||
}
|
||||
}
|
||||
|
||||
// Cas limite : commande entièrement couverte par le crédit de parrainage
|
||||
// (total_prix == referral_used) — la part de revenu attribuée à l'article
|
||||
// doit être 0, sans division par zéro ni erreur SQL.
|
||||
func TestProductBreakdowns_FullyCoveredByReferralYieldsZeroRevenue(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_breakdown_full_referral")
|
||||
productID := newTestProduct(t, "StatsBreakdownFullReferral", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productID, 4, 40, 40, now)
|
||||
|
||||
var prodRows []models.ProductRow
|
||||
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
|
||||
t.Fatalf("TopProducts: %v", err)
|
||||
}
|
||||
if len(prodRows) != 1 {
|
||||
t.Fatalf("attendu 1 produit, got=%d", len(prodRows))
|
||||
}
|
||||
if prodRows[0].Revenue != 0 {
|
||||
t.Errorf("revenu attendu à 0 pour une commande entièrement couverte par le parrainage: got=%.2f", prodRows[0].Revenue)
|
||||
}
|
||||
}
|
||||
|
||||
// RevenueByDayLast30 doit rester cohérent avec TotalRevenue pour des
|
||||
// commandes créées aujourd'hui (dans la fenêtre des 30 derniers jours).
|
||||
func TestRevenueByDayLast30_MatchesTotalRevenue(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_revenue_by_day")
|
||||
productID := newTestProduct(t, "StatsRevenueByDay", 100)
|
||||
now := time.Now()
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productID, 3, 90, 20, now) // net 70
|
||||
|
||||
totalRevenue, err := testDB.TotalRevenue(time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("TotalRevenue: %v", err)
|
||||
}
|
||||
|
||||
var dayRevRows []models.DayRevenueRow
|
||||
if err := testDB.RevenueByDayLast30(&dayRevRows, time.Time{}); err != nil {
|
||||
t.Fatalf("RevenueByDayLast30: %v", err)
|
||||
}
|
||||
sum := 0.0
|
||||
for _, r := range dayRevRows {
|
||||
sum += r.Revenue
|
||||
}
|
||||
if sum != totalRevenue {
|
||||
t.Errorf("somme RevenueByDayLast30 = %.2f, doit correspondre à TotalRevenue = %.2f", sum, totalRevenue)
|
||||
}
|
||||
}
|
||||
|
||||
// DailyProductDetailForDate ne doit inclure que les commandes du jour demandé.
|
||||
func TestDailyProductDetailForDate_OnlyIncludesGivenDate(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "stats_daily_date_filter")
|
||||
productID := newTestProduct(t, "StatsDailyDateFilter", 100)
|
||||
today := time.Now()
|
||||
yesterday := today.AddDate(0, 0, -1)
|
||||
|
||||
newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, today)
|
||||
newTestOrderForStats(t, username, "approved", productID, 1, 20, 0, yesterday)
|
||||
|
||||
var rows []models.DailyProductRow
|
||||
if err := testDB.DailyProductDetailForDate(&rows, today); err != nil {
|
||||
t.Fatalf("DailyProductDetailForDate: %v", err)
|
||||
}
|
||||
sum := 0.0
|
||||
for _, r := range rows {
|
||||
sum += r.Revenue
|
||||
}
|
||||
if sum != 10 {
|
||||
t.Errorf("revenu du jour ne doit inclure que la commande d'aujourd'hui: got=%.2f want=10", sum)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Régression: une commande "livrée" (marchandise déjà sortie du stock au
|
||||
// checkout, remise au client) ne doit pas voir son stock remboursé lors
|
||||
// d'une suppression — symétrique à CancelCommandByAdminAtomic qui exclut
|
||||
// déjà "livre" de son remboursement.
|
||||
func TestDeleteCommandAtomic_DoesNotRefundDeliveredOrder(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delete_admin_livre")
|
||||
productID := newTestProduct(t, "DeleteAdminLivre", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "livre", "", 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à livrée: got=%.2f want=5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCommandAtomic_UnknownCommandReturnsError(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
err := testDB.DeleteCommandAtomic(99999999, "admin_test", "admin")
|
||||
if err == nil {
|
||||
t.Fatal("DeleteCommandAtomic sur une commande inexistante doit retourner une erreur")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crypto (CancelCryptoCommand) ────────────────────────────────────────────
|
||||
|
||||
func TestCancelCryptoCommand_RefundsStockOnPendingPayment(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_crypto_ok")
|
||||
productID := newTestProduct(t, "CancelCryptoOk", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20)
|
||||
|
||||
if err := testDB.CancelCryptoCommand(cmdID); err != nil {
|
||||
t.Fatalf("CancelCryptoCommand: %v", err)
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 7 {
|
||||
t.Errorf("stock après annulation crypto (5 initial + 2 remboursés): got=%.2f want=7", got)
|
||||
}
|
||||
if got := commandStatus(t, cmdID); got != "cancelled" {
|
||||
t.Errorf("statut après annulation crypto: got=%s want=cancelled", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCryptoCommand_RejectsIfNotPendingPayment(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_crypto_wrong")
|
||||
productID := newTestProduct(t, "CancelCryptoWrong", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
|
||||
|
||||
if err := testDB.CancelCryptoCommand(cmdID); err == nil {
|
||||
t.Fatal("attendu une erreur : seule une commande pending_payment est annulable via ce chemin")
|
||||
}
|
||||
if got := productStock(t, productID); got != 5 {
|
||||
t.Errorf("stock ne doit pas bouger si le statut n'est pas pending_payment: got=%.2f want=5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCryptoCommand_DoubleCancelDoesNotDoubleRefund(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "cancel_crypto_dbl")
|
||||
productID := newTestProduct(t, "CancelCryptoDbl", 5)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20)
|
||||
|
||||
if err := testDB.CancelCryptoCommand(cmdID); err != nil {
|
||||
t.Fatalf("1er appel: %v", err)
|
||||
}
|
||||
if err := testDB.CancelCryptoCommand(cmdID); err == nil {
|
||||
t.Fatal("le 2e appel (webhook rejoué) doit échouer, pas rembourser une seconde fois")
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 7 {
|
||||
t.Errorf("stock après webhook rejoué: got=%.2f want=7 (un seul remboursement)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// AddToBasket vérifie le stock disponible mais ne le décrémente jamais —
|
||||
// le stock réel n'est consommé qu'au checkout (voir comprehension-metier).
|
||||
func TestAddToBasket_RejectsInsufficientStock(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "addbasket_insuff")
|
||||
productID := newTestProduct(t, "AddBasketInsuff", 2)
|
||||
|
||||
_, err := testDB.AddToBasket(username, productID, 3)
|
||||
if err == nil {
|
||||
t.Fatal("attendu une erreur (stock insuffisant), reçu nil")
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 2 {
|
||||
t.Errorf("stock ne doit pas bouger sur un ajout panier refusé: got=%.2f want=2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddToBasket_DoesNotDecrementStock(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "addbasket_ok")
|
||||
productID := newTestProduct(t, "AddBasketOk", 10)
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 4); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 10 {
|
||||
t.Errorf("le stock ne doit être décrémenté qu'au checkout, pas à l'ajout panier: got=%.2f want=10", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le checkout (CreateCommandWithAddress) doit décrémenter le stock exactement
|
||||
// de la quantité commandée, dans la même transaction que la création de la
|
||||
// commande et le vidage du panier.
|
||||
func TestCheckout_DecrementsStockExactly(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "checkout_ok")
|
||||
productID := newTestProduct(t, "CheckoutOk", 10)
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 3); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
|
||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 7 {
|
||||
t.Errorf("stock après checkout de 3 unités sur 10: got=%.2f want=7", got)
|
||||
}
|
||||
|
||||
var basketCount int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount)
|
||||
if basketCount != 0 {
|
||||
t.Errorf("le panier doit être vidé après checkout, reste %d article(s)", basketCount)
|
||||
}
|
||||
|
||||
var status string
|
||||
testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, cmd.ID).Scan(&status)
|
||||
if status != "pending" {
|
||||
t.Errorf("statut de la commande créée: got=%s want=pending", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Règle métier : les articles récompense (is_reward=true) sont des produits
|
||||
// physiques réellement distribués et doivent décrémenter le stock exactement
|
||||
// comme un article payant — jamais exclus du décompte.
|
||||
func TestCheckout_RewardItemDecrementsStockLikeAPaidItem(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "checkout_reward")
|
||||
paidProductID := newTestProduct(t, "CheckoutRewardPaid", 10)
|
||||
rewardProductID := newTestProduct(t, "CheckoutRewardFree", 5)
|
||||
|
||||
if _, err := testDB.AddToBasket(username, paidProductID, 2); err != nil {
|
||||
t.Fatalf("AddToBasket (payant): %v", err)
|
||||
}
|
||||
// Article récompense : inséré directement (produit par ClaimMyReward en
|
||||
// production), prix affiché 0€, mais le stock doit être traité pareil.
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||
VALUES (?, ?, 2, 0, true, 'pool_0', CURRENT_TIMESTAMP)`,
|
||||
username, rewardProductID,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insertion article récompense: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
if got := productStock(t, paidProductID); got != 8 {
|
||||
t.Errorf("stock produit payant après checkout: got=%.2f want=8", got)
|
||||
}
|
||||
if got := productStock(t, rewardProductID); got != 3 {
|
||||
t.Errorf("stock produit récompense après checkout (doit décrémenter comme un article payant): got=%.2f want=3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un stock insuffisant sur un seul article du panier doit faire échouer tout
|
||||
// le checkout, sans décrémenter partiellement les autres articles ni créer de
|
||||
// commande fantôme (le tout est dans une seule transaction).
|
||||
func TestCheckout_InsufficientStockOnOneItemRollsBackEverything(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "checkout_partial")
|
||||
okProductID := newTestProduct(t, "CheckoutPartialOk", 10)
|
||||
shortProductID := newTestProduct(t, "CheckoutPartialShort", 1)
|
||||
|
||||
if _, err := testDB.AddToBasket(username, okProductID, 5); err != nil {
|
||||
t.Fatalf("AddToBasket (ok): %v", err)
|
||||
}
|
||||
// Second article : on force un panier dont la quantité dépasse le stock
|
||||
// disponible au moment du checkout (simulation d'une désynchronisation,
|
||||
// par ex. deux clients ayant chacun ajouté le dernier article en stock).
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
||||
VALUES (?, ?, 5, 50, false, CURRENT_TIMESTAMP)`,
|
||||
username, shortProductID,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insertion panier insuffisant: %v", err)
|
||||
}
|
||||
|
||||
before := productStock(t, okProductID)
|
||||
|
||||
_, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err == nil {
|
||||
t.Fatal("attendu un échec de checkout (stock insuffisant), reçu nil")
|
||||
}
|
||||
|
||||
if got := productStock(t, okProductID); got != before {
|
||||
t.Errorf("le stock du 1er article ne doit pas être décrémenté si le 2e échoue (pas de décrément partiel): got=%.2f want=%.2f", got, before)
|
||||
}
|
||||
if got := productStock(t, shortProductID); got != 1 {
|
||||
t.Errorf("stock du produit en rupture ne doit pas bouger: got=%.2f want=1", got)
|
||||
}
|
||||
|
||||
var basketCount int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount)
|
||||
if basketCount != 2 {
|
||||
t.Errorf("le panier ne doit pas être vidé si le checkout échoue: got=%d want=2", basketCount)
|
||||
}
|
||||
|
||||
var cmdCount int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount)
|
||||
if cmdCount != 0 {
|
||||
t.Errorf("aucune commande fantôme ne doit être créée si le checkout échoue: got=%d want=0", cmdCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Deux checkouts quasi simultanés pour le même client (double-tap / retry
|
||||
// réseau) sur un stock tout juste suffisant pour une seule commande ne
|
||||
// doivent décrémenter le stock qu'une seule fois — le verrou FOR UPDATE sur
|
||||
// le panier sérialise les deux tentatives.
|
||||
func TestCheckout_ConcurrentDoubleSubmitDoesNotOversell(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "checkout_concurrent")
|
||||
productID := newTestProduct(t, "CheckoutConcurrent", 2)
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 2); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, 2)
|
||||
for i := range 2 {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
_, results[idx] = testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Errorf("exactement 1 des 2 checkouts concurrents doit réussir (panier vidé par le premier): got=%d succès", successCount)
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 0 {
|
||||
t.Errorf("stock final après un seul checkout réussi de 2 unités sur 2: got=%.2f want=0", got)
|
||||
}
|
||||
|
||||
var cmdCount int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount)
|
||||
if cmdCount != 1 {
|
||||
t.Errorf("une seule commande doit avoir été créée: got=%d want=1", cmdCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gestion/handlers"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// newTestCategory crée une catégorie de test et programme son nettoyage.
|
||||
// CreateProduct/UpdateProduct passent la catégorie reçue par strings.ToLower
|
||||
// avant de vérifier son existence : le nom doit donc déjà être en
|
||||
// minuscules ici pour matcher.
|
||||
func newTestCategory(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
fullName := strings.ToLower(testProductPrefix + name)
|
||||
if _, err := testDB.CreateCategory(fullName, "#000000", false); err != nil {
|
||||
t.Fatalf("CreateCategory %q: %v", fullName, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM categories WHERE name = ?`, fullName)
|
||||
})
|
||||
return fullName
|
||||
}
|
||||
|
||||
// productCreateRequest construit une requête multipart minimale pour
|
||||
// CreateProduct (un seul prix, pas de média).
|
||||
func productCreateRequest(fields map[string]string) (*bytes.Buffer, string) {
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
for k, v := range fields {
|
||||
w.WriteField(k, v)
|
||||
}
|
||||
w.Close()
|
||||
return body, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func productContext(role string, body *bytes.Buffer, contentType string, productID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = req
|
||||
c.Set("database", testDB)
|
||||
c.Set("username", testUserPrefix+"product_admin")
|
||||
c.Set("role", role)
|
||||
if productID != 0 {
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
|
||||
}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func jsonStockContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products/stock", 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", testUserPrefix+"product_admin")
|
||||
c.Set("role", role)
|
||||
if productID != 0 {
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
|
||||
}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// ── CreateProduct ────────────────────────────────────────────────────────
|
||||
|
||||
func TestCreateProduct_RejectsNonAdminNonCabine(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
body, ct := productCreateRequest(map[string]string{
|
||||
"name": testProductPrefix + "CP1", "category": "x", "description": "d",
|
||||
"stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5",
|
||||
})
|
||||
c, rec := productContext("client", body, ct, 0)
|
||||
handlers.CreateProduct(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProduct_RejectsNegativeStock(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
cat := newTestCategory(t, "CatNeg")
|
||||
body, ct := productCreateRequest(map[string]string{
|
||||
"name": testProductPrefix + "CPNeg", "category": cat, "description": "d",
|
||||
"stock": "-5", "prices[0][quantity]": "1", "prices[0][price]": "5",
|
||||
})
|
||||
c, rec := productContext("admin", body, ct, 0)
|
||||
handlers.CreateProduct(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProduct_RejectsStockOverMax(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
cat := newTestCategory(t, "CatOver")
|
||||
body, ct := productCreateRequest(map[string]string{
|
||||
"name": testProductPrefix + "CPOver", "category": cat, "description": "d",
|
||||
"stock": "1000001", "prices[0][quantity]": "1", "prices[0][price]": "5",
|
||||
})
|
||||
c, rec := productContext("admin", body, ct, 0)
|
||||
handlers.CreateProduct(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProduct_RejectsUnknownCategory(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
body, ct := productCreateRequest(map[string]string{
|
||||
"name": testProductPrefix + "CPCat", "category": "categorie-inexistante-xyz", "description": "d",
|
||||
"stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5",
|
||||
})
|
||||
c, rec := productContext("admin", body, ct, 0)
|
||||
handlers.CreateProduct(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("catégorie inconnue doit être rejetée: got=%d want=400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProduct_SuccessCreatesProductAndPrice(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
cat := newTestCategory(t, "CatOk")
|
||||
body, ct := productCreateRequest(map[string]string{
|
||||
"name": testProductPrefix + "CPOk", "category": cat, "description": "d",
|
||||
"stock": "42", "prices[0][quantity]": "1", "prices[0][price]": "9.99",
|
||||
})
|
||||
c, rec := productContext("admin", body, ct, 0)
|
||||
handlers.CreateProduct(c)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("création produit valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var count int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE name = ?`, testProductPrefix+"CPOk").Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("le produit doit être créé en base: got=%d", count)
|
||||
}
|
||||
var priceCount int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM product_prices pp JOIN products p ON p.id = pp.product_id WHERE p.name = ?`, testProductPrefix+"CPOk").Scan(&priceCount)
|
||||
if priceCount != 1 {
|
||||
t.Errorf("le prix doit être créé en base: got=%d", priceCount)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name = ?)`, testProductPrefix+"CPOk")
|
||||
testDB.GDB.Exec(`DELETE FROM products WHERE name = ?`, testProductPrefix+"CPOk")
|
||||
})
|
||||
}
|
||||
|
||||
// ── UpdateStock ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestUpdateStock_RejectsNonAdmin(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "USNonAdmin", 10)
|
||||
c, rec := jsonStockContext("cabine", []byte(`{"stock":20}`), productID)
|
||||
handlers.UpdateStock(c)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("role cabine doit être refusé pour UpdateStock: got=%d want=403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStock_RejectsNegativeStock(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "USNeg", 10)
|
||||
c, rec := jsonStockContext("admin", []byte(`{"stock":-1}`), productID)
|
||||
handlers.UpdateStock(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := productStock(t, productID); got != 10 {
|
||||
t.Errorf("le stock ne doit pas changer après un rejet: got=%.2f want=10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStock_RejectsStockOverMax(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "USOver", 10)
|
||||
c, rec := jsonStockContext("admin", []byte(`{"stock":1000001}`), productID)
|
||||
handlers.UpdateStock(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Documente un bug existant plutôt que le comportement souhaité :
|
||||
// GetProductByID fait un Raw(...).Scan() qui ne retourne PAS d'erreur
|
||||
// quand aucune ligne ne matche (contrairement à First()), donc le check
|
||||
// "produit non trouvé → 404" en tête de UpdateStock ne se déclenche
|
||||
// jamais. La requête continue jusqu'à SetProductStock, qui échoue côté
|
||||
// DB et remonte en 500 générique au lieu d'un 404 propre.
|
||||
func TestUpdateStock_UnknownProductReturns500NotFoundBugDocumented(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
c, rec := jsonStockContext("admin", []byte(`{"stock":5}`), 99999999)
|
||||
handlers.UpdateStock(c)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("comportement actuel (bug): produit inconnu retourne 500, pas 404: got=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStock_SuccessSetsExactValue(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "USOk", 10)
|
||||
c, rec := jsonStockContext("admin", []byte(`{"stock":33}`), productID)
|
||||
handlers.UpdateStock(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("mise à jour stock valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := productStock(t, productID); got != 33 {
|
||||
t.Errorf("stock après UpdateStock: got=%.2f want=33", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Documente le comportement actuel du garde-fou "réservé en panier" de
|
||||
// UpdateStock : `req.Stock+reserved < reserved` est algébriquement
|
||||
// équivalent à `req.Stock < 0`, donc ce garde-fou ne bloque en réalité
|
||||
// jamais un stock positif inférieur à la quantité réservée. Un admin peut
|
||||
// donc, aujourd'hui, mettre le stock en dessous du réservé en panier — ce
|
||||
// test documente ce comportement pour éviter une régression silencieuse
|
||||
// si quelqu'un "corrige" la formule sans le vouloir explicitement.
|
||||
func TestUpdateStock_ReservedGuardDoesNotBlockStockBelowReserved(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "us_reserved")
|
||||
productID := newTestProduct(t, "USReserved", 10)
|
||||
if _, err := testDB.AddToBasket(username, productID, 8); err != nil {
|
||||
t.Fatalf("AddToBasket: %v", err)
|
||||
}
|
||||
// 8 sont réservés en panier ; on met le stock à 1, en dessous du réservé.
|
||||
c, rec := jsonStockContext("admin", []byte(`{"stock":1}`), productID)
|
||||
handlers.UpdateStock(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("comportement actuel: la requête réussit malgré stock < réservé: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := productStock(t, productID); got != 1 {
|
||||
t.Errorf("stock après UpdateStock: got=%.2f want=1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── UpdateProduct ────────────────────────────────────────────────────────
|
||||
|
||||
func updateProductJSONContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products", 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", testUserPrefix+"product_admin")
|
||||
c.Set("role", role)
|
||||
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func TestUpdateProduct_UpdatesStockWhenProvided(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
cat := newTestCategory(t, "CatUpd")
|
||||
productID := newTestProduct(t, "UPStock", 5)
|
||||
|
||||
body := []byte(fmt.Sprintf(`{"name":"UPStock","category":%q,"description":"d2","unit":"kg","stock":77,"prices":[{"quantity":1,"price":5}]}`, cat))
|
||||
c, rec := updateProductJSONContext("admin", body, productID)
|
||||
handlers.UpdateProduct(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateProduct valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := productStock(t, productID); got != 77 {
|
||||
t.Errorf("stock après UpdateProduct: got=%.2f want=77", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeleteProduct ────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteProduct_RemovesProductWithoutMedia(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "DelNoMedia", 3)
|
||||
c, rec := productContext("admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", productID)
|
||||
handlers.DeleteProduct(c)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("suppression produit sans média doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var count int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE id = ?`, productID).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("le produit doit être supprimé: got=%d lignes restantes", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GetReservedQuantityInBaskets ─────────────────────────────────────────
|
||||
|
||||
func TestGetReservedQuantityInBaskets_SumsNonRewardQuantities(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
productID := newTestProduct(t, "ResQty", 20)
|
||||
c1 := newTestClient(t, "resqty_c1")
|
||||
c2 := newTestClient(t, "resqty_c2")
|
||||
if _, err := testDB.AddToBasket(c1, productID, 3); err != nil {
|
||||
t.Fatalf("AddToBasket c1: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(c2, productID, 5); err != nil {
|
||||
t.Fatalf("AddToBasket c2: %v", err)
|
||||
}
|
||||
reserved, err := testDB.GetReservedQuantityInBaskets(productID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetReservedQuantityInBaskets: %v", err)
|
||||
}
|
||||
if reserved != 8 {
|
||||
t.Errorf("réservé total: got=%.2f want=8", reserved)
|
||||
}
|
||||
}
|
||||
|
||||
// ── AddToBasket merge behavior ───────────────────────────────────────────
|
||||
|
||||
func TestAddToBasket_MergesIntoExistingNonRewardRow(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "merge_basket")
|
||||
productID := newTestProduct(t, "MergeBasket", 20)
|
||||
|
||||
if _, err := testDB.AddToBasket(username, productID, 2); err != nil {
|
||||
t.Fatalf("AddToBasket #1: %v", err)
|
||||
}
|
||||
if _, err := testDB.AddToBasket(username, productID, 3); err != nil {
|
||||
t.Fatalf("AddToBasket #2: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Fatalf("les deux ajouts doivent fusionner en une seule ligne: got=%d lignes", count)
|
||||
}
|
||||
var qty float64
|
||||
testDB.GDB.Raw(`SELECT quantity FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&qty)
|
||||
if qty != 5 {
|
||||
t.Errorf("quantité fusionnée: got=%.2f want=5", qty)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Checkout validation (db.CreateCommandWithAddress) ───────────────────
|
||||
|
||||
func TestCreateCommandWithAddress_RejectsEmptyBasket(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "checkout_empty")
|
||||
_, err := testDB.CreateCommandWithAddress(username, "1 rue vide")
|
||||
if err == nil {
|
||||
t.Fatal("checkout avec panier vide doit échouer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCommandWithAddress_RejectsInvalidBasketItemData(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "checkout_invalid_item")
|
||||
productID := newTestProduct(t, "CheckoutInvalidItem", 10)
|
||||
// Insertion directe d'une ligne de panier avec quantité invalide,
|
||||
// en contournant AddToBasket qui la rejetterait.
|
||||
testDB.GDB.Exec(
|
||||
`INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`,
|
||||
username, productID, 0, 10,
|
||||
)
|
||||
_, err := testDB.CreateCommandWithAddress(username, "1 rue invalide")
|
||||
if err == nil {
|
||||
t.Fatal("checkout avec donnée panier invalide (quantité=0) doit échouer")
|
||||
}
|
||||
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
|
||||
}
|
||||
|
||||
func TestCreateCommandWithAddress_RejectsTotalPriceOverMax(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "checkout_overmax")
|
||||
productID := newTestProduct(t, "CheckoutOverMax", 10)
|
||||
testDB.GDB.Exec(
|
||||
`INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`,
|
||||
username, productID, 1, 100001,
|
||||
)
|
||||
_, err := testDB.CreateCommandWithAddress(username, "1 rue trop cher")
|
||||
if err == nil {
|
||||
t.Fatal("checkout avec montant total > 100000 doit échouer")
|
||||
}
|
||||
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
|
||||
}
|
||||
|
||||
// ── DeleteCommandItem ─────────────────────────────────────────────────────
|
||||
|
||||
func TestDeleteCommandItem_UpdatesCommandTotalPrix(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delitem_total")
|
||||
productID := newTestProduct(t, "DelItemTotal", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
|
||||
testDB.GDB.Exec(`UPDATE commandes SET total_prix = 20 WHERE id = ?`, cmdID)
|
||||
|
||||
var itemID int
|
||||
testDB.GDB.Raw(`SELECT id FROM command_items WHERE command_id = ?`, cmdID).Scan(&itemID)
|
||||
|
||||
if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil {
|
||||
t.Fatalf("DeleteCommandItem: %v", err)
|
||||
}
|
||||
|
||||
var totalPrix float64
|
||||
testDB.GDB.Raw(`SELECT total_prix FROM commandes WHERE id = ?`, cmdID).Scan(&totalPrix)
|
||||
if totalPrix != 0 {
|
||||
t.Errorf("total_prix après suppression du seul item: got=%.2f want=0", totalPrix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCommandItem_UnknownItemReturnsError(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "delitem_unknown")
|
||||
productID := newTestProduct(t, "DelItemUnknown", 10)
|
||||
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
|
||||
|
||||
if err := testDB.DeleteCommandItem(cmdID, 99999999); err == nil {
|
||||
t.Fatal("suppression d'un item inconnu doit retourner une erreur")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteCommandItem_UnknownCommandReturnsError(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
if err := testDB.DeleteCommandItem(99999999, 1); err == nil {
|
||||
t.Fatal("suppression d'un item sur une commande inconnue doit retourner une erreur")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package tests
|
||||
|
||||
import "testing"
|
||||
|
||||
// Scénario métier précis : un client réclame une récompense sur un produit,
|
||||
// puis ajoute EN PLUS ce même produit à son panier en achat normal (quantité
|
||||
// différente). AddToBasket filtre ses recherches d'item existant sur
|
||||
// "is_reward = false" (voir db_basket.go), donc les deux lignes ne sont
|
||||
// jamais fusionnées : elles restent deux lignes distinctes en base
|
||||
// (product_id identique, is_reward différent). Le checkout doit décrémenter
|
||||
// le stock de la SOMME des deux quantités, pas juste de l'une des deux.
|
||||
|
||||
func insertNormalBasketRow(t *testing.T, username string, productID int, quantity, price float64) {
|
||||
t.Helper()
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
|
||||
VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)`,
|
||||
username, productID, quantity, price,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insertion panier normal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertRewardBasketRow(t *testing.T, username string, productID int, quantity float64) {
|
||||
t.Helper()
|
||||
if err := testDB.GDB.Exec(
|
||||
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||
VALUES (?, ?, ?, 0, true, 'pool_0', CURRENT_TIMESTAMP)`,
|
||||
username, productID, quantity,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insertion panier récompense: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckout_SameProductRewardAndNormalBothDecrementSeparately(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_same_product")
|
||||
productID := newTestProduct(t, "RewardSameProduct", 20)
|
||||
|
||||
// 1 unité offerte (récompense) + 5 unités achetées normalement, même produit.
|
||||
insertRewardBasketRow(t, username, productID, 1)
|
||||
insertNormalBasketRow(t, username, productID, 5, 50)
|
||||
|
||||
var basketCount int64
|
||||
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&basketCount)
|
||||
if basketCount != 2 {
|
||||
t.Fatalf("précondition: deux lignes panier distinctes attendues (reward + normal), got=%d", basketCount)
|
||||
}
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
|
||||
// 20 initial - 1 (récompense) - 5 (normal) = 14, pas 19 ni 15.
|
||||
if got := productStock(t, productID); got != 14 {
|
||||
t.Errorf("stock après checkout (récompense + normal sur le même produit): got=%.2f want=14", got)
|
||||
}
|
||||
|
||||
var items []struct {
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
IsReward bool `gorm:"column:is_reward"`
|
||||
}
|
||||
if err := testDB.GDB.Raw(
|
||||
`SELECT quantite, is_reward FROM command_items WHERE product_id = ? ORDER BY is_reward`,
|
||||
productID,
|
||||
).Scan(&items).Error; err != nil {
|
||||
t.Fatalf("lecture command_items: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("deux command_items distincts attendus (reward + normal), got=%d", len(items))
|
||||
}
|
||||
if items[0].IsReward || items[0].Quantite != 5 {
|
||||
t.Errorf("item normal: got IsReward=%v Quantite=%.2f, want IsReward=false Quantite=5", items[0].IsReward, items[0].Quantite)
|
||||
}
|
||||
if !items[1].IsReward || items[1].Quantite != 1 {
|
||||
t.Errorf("item récompense: got IsReward=%v Quantite=%.2f, want IsReward=true Quantite=1", items[1].IsReward, items[1].Quantite)
|
||||
}
|
||||
}
|
||||
|
||||
// Si l'article payant (même produit) fait basculer le panier sous le stock
|
||||
// disponible, tout le checkout doit échouer — y compris la part récompense,
|
||||
// déjà vérifié pour des produits différents dans rewards_test.go, ici avec
|
||||
// le MÊME produit sur les deux lignes.
|
||||
func TestCheckout_SameProductRewardAndNormalRollsBackTogetherOnInsufficientStock(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_same_product_fail")
|
||||
productID := newTestProduct(t, "RewardSameProductFail", 3)
|
||||
|
||||
insertRewardBasketRow(t, username, productID, 1)
|
||||
insertNormalBasketRow(t, username, productID, 5, 50) // 1 + 5 = 6 > stock disponible (3)
|
||||
|
||||
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
|
||||
t.Fatal("attendu un échec de checkout (1 + 5 = 6 unités demandées pour 3 en stock)")
|
||||
}
|
||||
|
||||
if got := productStock(t, productID); got != 3 {
|
||||
t.Errorf("stock ne doit pas bouger si le total (reward+normal) dépasse le stock: got=%.2f want=3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Une fois la commande passée (récompense + normal sur le même produit),
|
||||
// l'annulation doit rembourser la somme des deux quantités, en une seule
|
||||
// fois (la requête de remboursement agrège déjà par product_id sur
|
||||
// l'ensemble des command_items — voir db_cancel_command.go).
|
||||
func TestCancelCommandAtomic_RefundsBothRewardAndNormalLinesForSameProduct(t *testing.T) {
|
||||
cleanupStockTestData(t)
|
||||
username := newTestClient(t, "reward_same_product_cancel")
|
||||
productID := newTestProduct(t, "RewardSameProductCancel", 20)
|
||||
|
||||
insertRewardBasketRow(t, username, productID, 1)
|
||||
insertNormalBasketRow(t, username, productID, 5, 50)
|
||||
|
||||
cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCommandWithAddress: %v", err)
|
||||
}
|
||||
if got := productStock(t, productID); got != 14 {
|
||||
t.Fatalf("précondition stock post-checkout: got=%.2f want=14", got)
|
||||
}
|
||||
|
||||
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil {
|
||||
t.Fatalf("CancelCommandAtomic: %v", err)
|
||||
}
|
||||
|
||||
// 14 + 1 (reward) + 5 (normal) = 20, retour au stock initial exact.
|
||||
if got := productStock(t, productID); got != 20 {
|
||||
t.Errorf("stock après annulation (remboursement des deux lignes du même produit): got=%.2f want=20", got)
|
||||
}
|
||||
|
||||
// Rejeu : ne doit rembourser qu'une fois, même avec deux lignes sur le même produit.
|
||||
if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil {
|
||||
t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois")
|
||||
}
|
||||
if got := productStock(t, productID); got != 20 {
|
||||
t.Errorf("stock après double annulation (reward+normal même produit): got=%.2f want=20 (un seul remboursement)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"gestion/utils"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// utils.NormalizeAddress supprime les accents et normalise les espaces, mais
|
||||
// ne touche PAS à la casse — les appelants qui veulent une comparaison
|
||||
// insensible à la casse doivent combiner avec strings.EqualFold ou
|
||||
// strings.ToLower (voir services/adresses_correction.go et db/db_address.go).
|
||||
// Ce fichier fixe ce contrat par des tests, pour éviter qu'un futur appelant
|
||||
// suppose à tort que la casse est déjà gérée.
|
||||
|
||||
func TestNormalizeAddress_RemovesAccents(t *testing.T) {
|
||||
got := utils.NormalizeAddress("12 Rue Crébillon, Nantés")
|
||||
want := "12 Rue Crebillon, Nantes"
|
||||
if got != want {
|
||||
t.Errorf("got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAddress_CollapsesMultipleAndTrailingSpaces(t *testing.T) {
|
||||
got := utils.NormalizeAddress(" 12 Rue de la Paix ")
|
||||
want := "12 Rue de la Paix"
|
||||
if got != want {
|
||||
t.Errorf("got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAddress_DoesNotChangeCase(t *testing.T) {
|
||||
// Contrat volontaire : NormalizeAddress n'abaisse pas la casse. Un
|
||||
// appelant qui veut une comparaison insensible à la casse doit le faire
|
||||
// explicitement (strings.EqualFold côté appelant).
|
||||
got := utils.NormalizeAddress("Rue De La Paix")
|
||||
if got == strings.ToLower(got) {
|
||||
t.Fatalf("ce test suppose une entrée avec des majuscules significatives, corrige le cas de test")
|
||||
}
|
||||
if got != "Rue De La Paix" {
|
||||
t.Errorf("NormalizeAddress ne doit pas modifier la casse: got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAddress_EmptyStringReturnsEmpty(t *testing.T) {
|
||||
if got := utils.NormalizeAddress(""); got != "" {
|
||||
t.Errorf("got=%q want=%q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAddress_CombinedAccentsAndSpaces(t *testing.T) {
|
||||
got := utils.NormalizeAddress(" 20 Boulevard Général de Gaulle, Nantés ")
|
||||
want := "20 Boulevard General de Gaulle, Nantes"
|
||||
if got != want {
|
||||
t.Errorf("got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user