package tests import ( "encoding/json" "gestion/handlers" "net/http" "net/http/httptest" "strconv" "testing" "github.com/gin-gonic/gin" ) // Les clients ont signalé ne jamais voir le temps de livraison (notifications, // app mobile, site web). Ces tests reproduisent le chemin réel : une commande // est assignée automatiquement (le worker de queue appelle // SetCommandETAWithDetails, PAS SetCommandETA), puis un client consulte le // suivi de sa commande — exactement ce que fait l'app mobile / le site web. func etaTestContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) { req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/x/tracking", nil) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = req c.Set("database", testDB) c.Set("username", username) c.Set("role", "client") c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}} return c, rec } // SetCommandETAWithDetails est le chemin utilisé par le worker // d'auto-assignation/réoptimisation de queue (db/redis_queue_optimization.go, // db/redis_queue_assignment.go) — de loin le plus emprunté en production. // Il doit remplir "eta_minutes", pas seulement "total_eta_minutes", car // c'est le champ que l'app mobile et le site web lisent (confirmé dans // mobile/src/screens/client/OrderTrackingScreen.tsx et api.ts des deux // frontends). func TestSetCommandETAWithDetails_WritesEtaMinutesField(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "eta_details_field") productID := newTestProduct(t, "EtaDetailsField", 10) cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA", productID, 1, 10) if err := testDB.SetCommandETAWithDetails(cmdID, 22, 1); err != nil { t.Fatalf("SetCommandETAWithDetails: %v", err) } etaData, err := testDB.GetCommandETA(cmdID) if err != nil { t.Fatalf("GetCommandETA: %v", err) } got, ok := etaData["eta_minutes"] if !ok || got == "" { t.Errorf(`champ "eta_minutes" absent après SetCommandETAWithDetails (contenu: %v) — `+ `c'est le champ lu par le mobile et le site web, d'où l'absence de temps affiché`, etaData) } else if got != "22" { t.Errorf(`"eta_minutes" = %q, want "22"`, got) } } // Reproduction bout-en-bout du symptôme signalé : après une assignation // automatique (SetCommandETAWithDetails), le client consulte le suivi de sa // commande (GetCommandTracking, l'endpoint utilisé par l'app mobile et le // site web) — la réponse doit exposer eta.eta_minutes. func TestGetCommandTracking_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "eta_tracking_client") productID := newTestProduct(t, "EtaTrackingClient", 10) cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA2", productID, 1, 10) if err := testDB.SetCommandETAWithDetails(cmdID, 17, 2); err != nil { t.Fatalf("SetCommandETAWithDetails: %v", err) } c, rec := etaTestContext(username, cmdID) handlers.GetCommandTracking(c) if rec.Code != http.StatusOK { t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) } var resp struct { Success bool `json:"success"` ETA map[string]any `json:"eta"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String()) } etaMinutesRaw, ok := resp.ETA["eta_minutes"] if !ok { t.Fatalf(`la réponse de /tracking n'expose pas "eta.eta_minutes" (contenu eta: %v) — `+ `reproduit exactement le bug signalé par les clients`, resp.ETA) } etaMinutesStr, _ := etaMinutesRaw.(string) if got, _ := strconv.Atoi(etaMinutesStr); got != 17 { t.Errorf("eta.eta_minutes: got=%v want=17", etaMinutesRaw) } } // Même reproduction via GetCommandStatus (autre endpoint de suivi, utilisé // par l'app mobile pour le statut temps réel). func TestGetCommandStatus_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "eta_status_client") productID := newTestProduct(t, "EtaStatusClient", 10) cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA3", productID, 1, 10) if err := testDB.SetCommandETAWithDetails(cmdID, 9, 1); err != nil { t.Fatalf("SetCommandETAWithDetails: %v", err) } c, rec := etaTestContext(username, cmdID) handlers.GetCommandStatus(c) if rec.Code != http.StatusOK { t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) } var resp struct { ETA map[string]any `json:"eta"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("décodage réponse: %v", err) } if _, ok := resp.ETA["eta_minutes"]; !ok { t.Fatalf(`GetCommandStatus n'expose pas "eta.eta_minutes" (contenu: %v)`, resp.ETA) } } // SetCommandETA (chemin utilisé par UpdateDeliveryStatus côté livreur) doit // lui aussi rester lisible par les lecteurs qui attendent "total_eta_minutes" // (handlers/deleviry.go, validation_deleviry.go, geoloca.go). func TestSetCommandETA_AlsoWritesTotalEtaMinutesField(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "eta_simple_field") productID := newTestProduct(t, "EtaSimpleField", 10) cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA4", productID, 1, 10) if err := testDB.SetCommandETA(cmdID, 14); err != nil { t.Fatalf("SetCommandETA: %v", err) } etaData, err := testDB.GetCommandETA(cmdID) if err != nil { t.Fatalf("GetCommandETA: %v", err) } if got, ok := etaData["total_eta_minutes"]; !ok || got != "14" { t.Errorf(`"total_eta_minutes" = %q (présent=%v), want "14"`, got, ok) } if got, ok := etaData["eta_minutes"]; !ok || got != "14" { t.Errorf(`"eta_minutes" = %q (présent=%v), want "14"`, got, ok) } } // GetDeliverymanLocationForCommand (vue admin/cabine) lisait l'ETA via // Redis.Get sur une clé qui est en réalité un hash (HSet) — l'erreur // WRONGTYPE était silencieusement ignorée et etaMinutes restait toujours à 0. func TestGetDeliverymanLocationForCommand_ExposesEtaMinutes(t *testing.T) { cleanupStockTestData(t) username := newTestClient(t, "eta_admin_view_client") productID := newTestProduct(t, "EtaAdminView", 10) livreurUsername := testUserPrefix + "eta_admin_view_livreur" cmdID := newTestCommandWithItem(t, username, "en_route", livreurUsername, productID, 1, 10) if err := testDB.SetCommandETAWithDetails(cmdID, 12, 1); err != nil { t.Fatalf("SetCommandETAWithDetails: %v", err) } // Position GPS du livreur, requise par le handler avant de lire l'ETA. if err := testDB.UpdateDeliveryPersonLocation(livreurUsername, 47.2148, -1.5584); err != nil { t.Fatalf("UpdateDeliveryPersonLocation: %v", err) } req := httptest.NewRequest(http.MethodGet, "/x", nil) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = req c.Set("database", testDB) c.Set("username", "admin_test") c.Set("role", "admin") c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(cmdID)}} handlers.GetDeliverymanLocationForCommand(c) if rec.Code != http.StatusOK { t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) } var resp struct { Data struct { ETA struct { Minutes float64 `json:"minutes"` HasETA bool `json:"has_eta"` } `json:"eta"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String()) } if !resp.Data.ETA.HasETA { t.Errorf("has_eta devrait être true, ETA pourtant définie via SetCommandETAWithDetails") } if resp.Data.ETA.Minutes != 12 { t.Errorf("data.eta.minutes: got=%.0f want=12", resp.Data.ETA.Minutes) } }