chore: build
Backend - Build & Lint / build (push) Failing after 29m5s
Frontend Client - EAS Build / build (push) Failing after 2h25m33s

This commit is contained in:
Nuxgrid
2026-07-14 18:34:58 +02:00
parent 8c3fa39d86
commit 734493363f
17 changed files with 2014 additions and 7 deletions
+1 -1
View File
@@ -195,7 +195,7 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
// ✅ Ne restitue le stock QUE si pas déjà fait
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved"
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" || cmdResult.Status == "livre"
if !stockAlreadyRestored {
if err := tx.Exec(`
UPDATE products p
+1 -5
View File
@@ -145,11 +145,7 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
func sanitizeFilePath(path string) (string, error) {
cleaned := filepath.Clean(path)
if strings.Contains(cleaned, "..") || strings.Contains(cleaned, ".") {
return "", fmt.Errorf("path traversal détecté")
}
if strings.Contains(cleaned, "//..//") || strings.Contains(cleaned, "/../") {
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("path traversal détecté")
}
@@ -0,0 +1,256 @@
package tests
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func cmdContext(method, username, role string, body []byte, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
var reader *bytes.Reader
if body != nil {
reader = bytes.NewReader(body)
} else {
reader = bytes.NewReader([]byte{})
}
req := httptest.NewRequest(method, "/api/v1/commands", reader)
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 commandID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
}
return c, rec
}
// ── CancelCommandByClient (HTTP layer) ───────────────────────────────────
func TestCancelCommandByClient_RejectsNonClientRole(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodPost, testUserPrefix+"cancel_role", "admin", nil, 1)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role admin doit être refusé: got=%d want=403", rec.Code)
}
}
func TestCancelCommandByClient_InvalidCommandIDReturns400(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_badid")
c, rec := cmdContext(http.MethodPost, username, "client", nil, 0)
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
}
}
func TestCancelCommandByClient_UnknownCommandReturns404(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_404")
c, rec := cmdContext(http.MethodPost, username, "client", nil, 99999999)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCancelCommandByClient_WrongOwnerReturns403(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "cancel_owner")
intruder := newTestClient(t, "cancel_intruder")
productID := newTestProduct(t, "CancelWrongOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := cmdContext(http.MethodPost, intruder, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un autre client ne doit pas pouvoir annuler: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCancelCommandByClient_SuccessNoPenaltyWhenNoLivreur(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_ok_nopenalty")
productID := newTestProduct(t, "CancelOkNoPenalty", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusOK {
t.Fatalf("annulation sans livreur doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation: got=%s want=cancelled", got)
}
}
func TestCancelCommandByClient_TerminalStatusReturns400(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_terminal")
productID := newTestProduct(t, "CancelTerminal", 10)
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("annulation d'une commande livrée doit être rejetée: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCancelCommandByClient_ConfirmationRequiredReturns409WithPenaltyWarning(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_confirm")
livreur := "cancel_confirm_livreur"
productID := newTestProduct(t, "CancelConfirm", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", livreur, productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusConflict {
t.Fatalf("annulation tardive sans force doit demander confirmation: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"will_apply":true`)) {
t.Errorf("la réponse doit avertir d'une pénalité à venir: body=%s", rec.Body.String())
}
// Statut inchangé tant que non confirmé.
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("statut ne doit pas changer avant confirmation: got=%s want=en_route", got)
}
}
// ── GetMyCancellationHistory ──────────────────────────────────────────────
func TestGetMyCancellationHistory_RejectsNonClientRole(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodGet, testUserPrefix+"hist_role", "livreur", nil, 0)
handlers.GetMyCancellationHistory(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestGetMyCancellationHistory_ReturnsHistoryAndTotalPenalties(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "hist_ok")
c, rec := cmdContext(http.MethodGet, username, "client", nil, 0)
handlers.GetMyCancellationHistory(c)
if rec.Code != http.StatusOK {
t.Fatalf("historique doit réussir pour un client: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"total_penalties"`)) {
t.Errorf("la réponse doit inclure total_penalties: body=%s", rec.Body.String())
}
}
// ── GetAllCancelledOrders ─────────────────────────────────────────────────
func TestGetAllCancelledOrders_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_role", "livreur", nil, 0)
handlers.GetAllCancelledOrders(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestGetAllCancelledOrders_InvalidLimitFallsBackToDefault(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_limit", "admin", nil, 0)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=not-a-number", nil)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"allcancel_limit")
c.Set("role", "admin")
handlers.GetAllCancelledOrders(c)
if rec.Code != http.StatusOK {
t.Fatalf("limit invalide doit quand même réussir avec un défaut: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetAllCancelledOrders_LimitCapsAt500(t *testing.T) {
cleanupStockTestData(t)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=99999", nil)
c.Set("database", testDB)
c.Set("username", testUserPrefix+"allcancel_cap")
c.Set("role", "admin")
handlers.GetAllCancelledOrders(c)
if rec.Code != http.StatusOK {
t.Fatalf("limit énorme doit quand même réussir (plafonné): got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── DeleteCommandByCabine ─────────────────────────────────────────────────
func TestDeleteCommandByCabine_RejectsNonCabineNonAdmin(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_role", "client", nil, 1)
handlers.DeleteCommandByCabine(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
func TestDeleteCommandByCabine_UnknownCommandReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_404", "cabine", nil, 99999999)
handlers.DeleteCommandByCabine(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestDeleteCommandByCabine_SuccessDeletesCommand(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delcab_ok")
productID := newTestProduct(t, "DelCabOk", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_ok_actor", "cabine", nil, cmdID)
handlers.DeleteCommandByCabine(c)
if rec.Code != http.StatusOK {
t.Fatalf("suppression par cabine doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE id = ?`, cmdID).Scan(&count)
if count != 0 {
t.Errorf("la commande doit être supprimée: got=%d lignes restantes", count)
}
}
// ── validateReason (testé indirectement via CancelCommandByClient) ───────
func TestCancelCommandByClient_BlankReasonDefaultsToStandardMessage(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reason_blank")
productID := newTestProduct(t, "ReasonBlank", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
body := []byte(`{"reason":""}`)
c, rec := cmdContext(http.MethodPost, username, "client", body, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusOK {
t.Fatalf("annulation avec reason vide doit réussir (validateReason doit fournir un défaut, pas rejeter): got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation avec raison vide: got=%s want=cancelled", got)
}
}
@@ -0,0 +1,222 @@
package tests
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
// newTestLivreur crée un utilisateur role=livreur réel (AssignDeliveryPerson
// vérifie son existence/rôle en base, pas juste le contexte gin) et
// programme son nettoyage.
func newTestLivreur(t *testing.T, name string) string {
t.Helper()
username := testUserPrefix + name
if err := testDB.GDB.Exec(
`INSERT INTO users (username, password, role) VALUES (?, 'x', 'livreur') ON CONFLICT (username) DO NOTHING`,
username,
).Error; err != nil {
t.Fatalf("création livreur test %q: %v", username, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM users WHERE username = ?`, username)
})
return username
}
func getAllCommandsContext(role string, query url.Values) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands?"+query.Encode(), nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"gac_admin")
c.Set("role", role)
return c, rec
}
// ── GetAllCommands ────────────────────────────────────────────────────────
func TestGetAllCommands_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := getAllCommandsContext("livreur", url.Values{})
handlers.GetAllCommands(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestGetAllCommands_FiltersByStatusAndUsername(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "gac_filter")
productID := newTestProduct(t, "GACFilter", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
otherUser := newTestClient(t, "gac_filter_other")
newTestCommandWithItem(t, otherUser, "cancelled", "", productID, 1, 10)
q := url.Values{}
q.Set("status", "pending")
q.Set("username", username)
c, rec := getAllCommandsContext("admin", q)
handlers.GetAllCommands(c)
if rec.Code != http.StatusOK {
t.Fatalf("requête filtrée doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(fmt.Sprintf(`"id":%d`, cmdID))) {
t.Errorf("la commande filtrée doit apparaître dans le résultat: body=%s", rec.Body.String())
}
}
func TestGetAllCommands_AllSentinelReturnsEverything(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "gac_all")
productID := newTestProduct(t, "GACAll", 10)
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands/all/all", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"gac_admin")
c.Set("role", "admin")
c.Params = gin.Params{{Key: "status", Value: "all"}, {Key: "username", Value: "all"}}
handlers.GetAllCommands(c)
if rec.Code != http.StatusOK {
t.Fatalf("sentinel 'all' doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── AssignDeliveryPerson ──────────────────────────────────────────────────
func assignContext(role string, body []byte, commandID int, livreurUsername string) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/assign", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
c.Set("username", testUserPrefix+"assign_staff")
c.Set("role", role)
params := gin.Params{{Key: "command_id", Value: fmt.Sprintf("%d", commandID)}}
if livreurUsername != "" {
params = append(params, gin.Param{Key: "username", Value: livreurUsername})
}
c.Params = params
return c, rec
}
func TestAssignDeliveryPerson_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := assignContext("client", nil, 1, "someone")
handlers.AssignDeliveryPerson(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
func TestAssignDeliveryPerson_SupportsCommandIDParam(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "assign_cmdid")
livreur := newTestLivreur(t, "assign_cmdid_livreur")
productID := newTestProduct(t, "AssignCmdID", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
c, rec := assignContext("admin", nil, cmdID, livreur)
handlers.AssignDeliveryPerson(c)
if rec.Code != http.StatusOK {
t.Fatalf("assignation via :command_id doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestAssignDeliveryPerson_SupportsIDParamFallback(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "assign_id")
livreur := newTestLivreur(t, "assign_id_livreur")
productID := newTestProduct(t, "AssignID", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
req := httptest.NewRequest(http.MethodPost, "/api/v1/cabine/assign", bytes.NewReader([]byte(fmt.Sprintf(`{"livreur_username":%q}`, livreur))))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
c.Set("username", testUserPrefix+"assign_staff")
c.Set("role", "cabine")
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
handlers.AssignDeliveryPerson(c)
if rec.Code != http.StatusOK {
t.Fatalf("assignation via :id (fallback cabine) doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── GetCommandItemsWithDetails ────────────────────────────────────────────
func itemsDetailedContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/items", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
if setUsername {
c.Set("username", username)
}
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
return c, rec
}
func TestGetCommandItemsWithDetails_UnauthenticatedReturns401(t *testing.T) {
cleanupStockTestData(t)
c, rec := itemsDetailedContext("", "client", 1, false)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
}
}
func TestGetCommandItemsWithDetails_IDORBlockedForOtherClient(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "items_owner")
intruder := newTestClient(t, "items_intruder")
productID := newTestProduct(t, "ItemsIDOR", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := itemsDetailedContext(intruder, "client", cmdID, true)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un autre client ne doit pas accéder aux items: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetCommandItemsWithDetails_OwnerCanAccess(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "items_owner_ok")
productID := newTestProduct(t, "ItemsOwnerOk", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 2, 20)
c, rec := itemsDetailedContext(owner, "client", cmdID, true)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusOK {
t.Fatalf("le propriétaire doit accéder à ses items: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetCommandItemsWithDetails_NoItemsReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := itemsDetailedContext(testUserPrefix+"items_admin", "admin", 99999999, true)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande sans item doit retourner 404: got=%d", rec.Code)
}
}
+194
View File
@@ -0,0 +1,194 @@
package tests
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/db"
"gestion/handlers"
"gestion/services"
"github.com/gin-gonic/gin"
)
// db.Redis n'est initialisé qu'après TestMain (db.InitRedis) — un var
// package-level serait construit trop tôt, d'où cette init paresseuse.
var testGeoService *services.GeoService
// ensureTestGeoService initialise paresseusement le GeoService partagé
// (db.Redis n'existe qu'après TestMain) — réutilisé par les autres fichiers
// de tests qui ont besoin de "geoService" dans le contexte gin.
func ensureTestGeoService() *services.GeoService {
if testGeoService == nil {
testGeoService = services.NewGeoService(db.Redis, db.RedisCtx)
}
return testGeoService
}
func etaContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/eta", bytes.NewReader(nil))
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
if setUsername {
c.Set("username", username)
}
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
return c, rec
}
func TestGetOrderETA_UnauthenticatedReturns401(t *testing.T) {
cleanupStockTestData(t)
c, rec := etaContext("", "client", 1, false)
handlers.GetOrderETA(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
}
}
func TestGetOrderETA_InvalidCommandIDReturns400(t *testing.T) {
cleanupStockTestData(t)
c, rec := etaContext(testUserPrefix+"eta_badid", "client", 0, true)
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
handlers.GetOrderETA(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
}
}
func TestGetOrderETA_UnknownCommandReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := etaContext(testUserPrefix+"eta_404", "client", 99999999, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande inconnue doit retourner 404: got=%d", rec.Code)
}
}
func TestGetOrderETA_ClientAccessingAnotherClientCommandReturns403(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_owner")
intruder := newTestClient(t, "eta_intruder")
productID := newTestProduct(t, "EtaOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur", productID, 1, 10)
c, rec := etaContext(intruder, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un autre client ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOrderETA_LivreurNotAssignedReturns403(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_lv_owner")
productID := newTestProduct(t, "EtaLvOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_real_livreur", productID, 1, 10)
c, rec := etaContext("eta_other_livreur", "livreur", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un livreur non assigné ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOrderETA_AdminBypassesOwnershipChecks(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_admin_owner")
productID := newTestProduct(t, "EtaAdminOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := etaContext(testUserPrefix+"eta_admin", "admin", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Errorf("admin doit pouvoir accéder à l'ETA de n'importe quelle commande: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOrderETA_DeliveredStatusReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_delivered")
productID := newTestProduct(t, "EtaDelivered", 10)
cmdID := newTestCommandWithItem(t, owner, "livre", "eta_livreur_d", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("statut livre doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false pour une commande livrée: body=%s", rec.Body.String())
}
}
func TestGetOrderETA_PendingStatusReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_pending")
productID := newTestProduct(t, "EtaPending", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("statut pending doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false pour une commande pending: body=%s", rec.Body.String())
}
}
func TestGetOrderETA_ArrivedStatusReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_arrived")
productID := newTestProduct(t, "EtaArrived", 10)
cmdID := newTestCommandWithItem(t, owner, "arrived", "eta_livreur_a", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("statut arrived doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false quand le livreur est arrivé: body=%s", rec.Body.String())
}
}
func TestGetOrderETA_NoLivreurAssignedReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_nolivreur")
productID := newTestProduct(t, "EtaNoLivreur", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("sans livreur assigné doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false sans livreur assigné: body=%s", rec.Body.String())
}
}
// Sans coordonnées de destination et sans cache Redis préalable, le
// handler doit tomber sur returnStaleOrUnavailable plutôt que planter.
func TestGetOrderETA_MissingDestinationCoordsFallsBackGracefully(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_nodest")
productID := newTestProduct(t, "EtaNoDest", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur_nodest", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("sans coordonnées destination doit quand même retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false sans coordonnées destination: body=%s", rec.Body.String())
}
}
@@ -0,0 +1,79 @@
package tests
import (
"math"
"testing"
"gestion/services"
)
// Deux points à Nantes séparés d'environ 1.1km à vol d'oiseau (Haversine).
var (
nantesCentre = services.Coordinates{Latitude: 47.2184, Longitude: -1.5536}
nantesProche = services.Coordinates{Latitude: 47.2280, Longitude: -1.5536}
)
func TestCalculateDistance_HaversineCorrectness(t *testing.T) {
dist := services.CalculateDistance(nantesCentre, nantesProche)
// ~0.0096 rad de latitude ≈ 1.067km — tolérance large pour la formule.
if dist < 0.9 || dist > 1.3 {
t.Errorf("distance Haversine hors plage attendue: got=%.3fkm want≈1.07km", dist)
}
}
func TestCalculateDistance_SamePointIsZero(t *testing.T) {
dist := services.CalculateDistance(nantesCentre, nantesCentre)
if dist != 0 {
t.Errorf("distance entre un point et lui-même doit être 0: got=%.4f", dist)
}
}
func TestCalculateETA_UnderPointOneKmReturnsMinETA(t *testing.T) {
eta := services.CalculateETA(0.05)
if eta != services.MinETA {
t.Errorf("distance < 0.1km doit retourner MinETA: got=%d want=%d", eta, services.MinETA)
}
}
func TestCalculateETA_AppliesTwentyPercentTrafficMargin(t *testing.T) {
// 25km à 25km/h = 60min ; +20% marge = 72min (dans les bornes [MinETA, MaxETA]).
eta := services.CalculateETA(25)
want := 72
if eta != want {
t.Errorf("ETA avec marge trafic 20%%: got=%d want=%d", eta, want)
}
}
func TestCalculateETA_ClampsToMaxETA(t *testing.T) {
eta := services.CalculateETA(1000)
if eta != services.MaxETA {
t.Errorf("très longue distance doit être plafonnée à MaxETA: got=%d want=%d", eta, services.MaxETA)
}
}
func TestCalculateETA_ClampsToMinETA(t *testing.T) {
// distance faible mais non nulle, donnant un temps de trajet < MinETA
// après calcul (pas la branche <0.1km, une distance différente).
eta := services.CalculateETA(0.5)
if eta < services.MinETA {
t.Errorf("ETA ne doit jamais être inférieur à MinETA: got=%d want>=%d", eta, services.MinETA)
}
}
// Sans clé API TomTom configurée (cas de cet environnement de test),
// CalculateETAWithTomTom doit retomber sur le calcul local identique à
// CalculateDistance+CalculateETA.
func TestCalculateETAWithTomTom_FallsBackToLocalCalcWithoutAPIKey(t *testing.T) {
etaMinutes, distanceKm, err := services.CalculateETAWithTomTom(nantesCentre, nantesProche)
if err != nil {
t.Fatalf("fallback local ne doit pas retourner d'erreur: %v", err)
}
wantDistance := services.CalculateDistance(nantesCentre, nantesProche)
wantETA := services.CalculateETA(wantDistance)
if math.Abs(distanceKm-wantDistance) > 0.0001 {
t.Errorf("distance fallback: got=%.4f want=%.4f", distanceKm, wantDistance)
}
if etaMinutes != wantETA {
t.Errorf("eta fallback: got=%d want=%d", etaMinutes, wantETA)
}
}
@@ -0,0 +1,59 @@
package tests
import "testing"
func TestGetLastDeliveryCoords_NoPreviousDeliveryReturnsError(t *testing.T) {
cleanupStockTestData(t)
_, _, err := testDB.GetLastDeliveryCoords(testUserPrefix + "coords_nolivraison")
if err == nil {
t.Fatal("sans livraison précédente, une erreur est attendue")
}
}
func TestGetLastDeliveryCoords_FallsBackToDBWhenNoCache(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "coords_db")
livreur := testUserPrefix + "coords_db_livreur"
productID := newTestProduct(t, "CoordsDB", 10)
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 47.2184, dest_longitude = -1.5536, updated_at = NOW() WHERE id = ?`, cmdID)
lat, lon, err := testDB.GetLastDeliveryCoords(livreur)
if err != nil {
t.Fatalf("GetLastDeliveryCoords: %v", err)
}
if lat != 47.2184 || lon != -1.5536 {
t.Errorf("coordonnées depuis la DB: got=(%.4f,%.4f) want=(47.2184,-1.5536)", lat, lon)
}
}
// Une fois les coordonnées lues depuis la DB, elles sont mises en cache
// Redis — un second appel doit renvoyer la valeur cachée même si la DB
// change entretemps (TTL non expiré).
func TestGetLastDeliveryCoords_CachesResultInRedis(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "coords_cache")
livreur := testUserPrefix + "coords_cache_livreur"
productID := newTestProduct(t, "CoordsCache", 10)
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 48.8566, dest_longitude = 2.3522, updated_at = NOW() WHERE id = ?`, cmdID)
lat1, _, err := testDB.GetLastDeliveryCoords(livreur)
if err != nil {
t.Fatalf("premier appel: %v", err)
}
if lat1 != 48.8566 {
t.Fatalf("premier appel doit lire la DB: got lat=%.4f want=48.8566", lat1)
}
// Change la valeur en DB — un cache-hit doit ignorer ce changement.
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 0, dest_longitude = 0 WHERE id = ?`, cmdID)
lat2, lon2, err := testDB.GetLastDeliveryCoords(livreur)
if err != nil {
t.Fatalf("second appel (cache attendu): %v", err)
}
if lat2 != 48.8566 || lon2 != 2.3522 {
t.Errorf("second appel doit retourner la valeur cachée, pas la DB modifiée: got=(%.4f,%.4f) want=(48.8566,2.3522)", lat2, lon2)
}
}
@@ -0,0 +1,276 @@
package tests
import (
"bytes"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"gestion/handlers"
"gestion/services"
"github.com/gin-gonic/gin"
)
// Un MinIO local (docker) sert de backend S3 réel pour ces tests — pas de
// mock : mêmes chemins de code que RustFS en production (SDK AWS v2,
// UsePathStyle=true).
var testS3Service *services.S3Service
func getTestS3Service(t *testing.T) *services.S3Service {
t.Helper()
if testS3Service == nil {
s3, err := services.NewS3Service(
"us-east-1", "test-products", "http://localhost:9500",
services.S3Credentials{S3KeyId: "testadmin", S3AccessKey: "testpassword123"},
)
if err != nil {
t.Fatalf("NewS3Service: %v", err)
}
testS3Service = s3
}
return testS3Service
}
// tiny1x1PNG est un PNG valide minimal (1x1 pixel transparent), pour que
// la détection MIME réelle (mimetype.DetectReader) le reconnaisse comme
// image/png.
var tiny1x1PNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
func uploadMediaRequest(fileType, fileName string, fileContent []byte) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
w.WriteField("type", fileType)
part, _ := w.CreateFormFile("file", fileName)
part.Write(fileContent)
w.Close()
return body, w.FormDataContentType()
}
func mediaContext(t *testing.T, role string, body *bytes.Buffer, contentType string, productID int, mediaID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products/media", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("s3Service", getTestS3Service(t))
c.Set("username", testUserPrefix+"media_admin")
c.Set("role", role)
params := gin.Params{}
if productID != 0 {
params = append(params, gin.Param{Key: "id", Value: fmt.Sprintf("%d", productID)})
}
if mediaID != 0 {
params = append(params, gin.Param{Key: "media_id", Value: fmt.Sprintf("%d", mediaID)})
}
c.Params = params
return c, rec
}
// ── UploadMedia ──────────────────────────────────────────────────────────
func TestUploadMedia_RejectsNonAdmin(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaRoleCheck", 5)
body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
c, rec := mediaContext(t, "cabine", body, ct, productID, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role cabine doit être refusé pour UploadMedia: got=%d want=403", rec.Code)
}
}
func TestUploadMedia_RejectsInvalidType(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaBadType", 5)
body, ct := uploadMediaRequest("audio", "photo.png", tiny1x1PNG)
c, rec := mediaContext(t, "admin", body, ct, productID, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("type 'audio' invalide doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestUploadMedia_RejectsMimeMismatch(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaMimeMismatch", 5)
// Contenu texte brut mais annoncé comme "image" — le sniff MIME réel doit le détecter.
body, ct := uploadMediaRequest("image", "fake.png", []byte("ceci n'est pas une image, juste du texte brut"))
c, rec := mediaContext(t, "admin", body, ct, productID, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("contenu non-image envoyé comme type=image doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestUploadMedia_UnknownProductReturns404(t *testing.T) {
cleanupStockTestData(t)
body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
c, rec := mediaContext(t, "admin", body, ct, 99999999, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusNotFound {
t.Errorf("produit inconnu doit retourner 404: got=%d", rec.Code)
}
}
func TestUploadMedia_SuccessUploadsToS3AndCreatesMediaRow(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaUploadOk", 5)
body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
c, rec := mediaContext(t, "admin", body, ct, productID, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusCreated {
t.Fatalf("upload valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
media, err := testDB.GetMediaByProductID(productID)
if err != nil || len(media) != 1 {
t.Fatalf("un média doit être créé en base: err=%v count=%d", err, len(media))
}
// Vérifie que le fichier existe réellement sur MinIO (pas juste en DB).
rc, _, err := getTestS3Service(t).GetFile(t.Context(), media[0].Key)
if err != nil {
t.Fatalf("le fichier doit être récupérable depuis S3: %v", err)
}
rc.Close()
t.Cleanup(func() {
getTestS3Service(t).DeleteFile(media[0].Key)
})
}
// ── DeleteMedia ──────────────────────────────────────────────────────────
func TestDeleteMedia_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := mediaContext(t, "livreur", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, 1)
handlers.DeleteMedia(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestDeleteMedia_UnknownMediaReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := mediaContext(t, "admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, 99999999)
handlers.DeleteMedia(c)
if rec.Code != http.StatusNotFound {
t.Errorf("média inconnu doit retourner 404: got=%d", rec.Code)
}
}
func TestDeleteMedia_SuccessDeletesFromS3AndDB(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaDeleteOk", 5)
uploadBody, uploadCT := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
uc, urec := mediaContext(t, "admin", uploadBody, uploadCT, productID, 0)
handlers.UploadMedia(uc)
if urec.Code != http.StatusCreated {
t.Fatalf("upload préalable doit réussir: got=%d body=%s", urec.Code, urec.Body.String())
}
media, err := testDB.GetMediaByProductID(productID)
if err != nil || len(media) != 1 {
t.Fatalf("un média doit exister avant suppression: err=%v count=%d", err, len(media))
}
mediaID := media[0].ID
key := media[0].Key
c, rec := mediaContext(t, "admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, mediaID)
handlers.DeleteMedia(c)
if rec.Code != http.StatusOK {
t.Fatalf("suppression doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
remaining, _ := testDB.GetMediaByProductID(productID)
if len(remaining) != 0 {
t.Errorf("le média doit être supprimé en base: got=%d lignes restantes", len(remaining))
}
if _, _, err := getTestS3Service(t).GetFile(t.Context(), key); err == nil {
t.Error("le fichier doit être supprimé de S3, mais il est toujours récupérable")
}
}
// ── CreateProduct avec média réel (intégration multipart) ───────────────
//
// Contrairement à UploadMedia (qui écrit sur S3/RustFS), le chemin média de
// CreateProduct écrit sur le DISQUE LOCAL du serveur (c.SaveUploadedFile
// vers "uploads/<type>s/...") et ne renseigne jamais media.Key — donc ce
// fichier n'est pas récupérable via S3Service.GetFile ni via ServeMedia
// (qui lit par clé S3). C'est une incohérence d'architecture entre les deux
// chemins de code, pas juste une différence de test : à signaler séparément
// pour décision (stockage local perdu au redéploiement si le conteneur n'a
// pas de volume persistant sur "uploads/", et média invisible pour
// DeleteMedia's nettoyage S3). Ce test vérifie donc le comportement réel
// actuel (fichier sur disque local), pas un comportement souhaité en S3.
func TestCreateProduct_WithMediaSavesFileToLocalDisk(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatWithMedia")
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
w.WriteField("name", testProductPrefix+"CPMedia")
w.WriteField("category", cat)
w.WriteField("description", "d")
w.WriteField("stock", "5")
w.WriteField("prices[0][quantity]", "1")
w.WriteField("prices[0][price]", "5")
part, _ := w.CreateFormFile("media", "photo.png")
part.Write(tiny1x1PNG)
w.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products", body)
req.Header.Set("Content-Type", w.FormDataContentType())
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("s3Service", getTestS3Service(t))
c.Set("username", testUserPrefix+"media_admin")
c.Set("role", "admin")
handlers.CreateProduct(c)
if rec.Code != http.StatusCreated {
t.Fatalf("création produit avec média doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var productID int
testDB.GDB.Raw(`SELECT id FROM products WHERE name = ?`, testProductPrefix+"CPMedia").Scan(&productID)
if productID == 0 {
t.Fatalf("le produit doit être créé")
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
})
media, err := testDB.GetMediaByProductID(productID)
if err != nil || len(media) != 1 {
t.Fatalf("un média doit être associé au produit: err=%v count=%d", err, len(media))
}
if media[0].Key != "" {
t.Errorf("comportement actuel: media.Key doit être vide (pas de clé S3) pour le chemin CreateProduct: got=%q", media[0].Key)
}
localPath := strings.TrimPrefix(media[0].URL, "/")
if _, err := os.Stat(localPath); err != nil {
t.Errorf("le fichier doit exister sur le disque local à %q: %v", localPath, err)
}
t.Cleanup(func() {
os.Remove(localPath)
})
}
@@ -0,0 +1,162 @@
package tests
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"gestion/models"
"github.com/gin-gonic/gin"
)
func adminStatsContext() (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/stats", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
return c, rec
}
// insertStatsCommand insère une commande directement avec un created_at
// choisi, pour contrôler précisément le jour de semaine/heure agrégés.
func insertStatsCommand(t *testing.T, username, status, createdAt string) {
t.Helper()
testDB.GDB.Exec(
`INSERT INTO commandes (username, status, adresse, total_prix, created_at, updated_at)
VALUES (?, ?, 'Adresse stats test', 10, ?::timestamp, ?::timestamp)`,
username, status, createdAt, createdAt,
)
}
func TestGetAdminStats_ZeroOrdersAvgPerDayIsZeroNoPanic(t *testing.T) {
cleanupStockTestData(t)
// Table commandes peut contenir des données d'autres tests, mais aucune
// n'utilise ce username isolé — on vérifie juste l'absence de panic/NaN
// et que la structure de réponse est bien formée avec les vraies
// données actuelles de la base de test (qui peut être non-vide).
c, rec := adminStatsContext()
handlers.GetAdminStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Summary struct {
AvgPerDay float64 `json:"avg_per_day"`
} `json:"summary"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("réponse JSON invalide: %v", err)
}
if resp.Summary.AvgPerDay < 0 {
t.Errorf("avg_per_day ne doit jamais être négatif: got=%f", resp.Summary.AvgPerDay)
}
}
func TestGetAdminStats_PeakWeekdayMatchesBusiestDay(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_peak")
// 2024-01-08 est un lundi, 2024-01-07 un dimanche (DOW Postgres: 0=dimanche).
insertStatsCommand(t, username, "pending", "2024-01-08 10:00:00")
insertStatsCommand(t, username, "pending", "2024-01-08 11:00:00")
insertStatsCommand(t, username, "pending", "2024-01-08 12:00:00")
insertStatsCommand(t, username, "pending", "2024-01-07 10:00:00")
c, rec := adminStatsContext()
handlers.GetAdminStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Summary struct {
PeakWeekday string `json:"peak_weekday"`
} `json:"summary"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("réponse JSON invalide: %v", err)
}
if resp.Summary.PeakWeekday != "Lundi" {
t.Errorf("peak_weekday doit être le jour avec le plus de commandes: got=%q want=Lundi", resp.Summary.PeakWeekday)
}
}
func TestGetAdminStats_ByQuantitySortedDescendingByTotalOrders(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_byqty")
prodLow := newTestProduct(t, "ByQtyLow", 100)
prodHigh := newTestProduct(t, "ByQtyHigh", 100)
// prodHigh: 3 commandes ; prodLow: 1 commande.
for range 3 {
newTestCommandWithItem(t, username, "pending", "", prodHigh, 1, 10)
}
newTestCommandWithItem(t, username, "pending", "", prodLow, 1, 10)
c, rec := adminStatsContext()
handlers.GetAdminStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
ByQuantity []struct {
ProductID int `json:"product_id"`
TotalOrders int `json:"total_orders"`
} `json:"by_quantity"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("réponse JSON invalide: %v", err)
}
// Vérifie l'ordre décroissant global (pas seulement nos deux produits,
// d'autres tests peuvent avoir laissé des données) et que prodHigh
// apparaît avant prodLow.
highIdx, lowIdx := -1, -1
for i, r := range resp.ByQuantity {
if r.ProductID == prodHigh {
highIdx = i
}
if r.ProductID == prodLow {
lowIdx = i
}
if i > 0 && r.TotalOrders > resp.ByQuantity[i-1].TotalOrders {
t.Errorf("by_quantity doit être trié par total_orders décroissant: rupture à l'index %d", i)
}
}
if highIdx == -1 || lowIdx == -1 {
t.Fatalf("les deux produits de test doivent apparaître dans by_quantity: highIdx=%d lowIdx=%d", highIdx, lowIdx)
}
if highIdx >= lowIdx {
t.Errorf("le produit avec le plus de commandes doit apparaître avant: highIdx=%d lowIdx=%d", highIdx, lowIdx)
}
}
func TestOrdersAndRevenueByHour_CountsNonCancelledRevenueOnlyApproved(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_hour")
insertStatsCommand(t, username, "approved", "2024-01-08 14:00:00")
insertStatsCommand(t, username, "pending", "2024-01-08 14:30:00")
insertStatsCommand(t, username, "cancelled", "2024-01-08 14:45:00")
testDB.GDB.Exec(`UPDATE commandes SET total_prix = 25 WHERE username = ? AND status = 'approved'`, username)
var hourRows []models.HourRow
if err := testDB.OrdersAndRevenueByHour(&hourRows, testDB.ReadResetAt("stats_reset_heures_at")); err != nil {
t.Fatalf("OrdersAndRevenueByHour: %v", err)
}
var found bool
for _, r := range hourRows {
if r.Hour == 14 {
found = true
if r.Count != 2 {
t.Errorf("count à 14h doit exclure la commande annulée: got=%d want=2", r.Count)
}
if r.Revenue != 25 {
t.Errorf("revenue à 14h ne doit compter que les commandes approuvées: got=%.2f want=25", r.Revenue)
}
}
}
if !found {
t.Fatal("aucune ligne pour l'heure 14 trouvée")
}
}
@@ -0,0 +1,59 @@
package tests
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func statsSectionContext(section string) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/stats/reset", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Params = gin.Params{{Key: "section", Value: section}}
return c, rec
}
func TestResetAdminStats_RejectsInvalidSection(t *testing.T) {
c, rec := statsSectionContext("section-inexistante")
handlers.ResetAdminStats(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("section invalide doit retourner 400: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestResetAdminStats_SuccessResetsSection(t *testing.T) {
c, rec := statsSectionContext("commandes")
handlers.ResetAdminStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("reset d'une section valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"success":true`)) {
t.Errorf("réponse doit indiquer success: body=%s", rec.Body.String())
}
resetAt := testDB.ReadResetAt("stats_reset_commandes_at")
if resetAt.IsZero() {
t.Error("le timestamp de reset doit être renseigné après ResetAdminStats")
}
}
func TestGetMyDeliveryStats_RejectsNonLivreurRole(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/livreur/stats", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"stats_role")
c.Set("role", "client")
handlers.GetMyDeliveryStats(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
@@ -204,6 +204,33 @@ func TestDeleteCommandAtomic_DoesNotRefundAlreadyCancelledOrApprovedOrder(t *tes
}
}
// Régression: une commande "livrée" (marchandise déjà sortie du stock au
// checkout, remise au client) ne doit pas voir son stock remboursé lors
// d'une suppression — symétrique à CancelCommandByAdminAtomic qui exclut
// déjà "livre" de son remboursement.
func TestDeleteCommandAtomic_DoesNotRefundDeliveredOrder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delete_admin_livre")
productID := newTestProduct(t, "DeleteAdminLivre", 5)
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 4, 40)
if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil {
t.Fatalf("DeleteCommandAtomic: %v", err)
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas être remboursé pour une commande déjà livrée: got=%.2f want=5", got)
}
}
func TestDeleteCommandAtomic_UnknownCommandReturnsError(t *testing.T) {
cleanupStockTestData(t)
err := testDB.DeleteCommandAtomic(99999999, "admin_test", "admin")
if err == nil {
t.Fatal("DeleteCommandAtomic sur une commande inexistante doit retourner une erreur")
}
}
// ── Crypto (CancelCryptoCommand) ────────────────────────────────────────────
func TestCancelCryptoCommand_RefundsStockOnPendingPayment(t *testing.T) {
@@ -0,0 +1,428 @@
package tests
import (
"bytes"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
// ── Helpers ──────────────────────────────────────────────────────────────
// newTestCategory crée une catégorie de test et programme son nettoyage.
// CreateProduct/UpdateProduct passent la catégorie reçue par strings.ToLower
// avant de vérifier son existence : le nom doit donc déjà être en
// minuscules ici pour matcher.
func newTestCategory(t *testing.T, name string) string {
t.Helper()
fullName := strings.ToLower(testProductPrefix + name)
if _, err := testDB.CreateCategory(fullName, "#000000", false); err != nil {
t.Fatalf("CreateCategory %q: %v", fullName, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM categories WHERE name = ?`, fullName)
})
return fullName
}
// productCreateRequest construit une requête multipart minimale pour
// CreateProduct (un seul prix, pas de média).
func productCreateRequest(fields map[string]string) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
for k, v := range fields {
w.WriteField(k, v)
}
w.Close()
return body, w.FormDataContentType()
}
func productContext(role string, body *bytes.Buffer, contentType string, productID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"product_admin")
c.Set("role", role)
if productID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
}
return c, rec
}
func jsonStockContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products/stock", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"product_admin")
c.Set("role", role)
if productID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
}
return c, rec
}
// ── CreateProduct ────────────────────────────────────────────────────────
func TestCreateProduct_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CP1", "category": "x", "description": "d",
"stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("client", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
func TestCreateProduct_RejectsNegativeStock(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatNeg")
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPNeg", "category": cat, "description": "d",
"stock": "-5", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateProduct_RejectsStockOverMax(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatOver")
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPOver", "category": cat, "description": "d",
"stock": "1000001", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateProduct_RejectsUnknownCategory(t *testing.T) {
cleanupStockTestData(t)
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPCat", "category": "categorie-inexistante-xyz", "description": "d",
"stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("catégorie inconnue doit être rejetée: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateProduct_SuccessCreatesProductAndPrice(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatOk")
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPOk", "category": cat, "description": "d",
"stock": "42", "prices[0][quantity]": "1", "prices[0][price]": "9.99",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusCreated {
t.Fatalf("création produit valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE name = ?`, testProductPrefix+"CPOk").Scan(&count)
if count != 1 {
t.Errorf("le produit doit être créé en base: got=%d", count)
}
var priceCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM product_prices pp JOIN products p ON p.id = pp.product_id WHERE p.name = ?`, testProductPrefix+"CPOk").Scan(&priceCount)
if priceCount != 1 {
t.Errorf("le prix doit être créé en base: got=%d", priceCount)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name = ?)`, testProductPrefix+"CPOk")
testDB.GDB.Exec(`DELETE FROM products WHERE name = ?`, testProductPrefix+"CPOk")
})
}
// ── UpdateStock ──────────────────────────────────────────────────────────
func TestUpdateStock_RejectsNonAdmin(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USNonAdmin", 10)
c, rec := jsonStockContext("cabine", []byte(`{"stock":20}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role cabine doit être refusé pour UpdateStock: got=%d want=403", rec.Code)
}
}
func TestUpdateStock_RejectsNegativeStock(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USNeg", 10)
c, rec := jsonStockContext("admin", []byte(`{"stock":-1}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 10 {
t.Errorf("le stock ne doit pas changer après un rejet: got=%.2f want=10", got)
}
}
func TestUpdateStock_RejectsStockOverMax(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USOver", 10)
c, rec := jsonStockContext("admin", []byte(`{"stock":1000001}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400", rec.Code)
}
}
// Documente un bug existant plutôt que le comportement souhaité :
// GetProductByID fait un Raw(...).Scan() qui ne retourne PAS d'erreur
// quand aucune ligne ne matche (contrairement à First()), donc le check
// "produit non trouvé → 404" en tête de UpdateStock ne se déclenche
// jamais. La requête continue jusqu'à SetProductStock, qui échoue côté
// DB et remonte en 500 générique au lieu d'un 404 propre.
func TestUpdateStock_UnknownProductReturns500NotFoundBugDocumented(t *testing.T) {
cleanupStockTestData(t)
c, rec := jsonStockContext("admin", []byte(`{"stock":5}`), 99999999)
handlers.UpdateStock(c)
if rec.Code != http.StatusInternalServerError {
t.Errorf("comportement actuel (bug): produit inconnu retourne 500, pas 404: got=%d", rec.Code)
}
}
func TestUpdateStock_SuccessSetsExactValue(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USOk", 10)
c, rec := jsonStockContext("admin", []byte(`{"stock":33}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusOK {
t.Fatalf("mise à jour stock valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 33 {
t.Errorf("stock après UpdateStock: got=%.2f want=33", got)
}
}
// Documente le comportement actuel du garde-fou "réservé en panier" de
// UpdateStock : `req.Stock+reserved < reserved` est algébriquement
// équivalent à `req.Stock < 0`, donc ce garde-fou ne bloque en réalité
// jamais un stock positif inférieur à la quantité réservée. Un admin peut
// donc, aujourd'hui, mettre le stock en dessous du réservé en panier — ce
// test documente ce comportement pour éviter une régression silencieuse
// si quelqu'un "corrige" la formule sans le vouloir explicitement.
func TestUpdateStock_ReservedGuardDoesNotBlockStockBelowReserved(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "us_reserved")
productID := newTestProduct(t, "USReserved", 10)
if _, err := testDB.AddToBasket(username, productID, 8); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
// 8 sont réservés en panier ; on met le stock à 1, en dessous du réservé.
c, rec := jsonStockContext("admin", []byte(`{"stock":1}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusOK {
t.Fatalf("comportement actuel: la requête réussit malgré stock < réservé: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 1 {
t.Errorf("stock après UpdateStock: got=%.2f want=1", got)
}
}
// ── UpdateProduct ────────────────────────────────────────────────────────
func updateProductJSONContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"product_admin")
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
return c, rec
}
func TestUpdateProduct_UpdatesStockWhenProvided(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatUpd")
productID := newTestProduct(t, "UPStock", 5)
body := []byte(fmt.Sprintf(`{"name":"UPStock","category":%q,"description":"d2","unit":"kg","stock":77,"prices":[{"quantity":1,"price":5}]}`, cat))
c, rec := updateProductJSONContext("admin", body, productID)
handlers.UpdateProduct(c)
if rec.Code != http.StatusOK {
t.Fatalf("UpdateProduct valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 77 {
t.Errorf("stock après UpdateProduct: got=%.2f want=77", got)
}
}
// ── DeleteProduct ────────────────────────────────────────────────────────
func TestDeleteProduct_RemovesProductWithoutMedia(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "DelNoMedia", 3)
c, rec := productContext("admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", productID)
handlers.DeleteProduct(c)
if rec.Code != http.StatusOK {
t.Fatalf("suppression produit sans média doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE id = ?`, productID).Scan(&count)
if count != 0 {
t.Errorf("le produit doit être supprimé: got=%d lignes restantes", count)
}
}
// ── GetReservedQuantityInBaskets ─────────────────────────────────────────
func TestGetReservedQuantityInBaskets_SumsNonRewardQuantities(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "ResQty", 20)
c1 := newTestClient(t, "resqty_c1")
c2 := newTestClient(t, "resqty_c2")
if _, err := testDB.AddToBasket(c1, productID, 3); err != nil {
t.Fatalf("AddToBasket c1: %v", err)
}
if _, err := testDB.AddToBasket(c2, productID, 5); err != nil {
t.Fatalf("AddToBasket c2: %v", err)
}
reserved, err := testDB.GetReservedQuantityInBaskets(productID)
if err != nil {
t.Fatalf("GetReservedQuantityInBaskets: %v", err)
}
if reserved != 8 {
t.Errorf("réservé total: got=%.2f want=8", reserved)
}
}
// ── AddToBasket merge behavior ───────────────────────────────────────────
func TestAddToBasket_MergesIntoExistingNonRewardRow(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "merge_basket")
productID := newTestProduct(t, "MergeBasket", 20)
if _, err := testDB.AddToBasket(username, productID, 2); err != nil {
t.Fatalf("AddToBasket #1: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 3); err != nil {
t.Fatalf("AddToBasket #2: %v", err)
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&count)
if count != 1 {
t.Fatalf("les deux ajouts doivent fusionner en une seule ligne: got=%d lignes", count)
}
var qty float64
testDB.GDB.Raw(`SELECT quantity FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&qty)
if qty != 5 {
t.Errorf("quantité fusionnée: got=%.2f want=5", qty)
}
}
// ── Checkout validation (db.CreateCommandWithAddress) ───────────────────
func TestCreateCommandWithAddress_RejectsEmptyBasket(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_empty")
_, err := testDB.CreateCommandWithAddress(username, "1 rue vide")
if err == nil {
t.Fatal("checkout avec panier vide doit échouer")
}
}
func TestCreateCommandWithAddress_RejectsInvalidBasketItemData(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_invalid_item")
productID := newTestProduct(t, "CheckoutInvalidItem", 10)
// Insertion directe d'une ligne de panier avec quantité invalide,
// en contournant AddToBasket qui la rejetterait.
testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`,
username, productID, 0, 10,
)
_, err := testDB.CreateCommandWithAddress(username, "1 rue invalide")
if err == nil {
t.Fatal("checkout avec donnée panier invalide (quantité=0) doit échouer")
}
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
}
func TestCreateCommandWithAddress_RejectsTotalPriceOverMax(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_overmax")
productID := newTestProduct(t, "CheckoutOverMax", 10)
testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`,
username, productID, 1, 100001,
)
_, err := testDB.CreateCommandWithAddress(username, "1 rue trop cher")
if err == nil {
t.Fatal("checkout avec montant total > 100000 doit échouer")
}
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
}
// ── DeleteCommandItem ─────────────────────────────────────────────────────
func TestDeleteCommandItem_UpdatesCommandTotalPrix(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delitem_total")
productID := newTestProduct(t, "DelItemTotal", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
testDB.GDB.Exec(`UPDATE commandes SET total_prix = 20 WHERE id = ?`, cmdID)
var itemID int
testDB.GDB.Raw(`SELECT id FROM command_items WHERE command_id = ?`, cmdID).Scan(&itemID)
if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil {
t.Fatalf("DeleteCommandItem: %v", err)
}
var totalPrix float64
testDB.GDB.Raw(`SELECT total_prix FROM commandes WHERE id = ?`, cmdID).Scan(&totalPrix)
if totalPrix != 0 {
t.Errorf("total_prix après suppression du seul item: got=%.2f want=0", totalPrix)
}
}
func TestDeleteCommandItem_UnknownItemReturnsError(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delitem_unknown")
productID := newTestProduct(t, "DelItemUnknown", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
if err := testDB.DeleteCommandItem(cmdID, 99999999); err == nil {
t.Fatal("suppression d'un item inconnu doit retourner une erreur")
}
}
func TestDeleteCommandItem_UnknownCommandReturnsError(t *testing.T) {
cleanupStockTestData(t)
if err := testDB.DeleteCommandItem(99999999, 1); err == nil {
t.Fatal("suppression d'un item sur une commande inconnue doit retourner une erreur")
}
}