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) } }