This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,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,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,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,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,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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user