chore: build
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user