From 734493363f049cd4d9dbb7a6a3419006d518d75a Mon Sep 17 00:00:00 2001 From: Nuxgrid Date: Tue, 14 Jul 2026 18:34:58 +0200 Subject: [PATCH] chore: build --- backend/gestion/db/db_cancel_command.go | 2 +- backend/gestion/handlers/product.go | 6 +- .../gestion/tests/commande_coverage_test.go | 256 +++++++++++ .../tests/commands_handlers_coverage_test.go | 222 +++++++++ backend/gestion/tests/eta_coverage_test.go | 194 ++++++++ backend/gestion/tests/eta_geo_calc_test.go | 79 ++++ backend/gestion/tests/eta_last_coords_test.go | 59 +++ .../gestion/tests/media_s3_coverage_test.go | 276 +++++++++++ backend/gestion/tests/stats_advanced_test.go | 162 +++++++ backend/gestion/tests/stats_coverage_test.go | 59 +++ backend/gestion/tests/stock_cancel_test.go | 27 ++ backend/gestion/tests/stock_coverage_test.go | 428 ++++++++++++++++++ mobile/App.tsx | 6 + mobile/src/api/api.ts | 38 ++ mobile/src/navigation/types.ts | 1 + mobile/src/screens/auth/ClientLoginScreen.tsx | 6 +- mobile/src/screens/auth/ClientTwoFAScreen.tsx | 200 ++++++++ 17 files changed, 2014 insertions(+), 7 deletions(-) create mode 100644 backend/gestion/tests/commande_coverage_test.go create mode 100644 backend/gestion/tests/commands_handlers_coverage_test.go create mode 100644 backend/gestion/tests/eta_coverage_test.go create mode 100644 backend/gestion/tests/eta_geo_calc_test.go create mode 100644 backend/gestion/tests/eta_last_coords_test.go create mode 100644 backend/gestion/tests/media_s3_coverage_test.go create mode 100644 backend/gestion/tests/stats_advanced_test.go create mode 100644 backend/gestion/tests/stats_coverage_test.go create mode 100644 backend/gestion/tests/stock_coverage_test.go create mode 100644 mobile/src/screens/auth/ClientTwoFAScreen.tsx diff --git a/backend/gestion/db/db_cancel_command.go b/backend/gestion/db/db_cancel_command.go index cbcd7abe..51f41487 100644 --- a/backend/gestion/db/db_cancel_command.go +++ b/backend/gestion/db/db_cancel_command.go @@ -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 diff --git a/backend/gestion/handlers/product.go b/backend/gestion/handlers/product.go index 47bf91d2..b279492e 100644 --- a/backend/gestion/handlers/product.go +++ b/backend/gestion/handlers/product.go @@ -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é") } diff --git a/backend/gestion/tests/commande_coverage_test.go b/backend/gestion/tests/commande_coverage_test.go new file mode 100644 index 00000000..c44eb851 --- /dev/null +++ b/backend/gestion/tests/commande_coverage_test.go @@ -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) + } +} diff --git a/backend/gestion/tests/commands_handlers_coverage_test.go b/backend/gestion/tests/commands_handlers_coverage_test.go new file mode 100644 index 00000000..23ef2091 --- /dev/null +++ b/backend/gestion/tests/commands_handlers_coverage_test.go @@ -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) + } +} diff --git a/backend/gestion/tests/eta_coverage_test.go b/backend/gestion/tests/eta_coverage_test.go new file mode 100644 index 00000000..63fcc28c --- /dev/null +++ b/backend/gestion/tests/eta_coverage_test.go @@ -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()) + } +} diff --git a/backend/gestion/tests/eta_geo_calc_test.go b/backend/gestion/tests/eta_geo_calc_test.go new file mode 100644 index 00000000..c9cd5f47 --- /dev/null +++ b/backend/gestion/tests/eta_geo_calc_test.go @@ -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) + } +} diff --git a/backend/gestion/tests/eta_last_coords_test.go b/backend/gestion/tests/eta_last_coords_test.go new file mode 100644 index 00000000..51c1058a --- /dev/null +++ b/backend/gestion/tests/eta_last_coords_test.go @@ -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) + } +} diff --git a/backend/gestion/tests/media_s3_coverage_test.go b/backend/gestion/tests/media_s3_coverage_test.go new file mode 100644 index 00000000..a0cfc25e --- /dev/null +++ b/backend/gestion/tests/media_s3_coverage_test.go @@ -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/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) + }) +} diff --git a/backend/gestion/tests/stats_advanced_test.go b/backend/gestion/tests/stats_advanced_test.go new file mode 100644 index 00000000..225b64b2 --- /dev/null +++ b/backend/gestion/tests/stats_advanced_test.go @@ -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") + } +} diff --git a/backend/gestion/tests/stats_coverage_test.go b/backend/gestion/tests/stats_coverage_test.go new file mode 100644 index 00000000..6f0bc3df --- /dev/null +++ b/backend/gestion/tests/stats_coverage_test.go @@ -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) + } +} diff --git a/backend/gestion/tests/stock_cancel_test.go b/backend/gestion/tests/stock_cancel_test.go index 1d29d54a..5082f947 100644 --- a/backend/gestion/tests/stock_cancel_test.go +++ b/backend/gestion/tests/stock_cancel_test.go @@ -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) { diff --git a/backend/gestion/tests/stock_coverage_test.go b/backend/gestion/tests/stock_coverage_test.go new file mode 100644 index 00000000..678b2b05 --- /dev/null +++ b/backend/gestion/tests/stock_coverage_test.go @@ -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") + } +} diff --git a/mobile/App.tsx b/mobile/App.tsx index a6bcfd2b..e4726345 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -12,6 +12,7 @@ import { ThemeProvider, useTheme } from "./src/context/ThemeContext"; import HomeScreen from "./src/screens/HomeScreen"; import ClientLoginScreen from "./src/screens/auth/ClientLoginScreen"; +import ClientTwoFAScreen from "./src/screens/auth/ClientTwoFAScreen"; import ChangePasswordScreen from "./src/screens/auth/ChangePasswordScreen"; import ClientNavigator from "./src/navigation/ClientNavigator"; import type { RootStackParamList, ChangePasswordStackParamList } from "./src/navigation/types"; @@ -39,6 +40,11 @@ function AuthStack() { component={ClientLoginScreen} options={{ title: "Connexion" }} /> + ); } diff --git a/mobile/src/api/api.ts b/mobile/src/api/api.ts index c15b050e..d63c1efd 100644 --- a/mobile/src/api/api.ts +++ b/mobile/src/api/api.ts @@ -27,6 +27,8 @@ export interface AuthResponse { access_token?: string; token_type?: string; expires_in?: number; + requires_2fa?: boolean; + session_token?: string; user?: { id: number; username: string; @@ -48,6 +50,13 @@ export const loginUser = async ( username, password, }); + if (data.requires_2fa) { + return { + success: false, + requires_2fa: true, + session_token: data.session_token, + }; + } if (!data.access_token) { return { success: false, message: "Token non reçu du serveur" }; } @@ -68,6 +77,35 @@ export const loginUser = async ( } }; +export const verifyClient2FA = async ( + sessionToken: string, + code: string, +): Promise => { + try { + const { data } = await apiClient.post(`${V1}/auth/2fa/verify`, { + session_token: sessionToken, + code, + }); + if (!data.access_token) { + return { success: false, message: "Token non reçu du serveur" }; + } + return { + success: true, + message: "Connexion réussie", + access_token: data.access_token, + token_type: data.token_type, + expires_in: data.expires_in, + user: data.user, + }; + } catch (error: any) { + const msg = + error.response?.data?.error || + error.message || + "Code invalide"; + return { success: false, message: msg }; + } +}; + export const registerUser = async ( username: string, password: string, diff --git a/mobile/src/navigation/types.ts b/mobile/src/navigation/types.ts index 7817aca7..73f09575 100644 --- a/mobile/src/navigation/types.ts +++ b/mobile/src/navigation/types.ts @@ -3,6 +3,7 @@ export type RootStackParamList = { login: undefined; register: undefined; role: undefined; + clientTwoFA: { sessionToken: string }; }; export type ChangePasswordStackParamList = { diff --git a/mobile/src/screens/auth/ClientLoginScreen.tsx b/mobile/src/screens/auth/ClientLoginScreen.tsx index 020ea82b..0b76272e 100644 --- a/mobile/src/screens/auth/ClientLoginScreen.tsx +++ b/mobile/src/screens/auth/ClientLoginScreen.tsx @@ -61,7 +61,11 @@ const LoginClient = () => { formData.username, formData.password, ); - if (result.success && result.access_token) { + if (result.requires_2fa && result.session_token) { + navigation.navigate("clientTwoFA", { + sessionToken: result.session_token, + }); + } else if (result.success && result.access_token) { await loginClient(result.access_token, result.user?.must_change_password ?? false); } else { const errorMessage = diff --git a/mobile/src/screens/auth/ClientTwoFAScreen.tsx b/mobile/src/screens/auth/ClientTwoFAScreen.tsx new file mode 100644 index 00000000..4ffc57d5 --- /dev/null +++ b/mobile/src/screens/auth/ClientTwoFAScreen.tsx @@ -0,0 +1,200 @@ +import React, { useState, useMemo } from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + ActivityIndicator, + StyleSheet, + KeyboardAvoidingView, + ScrollView, +} from "react-native"; +import { Feather } from "@expo/vector-icons"; +import { verifyClient2FA } from "../../api/api"; +import { useAuth } from "../../auth/AuthContext"; +import { useNavigation, useRoute, type RouteProp } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import type { RootStackParamList } from "../../navigation/types"; +import { useTheme } from "../../context/ThemeContext"; + +type TwoFANavigationProp = NativeStackNavigationProp< + RootStackParamList, + "clientTwoFA" +>; +type TwoFARouteProp = RouteProp; + +const ClientTwoFAScreen = () => { + const navigation = useNavigation(); + const route = useRoute(); + const { sessionToken } = route.params; + const { loginClient } = useAuth(); + const { colors } = useTheme(); + + const [code, setCode] = useState(""); + const [apiError, setApiError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async () => { + if (code.trim().length !== 6) { + setApiError("Le code doit contenir 6 chiffres"); + return; + } + setIsLoading(true); + setApiError(""); + try { + const result = await verifyClient2FA(sessionToken, code.trim()); + if (result.success && result.access_token) { + await loginClient(result.access_token, result.user?.must_change_password ?? false); + } else { + setApiError(result.message || "Code invalide"); + } + } catch (err) { + const message = + err instanceof Error ? err.message : "Erreur de connexion"; + setApiError(message); + } finally { + setIsLoading(false); + } + }; + + const handleChangeCode = (value: string) => { + const digitsOnly = value.replace(/[^0-9]/g, "").slice(0, 6); + setCode(digitsOnly); + if (apiError) setApiError(""); + }; + + const styles = useMemo( + () => + StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bgSecondary }, + scrollContent: { + flexGrow: 1, + justifyContent: "center", + alignItems: "center", + padding: 16, + }, + card: { + width: "100%", + maxWidth: 400, + borderRadius: 16, + padding: 24, + backgroundColor: colors.bgPrimary, + }, + header: { alignItems: "center", marginBottom: 24 }, + title: { + fontSize: 24, + fontWeight: "bold", + marginTop: 8, + color: colors.textPrimary, + }, + subtitle: { + fontSize: 14, + marginTop: 4, + textAlign: "center", + color: colors.textMuted, + }, + apiError: { + backgroundColor: "#fee2e2", + color: "#991b1b", + padding: 12, + borderRadius: 6, + marginBottom: 16, + textAlign: "center", + }, + inputGroup: { marginBottom: 16 }, + label: { marginBottom: 4, fontWeight: "500", color: colors.textSecondary }, + inputWrapper: { + flexDirection: "row", + alignItems: "center", + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 12, + borderColor: colors.border, + backgroundColor: colors.bgInput, + }, + icon: { marginRight: 8 }, + input: { + flex: 1, + height: 48, + color: colors.textPrimary, + fontSize: 20, + letterSpacing: 8, + textAlign: "center", + }, + submitButton: { + backgroundColor: colors.accent, + borderRadius: 8, + padding: 12, + alignItems: "center", + marginTop: 8, + }, + submitText: { color: colors.white, fontWeight: "600", fontSize: 16 }, + backButton: { alignItems: "center", marginTop: 16 }, + backText: { color: colors.textMuted, fontSize: 14 }, + }), + [colors], + ); + + return ( + + + + + + Vérification en deux étapes + + Entrez le code à 6 chiffres envoyé sur Telegram + + + + {apiError ? ( + ⚠️ {apiError} + ) : null} + + + Code de vérification + + + + + + + {isLoading ? ( + + ) : ( + Vérifier + )} + + + navigation.goBack()} + disabled={isLoading} + > + Retour à la connexion + + + + + ); +}; + +export default ClientTwoFAScreen;