406 lines
15 KiB
Go
406 lines
15 KiB
Go
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)
|
|
}
|
|
}
|