chore: build
Backend - Build & Lint / build (push) Failing after 28m23s

This commit is contained in:
Nuxgrid
2026-07-12 15:20:53 +02:00
parent 2919bca86d
commit 4de42cff57
40 changed files with 2539 additions and 796 deletions
@@ -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")
}
}
+273
View File
@@ -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)
}
}
+188
View File
@@ -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)
}
}
+261
View File
@@ -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)
}
}
+160
View File
@@ -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)
}
}
+254
View File
@@ -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)
}
}
+104
View File
@@ -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)
}
}