chore: build
This commit is contained in:
@@ -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)
|
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
|
||||||
|
|
||||||
// ✅ Ne restitue le stock QUE si pas déjà fait
|
// ✅ 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 !stockAlreadyRestored {
|
||||||
if err := tx.Exec(`
|
if err := tx.Exec(`
|
||||||
UPDATE products p
|
UPDATE products p
|
||||||
|
|||||||
@@ -145,11 +145,7 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
|||||||
func sanitizeFilePath(path string) (string, error) {
|
func sanitizeFilePath(path string) (string, error) {
|
||||||
cleaned := filepath.Clean(path)
|
cleaned := filepath.Clean(path)
|
||||||
|
|
||||||
if strings.Contains(cleaned, "..") || strings.Contains(cleaned, ".") {
|
if strings.Contains(cleaned, "..") {
|
||||||
return "", fmt.Errorf("path traversal détecté")
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(cleaned, "//..//") || strings.Contains(cleaned, "/../") {
|
|
||||||
return "", fmt.Errorf("path traversal détecté")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) ────────────────────────────────────────────
|
// ── Crypto (CancelCryptoCommand) ────────────────────────────────────────────
|
||||||
|
|
||||||
func TestCancelCryptoCommand_RefundsStockOnPendingPayment(t *testing.T) {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { ThemeProvider, useTheme } from "./src/context/ThemeContext";
|
|||||||
|
|
||||||
import HomeScreen from "./src/screens/HomeScreen";
|
import HomeScreen from "./src/screens/HomeScreen";
|
||||||
import ClientLoginScreen from "./src/screens/auth/ClientLoginScreen";
|
import ClientLoginScreen from "./src/screens/auth/ClientLoginScreen";
|
||||||
|
import ClientTwoFAScreen from "./src/screens/auth/ClientTwoFAScreen";
|
||||||
import ChangePasswordScreen from "./src/screens/auth/ChangePasswordScreen";
|
import ChangePasswordScreen from "./src/screens/auth/ChangePasswordScreen";
|
||||||
import ClientNavigator from "./src/navigation/ClientNavigator";
|
import ClientNavigator from "./src/navigation/ClientNavigator";
|
||||||
import type { RootStackParamList, ChangePasswordStackParamList } from "./src/navigation/types";
|
import type { RootStackParamList, ChangePasswordStackParamList } from "./src/navigation/types";
|
||||||
@@ -39,6 +40,11 @@ function AuthStack() {
|
|||||||
component={ClientLoginScreen}
|
component={ClientLoginScreen}
|
||||||
options={{ title: "Connexion" }}
|
options={{ title: "Connexion" }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="clientTwoFA"
|
||||||
|
component={ClientTwoFAScreen}
|
||||||
|
options={{ title: "Vérification" }}
|
||||||
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface AuthResponse {
|
|||||||
access_token?: string;
|
access_token?: string;
|
||||||
token_type?: string;
|
token_type?: string;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
|
requires_2fa?: boolean;
|
||||||
|
session_token?: string;
|
||||||
user?: {
|
user?: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -48,6 +50,13 @@ export const loginUser = async (
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
if (data.requires_2fa) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
requires_2fa: true,
|
||||||
|
session_token: data.session_token,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!data.access_token) {
|
if (!data.access_token) {
|
||||||
return { success: false, message: "Token non reçu du serveur" };
|
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<AuthResponse> => {
|
||||||
|
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 (
|
export const registerUser = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type RootStackParamList = {
|
|||||||
login: undefined;
|
login: undefined;
|
||||||
register: undefined;
|
register: undefined;
|
||||||
role: undefined;
|
role: undefined;
|
||||||
|
clientTwoFA: { sessionToken: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChangePasswordStackParamList = {
|
export type ChangePasswordStackParamList = {
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ const LoginClient = () => {
|
|||||||
formData.username,
|
formData.username,
|
||||||
formData.password,
|
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);
|
await loginClient(result.access_token, result.user?.must_change_password ?? false);
|
||||||
} else {
|
} else {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
|
|||||||
@@ -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<RootStackParamList, "clientTwoFA">;
|
||||||
|
|
||||||
|
const ClientTwoFAScreen = () => {
|
||||||
|
const navigation = useNavigation<TwoFANavigationProp>();
|
||||||
|
const route = useRoute<TwoFARouteProp>();
|
||||||
|
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 (
|
||||||
|
<KeyboardAvoidingView style={styles.container} behavior="padding">
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Feather name="shield" size={48} color={colors.accent} />
|
||||||
|
<Text style={styles.title}>Vérification en deux étapes</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Entrez le code à 6 chiffres envoyé sur Telegram
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{apiError ? (
|
||||||
|
<Text style={styles.apiError}>⚠️ {apiError}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.inputGroup}>
|
||||||
|
<Text style={styles.label}>Code de vérification</Text>
|
||||||
|
<View style={styles.inputWrapper}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="000000"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
value={code}
|
||||||
|
onChangeText={handleChangeCode}
|
||||||
|
editable={!isLoading}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
maxLength={6}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitButton, isLoading && { opacity: 0.6 }]}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color={colors.white} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.submitText}>Vérifier</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => navigation.goBack()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.backText}>Retour à la connexion</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientTwoFAScreen;
|
||||||
Reference in New Issue
Block a user