chore: build
Backend - Build & Lint / build (push) Canceled after 29m7s

This commit is contained in:
Xor290
2026-08-18 20:02:59 +02:00
parent ef7166c2dd
commit e82ad3ae4d
7 changed files with 641 additions and 17 deletions
+15 -3
View File
@@ -892,13 +892,25 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
return fmt.Errorf("commande non trouvée")
}
if cmd.Status != "livre" {
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
// Historiquement restreint à "livre" seul (cf. commentaire de
// TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un
// incident réel où une vérification GPS en amont (coordonnées de
// destination périmées après un changement d'adresse, cf.
// updateCommandDestinationCoords) a bloqué la transition du livreur
// vers "livre" : la commande restait alors coincée, sans qu'admin ni
// cabine ne puissent confirmer la réception. On accepte désormais tout
// statut non terminal ("arrived" inclus), à l'image de
// ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route),
// pour que le staff garde toujours un moyen de débloquer une commande
// légitime indépendamment d'un blocage en amont côté livreur.
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"}
if !slices.Contains(validStatuses, cmd.Status) {
return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status)
}
result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = 'livre'`, commandID)
WHERE id = ? AND status = ?`, commandID, cmd.Status)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
+18 -8
View File
@@ -145,11 +145,19 @@ type DailyMonthStatRow struct {
Quantity float64
}
func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetAt time.Time) error {
// StatsByDayForMonth applique resetCommandes au comptage (count) et à la
// quantité (quantity, qui reflète le volume de commandes comme count), et
// resetRevenus au revenu (revenue) — chaque métrique doit respecter la même
// section de reset que son équivalent dans le résumé global (TotalOrders /
// TotalRevenue), sous peine d'afficher des chiffres incohérents entre eux
// après une réinitialisation partielle.
func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetCommandes time.Time, resetRevenus time.Time) error {
start := time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
end := start.AddDate(0, 1, 0)
where, whereArgs := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
whereCount, argsCount := statusFilterClause("status != 'cancelled'", resetCommandes, "created_at")
whereRevenue, argsRevenue := statusFilterClause("status = 'approved'", resetRevenus, "created_at")
whereQuantity, argsQuantity := statusFilterClause("c.status != 'cancelled'", resetCommandes, "c.created_at")
query := `
SELECT
@@ -161,7 +169,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= ? AND created_at < ?
AND ` + where + `
AND ` + whereCount + `
GROUP BY DATE(created_at)
) d
LEFT JOIN (
@@ -169,7 +177,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= ? AND created_at < ?
AND status = 'approved'
AND ` + whereRevenue + `
GROUP BY DATE(created_at)
) rv ON rv.day = d.day
LEFT JOIN (
@@ -177,18 +185,20 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
FROM commandes c
JOIN command_items ci ON ci.command_id = c.id
WHERE c.created_at >= ? AND c.created_at < ?
AND c.status != 'cancelled'
AND ` + whereQuantity + `
GROUP BY DATE(c.created_at)
) qt ON qt.day = d.day
ORDER BY d.day
`
// Ordre des "?" dans la requête : (start, end, [reset]) pour le bloc "d",
// puis (start, end) pour "rv", puis (start, end) pour "qt".
// Ordre des "?" dans la requête : (start, end, [resetCommandes]) pour "d",
// puis (start, end, [resetRevenus]) pour "rv", puis (start, end, [resetCommandes]) pour "qt".
args := []interface{}{start, end}
args = append(args, whereArgs...)
args = append(args, argsCount...)
args = append(args, start, end)
args = append(args, argsRevenue...)
args = append(args, start, end)
args = append(args, argsQuantity...)
return d.GDB.Raw(query, args...).Scan(rows).Error
}
+58
View File
@@ -73,8 +73,47 @@ func validateAddress(address string) error {
return nil
}
// updateCommandDestinationCoords regéocode l'adresse et met à jour
// dest_latitude/dest_longitude après tout changement d'adresse de livraison.
// Sans cet appel, ces coordonnées restent celles de l'ANCIENNE adresse
// (géocodées une seule fois à l'assignation) : la vérification GPS de
// handlers/deleviry.go compare alors la position réelle du livreur à un point
// périmé et peut refuser à tort une validation "trop loin de la destination"
// alors que le livreur est bien arrivé à la nouvelle adresse. En cas d'échec
// de géocodage, on réinitialise les coordonnées plutôt que de laisser
// l'ancienne valeur périmée : le contrôle GPS est alors ignoré (comportement
// déjà prévu quand dest_latitude/dest_longitude sont absentes) au lieu de
// bloquer sur un point qui ne correspond plus à l'adresse réelle.
func updateCommandDestinationCoords(database *db.Database, geoService *services.GeoService, commandID int, address string) {
if geoService == nil || strings.TrimSpace(address) == "" {
return
}
location, err := geoService.GeocodeAddress(address)
if err != nil || location == nil {
log.Printf("⚠️ [ADDR_GEOCODE] Échec géocodage cmd %d (%q): %v — coordonnées de destination réinitialisées", commandID, address, err)
if err := database.GDB.Exec(
`UPDATE commandes SET dest_latitude = NULL, dest_longitude = NULL WHERE id = ?`,
commandID,
).Error; err != nil {
log.Printf("⚠️ [ADDR_GEOCODE] Erreur reset coordonnées cmd %d: %v", commandID, err)
}
return
}
if err := database.GDB.Exec(
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
location.Latitude, location.Longitude, commandID,
).Error; err != nil {
log.Printf("⚠️ [ADDR_GEOCODE] Erreur mise à jour coordonnées cmd %d: %v", commandID, err)
return
}
log.Printf("✅ [ADDR_GEOCODE] Coordonnées de destination mises à jour pour cmd %d", commandID)
}
func UpdateCommandAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if !utils.CheckRoleAdmin(c, userRole) {
@@ -138,6 +177,8 @@ func UpdateCommandAddress(c *gin.Context) {
return
}
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
database.AddCommandLog(commandID, "address_updated",
fmt.Sprintf("Adresse mise à jour par admin %s", adminUsername),
adminUsername)
@@ -220,6 +261,7 @@ func ProposeAddressChange(c *gin.Context) {
// POST /api/v1/commands/:id/address/respond
func RespondToAddressProposal(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if !utils.CheckRoleClient(c, userRole) {
@@ -246,11 +288,25 @@ func RespondToAddressProposal(c *gin.Context) {
return
}
// La colonne proposed_address est vidée par RespondToAddressProposal dès
// qu'elle est traitée : on la lit avant l'appel pour pouvoir regéocoder la
// nouvelle adresse en cas d'acceptation.
var proposedAddress string
if req.Accepted {
if command, err := database.GetCommandByID(commandID); err == nil {
proposedAddress, _ = command["proposed_address"].(string)
}
}
if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil {
utils.ServerErr(c, "Impossible de traiter la réponse", err)
return
}
if req.Accepted && proposedAddress != "" {
updateCommandDestinationCoords(database, geoService, commandID, proposedAddress)
}
action := "refusée"
if req.Accepted {
action = "acceptée"
@@ -312,6 +368,8 @@ func UpdateOwnCommandAddress(c *gin.Context) {
return
}
updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
log.Printf("✅ [UPD_OWN_ADDR] Commande %d mise à jour par %s", commandID, clientUsername)
c.JSON(http.StatusOK, gin.H{
"success": true,
+2 -2
View File
@@ -70,10 +70,10 @@ func GetAdminStatsByMonth(c *gin.Context) {
}
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
resetCmd := database.ReadResetAt("stats_reset_commandes_at")
filters := database.LoadAdminStatsFilters()
var rows []db.DailyMonthStatRow
if err := database.StatsByDayForMonth(&rows, monthStart, resetCmd); err != nil {
if err := database.StatsByDayForMonth(&rows, monthStart, filters.ResetCommandes, filters.ResetRevenus); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)})
return
}
@@ -220,3 +220,162 @@ func TestGetCommandItemsWithDetails_NoItemsReturns404(t *testing.T) {
t.Errorf("commande sans item doit retourner 404: got=%d", rec.Code)
}
}
// ── Régression : les changements d'adresse doivent regéocoder dest_latitude/
// dest_longitude ──────────────────────────────────────────────────────────
//
// Incident réel : ces coordonnées n'étaient géocodées qu'une seule fois, à
// l'assignation du livreur. Une correction d'adresse ultérieure ne les
// touchait pas, si bien que la vérification GPS de confirmation de livraison
// (handlers/deleviry.go) comparait la position réelle du livreur à un point
// périmé et pouvait refuser à tort une validation "trop loin de la
// destination" alors que le livreur était bien arrivé à la nouvelle adresse.
// Ces tests appellent le vrai service de géocodage (Nominatim) — sautés en
// mode -short, comme TestResolveAddress_RealNantesAddresses.
// staleDestCoords sont volontairement celles de Paris : n'importe quelle
// adresse de test à Nantes en est assez éloignée pour distinguer un vrai
// regéocodage d'une valeur restée périmée.
const (
staleDestLat = 48.8566
staleDestLon = 2.3522
)
func seedStaleDestCoords(t *testing.T, commandID int) {
t.Helper()
if err := testDB.GDB.Exec(
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
staleDestLat, staleDestLon, commandID,
).Error; err != nil {
t.Fatalf("seedStaleDestCoords: %v", err)
}
}
type destCoords struct {
DestLatitude float64 `gorm:"column:dest_latitude"`
DestLongitude float64 `gorm:"column:dest_longitude"`
}
func getDestCoords(t *testing.T, commandID int) destCoords {
t.Helper()
var c destCoords
if err := testDB.GDB.Raw(
`SELECT COALESCE(dest_latitude, 0) AS dest_latitude, COALESCE(dest_longitude, 0) AS dest_longitude
FROM commandes WHERE id = ?`, commandID,
).Scan(&c).Error; err != nil {
t.Fatalf("getDestCoords: %v", err)
}
return c
}
func TestUpdateCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
cleanupStockTestData(t)
username := newTestClient(t, "upd_addr_regeo")
productID := newTestProduct(t, "UpdAddrRegeo", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
seedStaleDestCoords(t, cmdID)
body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`)
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/commands/address", 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+"upd_addr_regeo_admin")
c.Set("role", "admin")
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
handlers.UpdateCommandAddress(c)
if rec.Code != http.StatusOK {
t.Fatalf("UpdateCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
coords := getDestCoords(t, cmdID)
if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après changement d'adresse, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
}
if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
t.Errorf("le géocodage de la nouvelle adresse a échoué (coordonnées à 0): got=%+v", coords)
}
}
func TestRespondToAddressProposal_Handler_AcceptedRegeocodesStaleDestinationCoords(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_regeo")
productID := newTestProduct(t, "RespondAddrRegeo", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
seedStaleDestCoords(t, cmdID)
if err := testDB.ProposeAddressChange(cmdID, "12 rue Crebillon, 44000 Nantes", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
body := []byte(`{"accepted":true}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/commands/address/respond", 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", username)
c.Set("role", "client")
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
handlers.RespondToAddressProposal(c)
if rec.Code != http.StatusOK {
t.Fatalf("RespondToAddressProposal doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
coords := getDestCoords(t, cmdID)
if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après acceptation de la proposition, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
}
if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
t.Errorf("le géocodage de l'adresse acceptée a échoué (coordonnées à 0): got=%+v", coords)
}
}
func TestUpdateOwnCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
cleanupStockTestData(t)
username := newTestClient(t, "upd_own_addr_regeo")
productID := newTestProduct(t, "UpdOwnAddrRegeo", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
seedStaleDestCoords(t, cmdID)
body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`)
req := httptest.NewRequest(http.MethodPut, "/api/v1/commands/address", 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", username)
c.Set("role", "client")
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
handlers.UpdateOwnCommandAddress(c)
if rec.Code != http.StatusOK {
t.Fatalf("UpdateOwnCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
coords := getDestCoords(t, cmdID)
if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après auto-correction, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
}
if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
t.Errorf("le géocodage de l'adresse corrigée a échoué (coordonnées à 0): got=%+v", coords)
}
}
@@ -10,9 +10,16 @@ import (
// ── ApproveDeliveryAtomicByStaff : confirmation de réception par admin/cabine
// à la place du client. Contrairement au chemin client (ApproveDeliveryAtomic),
// aucune vérification de propriétaire n'est faite ici (le staff agit au nom
// du client) — mais la contrainte de statut ('livre' uniquement) est
// identique, et la double-approbation renvoie une VRAIE erreur (pas un no-op
// silencieux comme côté client).
// du client) — et la double-approbation renvoie une VRAIE erreur (pas un
// no-op silencieux comme côté client).
//
// La contrainte de statut a été volontairement élargie (n'exige plus 'livre'
// seul) après un incident où un blocage GPS en amont (coordonnées de
// destination périmées) empêchait le livreur d'atteindre 'livre', laissant le
// staff sans recours pour confirmer une commande par ailleurs légitime — voir
// db.ApproveDeliveryAtomicByStaff. Tout statut non terminal est accepté — y
// compris "arrived", que ValidateDeliveryAtomic (validation admin en masse,
// non modifiée) continue lui de rejeter.
func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing.T) {
cleanupStockTestData(t)
@@ -41,13 +48,44 @@ func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing.
}
}
func TestApproveDeliveryAtomicByStaff_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) {
// Tout statut non terminal doit permettre au staff de confirmer la
// réception — y compris avant "livre" (voir le commentaire de section
// ci-dessus pour le contexte de cet élargissement volontaire).
func TestApproveDeliveryAtomicByStaff_AcceptsAnyNonTerminalStatus_CreditsPoints(t *testing.T) {
cleanupStockTestData(t)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
for _, status := range []string{"pending", "assigned", "en_route", "arrived"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "staff_accept_"+status)
productID := newTestProduct(t, "StaffAccept"+status, 20)
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err != nil {
t.Fatalf("ApproveDeliveryAtomicByStaff depuis le statut %q: %v", status, err)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points depuis statut %q: got=%d want=6", status, got)
}
if got := commandStatus(t, cmdID); got != "approved" {
t.Errorf("statut final depuis %q: got=%s want=approved", status, got)
}
})
}
}
// Les statuts réellement terminaux (annulée, désactivée) restent, eux,
// rejetés : l'élargissement de la règle ne doit pas permettre de "confirmer
// la réception" d'une commande qui ne peut plus en avoir une.
func TestApproveDeliveryAtomicByStaff_RejectsTerminalStatus_NoPointsCredited(t *testing.T) {
cleanupStockTestData(t)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
for _, status := range []string{"cancelled", "disabled"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "staff_reject_"+status)
productID := newTestProduct(t, "StaffReject"+status, 20)
@@ -0,0 +1,347 @@
package tests
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"gestion/db"
"gestion/handlers"
"gestion/models"
"github.com/gin-gonic/gin"
)
// ── Régression : cohérence des resets par section dans la vue mensuelle ────
//
// Les tests ci-dessous couvrent la vue mensuelle (StatsByDayForMonth /
// GetAdminStatsByMonth), qui a longtemps ignoré le filtre de reset pour le
// revenu et la quantité (seul le comptage de commandes le respectait) et
// n'appliquait jamais la section "revenus" (contrairement au résumé global
// GetAdminStats, qui utilise filters.ResetRevenus pour TotalRevenue). Depuis
// le correctif, count/quantity suivent la section "commandes" et revenue suit
// sa propre section "revenus", exactement comme TotalOrders/TotalRevenue.
func monthStatsContext(month string) (*gin.Context, *httptest.ResponseRecorder) {
target := "/api/v1/admin/stats/month"
if month != "" {
target += "?month=" + month
}
req := httptest.NewRequest(http.MethodGet, target, nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
return c, rec
}
type monthStatsResponse struct {
Summary struct {
TotalOrders int `json:"total_orders"`
TotalRevenue float64 `json:"total_revenue"`
TotalQuantity float64 `json:"total_quantity"`
} `json:"summary"`
}
func getMonthStats(t *testing.T, month string) monthStatsResponse {
t.Helper()
c, rec := monthStatsContext(month)
handlers.GetAdminStatsByMonth(c)
if rec.Code != http.StatusOK {
t.Fatalf("GetAdminStatsByMonth doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp monthStatsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("réponse JSON invalide: %v", err)
}
return resp
}
// count et quantity doivent respecter le reset de la section "commandes" —
// tandis que revenue, lui, reste indépendant de cette section et ne doit
// bouger que lorsque la section "revenus" est réinitialisée séparément
// (même principe d'indépendance des sections que TestResetAdminStat_DifferentSectionsAreIndependent).
func TestStatsByDayForMonth_CountAndQuantityFollowCommandesReset_RevenueFollowsRevenusReset(t *testing.T) {
cleanupStockTestData(t)
cleanupStatsResetKey(t, statsResetTestKey) // "stats_reset_commandes_at"
cleanupStatsResetKey(t, "stats_reset_revenus_at")
username := newTestClient(t, "stats_month_reset_sections")
productID := newTestProduct(t, "StatsMonthResetSections", 100)
// .UTC() est indispensable ici : ResetAdminStat stocke sa marque en UTC, et
// newTestOrderForStats lie created_at comme paramètre (contrairement à
// newTestCommandWithItem qui utilise NOW() côté SQL) — sur une colonne
// "timestamp without time zone", le driver écrit les composants d'horloge
// tels quels sans conversion. Sans .UTC() explicite, la comparaison avec
// resetAt dépend du fuseau horaire local de la machine qui exécute les tests.
newTestOrderForStats(t, username, "approved", productID, 5, 100, 0, time.Now().UTC()) // avant tout reset : qty=5, revenue=100
time.Sleep(1100 * time.Millisecond) // marge : reset stocké sans fraction de seconde (RFC3339)
if err := testDB.ResetAdminStat(statsResetTestKey); err != nil { // reset "commandes"
t.Fatalf("ResetAdminStat(commandes): %v", err)
}
time.Sleep(1100 * time.Millisecond)
newTestOrderForStats(t, username, "approved", productID, 2, 50, 0, time.Now().UTC()) // après reset commandes, avant reset revenus : qty=2, revenue=50
resetCommandes := testDB.ReadResetAt(statsResetTestKey)
if resetCommandes.IsZero() {
t.Fatal("ReadResetAt(commandes) ne doit pas être zero")
}
var rows []db.DailyMonthStatRow
if err := testDB.StatsByDayForMonth(&rows, time.Now(), resetCommandes, time.Time{}); err != nil {
t.Fatalf("StatsByDayForMonth: %v", err)
}
if len(rows) != 1 {
t.Fatalf("les deux commandes sont le même jour, une seule ligne attendue: got=%d", len(rows))
}
r := rows[0]
if r.Count != 1 {
t.Errorf("count doit exclure la commande d'avant le reset commandes: got=%d want=1", r.Count)
}
if r.Quantity != 2 {
t.Errorf("quantity doit suivre le même reset (commandes) que count: got=%.2f want=2", r.Quantity)
}
// La section "revenus" n'a pas encore été réinitialisée : revenue reste
// indépendant du reset "commandes" et inclut donc les deux commandes.
if r.Revenue != 150 {
t.Errorf("revenue ne doit pas être affecté par le reset de la section commandes (indépendance des sections): got=%.2f want=150", r.Revenue)
}
time.Sleep(1100 * time.Millisecond)
if err := testDB.ResetAdminStat("stats_reset_revenus_at"); err != nil { // reset "revenus"
t.Fatalf("ResetAdminStat(revenus): %v", err)
}
time.Sleep(1100 * time.Millisecond)
newTestOrderForStats(t, username, "approved", productID, 1, 20, 0, time.Now().UTC()) // après reset revenus : qty=1, revenue=20
resetRevenus := testDB.ReadResetAt("stats_reset_revenus_at")
if resetRevenus.IsZero() {
t.Fatal("ReadResetAt(revenus) ne doit pas être zero")
}
rows = nil
if err := testDB.StatsByDayForMonth(&rows, time.Now(), resetCommandes, resetRevenus); err != nil {
t.Fatalf("StatsByDayForMonth (2e appel): %v", err)
}
if len(rows) != 1 {
t.Fatalf("les trois commandes sont le même jour, une seule ligne attendue: got=%d", len(rows))
}
r = rows[0]
if r.Count != 2 {
t.Errorf("count doit inclure les deux commandes postérieures au reset commandes: got=%d want=2", r.Count)
}
if r.Quantity != 3 {
t.Errorf("quantity doit inclure les deux commandes postérieures au reset commandes (2+1): got=%.2f want=3", r.Quantity)
}
// Le reset "revenus" exclut désormais les commandes créées avant lui :
// seule la dernière (revenue=20) doit rester.
if r.Revenue != 20 {
t.Errorf("revenue doit exclure les commandes créées avant le reset revenus: got=%.2f want=20", r.Revenue)
}
}
// Le reset de la section "revenus" doit se répercuter sur le total du mois
// affiché par GetAdminStatsByMonth, exactement comme il se répercute sur le
// résumé global (GetAdminStats / TotalRevenue) — sans quoi l'admin voit un
// résumé à zéro mais une vue mensuelle qui continue d'afficher l'historique.
func TestGetAdminStatsByMonth_TotalRevenueRespectsRevenusResetSection(t *testing.T) {
cleanupStockTestData(t)
cleanupStatsResetKey(t, "stats_reset_revenus_at")
username := newTestClient(t, "stats_month_revenus_reset")
productID := newTestProduct(t, "StatsMonthRevenusReset", 100)
before := getMonthStats(t, "")
newTestOrderForStats(t, username, "approved", productID, 1, 111, 0, time.Now().UTC())
afterOrder := getMonthStats(t, "")
if afterOrder.Summary.TotalRevenue != before.Summary.TotalRevenue+111 {
t.Fatalf("précondition: le revenu du mois doit augmenter de 111: before=%.2f after=%.2f",
before.Summary.TotalRevenue, afterOrder.Summary.TotalRevenue)
}
time.Sleep(1100 * time.Millisecond) // marge : reset stocké sans fraction de seconde (RFC3339)
if err := testDB.ResetAdminStat("stats_reset_revenus_at"); err != nil {
t.Fatalf("ResetAdminStat: %v", err)
}
afterReset := getMonthStats(t, "")
if afterReset.Summary.TotalRevenue != before.Summary.TotalRevenue {
t.Errorf("après reset de la section revenus, le revenu du mois doit revenir à sa valeur d'avant l'ajout de la commande: got=%.2f want=%.2f",
afterReset.Summary.TotalRevenue, before.Summary.TotalRevenue)
}
}
// ── Tests de couverture / non-régression (comportement correct attendu) ────
// Une commande avec plusieurs articles à prix différents et un crédit de
// parrainage partiel doit répartir le revenu net proportionnellement entre
// les articles, et la somme des parts doit correspondre exactement au revenu
// net de la commande — sur les trois vues qui font cette répartition
// (TopProducts, QuantityBreakdown, DailyProductDetailForDate).
func TestProductBreakdowns_MultiItemOrder_ProportionalSplitMatchesNetRevenue(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_multi_item")
productA := newTestProduct(t, "StatsMultiItemA", 100)
productB := newTestProduct(t, "StatsMultiItemB", 100)
now := time.Now()
// Une commande à deux articles : total_prix = 80 (30+50), referral_used=20 → net 60.
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, adresse, total_prix, referral_used, created_at, updated_at)
VALUES (?, 'approved', 'Adresse test', 80, 20, ?, ?) RETURNING id`,
username, now, now,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande multi-articles: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item A', 3, 30, 'pending')`, cmdID, productA,
).Error; err != nil {
t.Fatalf("création item A: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item B', 5, 50, 'pending')`, cmdID, productB,
).Error; err != nil {
t.Fatalf("création item B: %v", err)
}
wantRevenueA := 30.0 * 60.0 / 80.0 // 22.5
wantRevenueB := 50.0 * 60.0 / 80.0 // 37.5
var prodRows []models.ProductRow
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
t.Fatalf("TopProducts: %v", err)
}
revByProduct := map[int]float64{}
sumTop := 0.0
for _, r := range prodRows {
revByProduct[r.ProductID] = r.Revenue
sumTop += r.Revenue
}
if got := revByProduct[productA]; got != wantRevenueA {
t.Errorf("TopProducts revenue produit A: got=%.4f want=%.4f", got, wantRevenueA)
}
if got := revByProduct[productB]; got != wantRevenueB {
t.Errorf("TopProducts revenue produit B: got=%.4f want=%.4f", got, wantRevenueB)
}
totalRevenue, err := testDB.TotalRevenue(time.Time{})
if err != nil {
t.Fatalf("TotalRevenue: %v", err)
}
if totalRevenue != 60 {
t.Fatalf("précondition TotalRevenue: got=%.2f want=60", totalRevenue)
}
if sumTop != totalRevenue {
t.Errorf("somme TopProducts.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumTop, totalRevenue)
}
var qtyRows []models.QuantityBreakdownRow
if err := testDB.QuantityBreakdown(&qtyRows, time.Time{}); err != nil {
t.Fatalf("QuantityBreakdown: %v", err)
}
sumQty := 0.0
for _, r := range qtyRows {
sumQty += r.Revenue
}
if sumQty != totalRevenue {
t.Errorf("somme QuantityBreakdown.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumQty, totalRevenue)
}
var dailyRows []models.DailyProductRow
if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil {
t.Fatalf("DailyProductDetailForDate: %v", err)
}
sumDaily := 0.0
for _, r := range dailyRows {
sumDaily += r.Revenue
}
if sumDaily != totalRevenue {
t.Errorf("somme DailyProductDetailForDate.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumDaily, totalRevenue)
}
}
// Commande dont le total est entièrement composé d'articles récompense
// (total_prix = 0) : aucune division par zéro ni valeur NULL ne doit
// remonter comme revenu — le résultat attendu est 0, exactement comme pour
// TotalRevenue sur la même commande.
func TestProductBreakdowns_ZeroTotalPrixYieldsZeroRevenueNoDivideByZero(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_zero_total")
productID := newTestProduct(t, "StatsZeroTotal", 100)
now := time.Now()
newTestOrderForStats(t, username, "approved", productID, 4, 0, 0, now)
totalRevenue, err := testDB.TotalRevenue(time.Time{})
if err != nil {
t.Fatalf("TotalRevenue: %v", err)
}
if totalRevenue != 0 {
t.Fatalf("précondition TotalRevenue: got=%.2f want=0", totalRevenue)
}
var prodRows []models.ProductRow
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
t.Fatalf("TopProducts: %v", err)
}
if len(prodRows) != 1 {
t.Fatalf("attendu 1 produit, got=%d", len(prodRows))
}
if prodRows[0].Revenue != 0 {
t.Errorf("revenu attendu à 0 pour total_prix=0: got=%.2f", prodRows[0].Revenue)
}
var dailyRows []models.DailyProductRow
if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil {
t.Fatalf("DailyProductDetailForDate: %v", err)
}
if len(dailyRows) != 1 || dailyRows[0].Revenue != 0 {
t.Errorf("DailyProductDetailForDate revenu attendu à 0 pour total_prix=0: got=%+v", dailyRows)
}
}
// Le résumé du mois (summary.total_revenue) doit toujours être égal à la
// somme des revenus journaliers renvoyés dans by_day, pour rester cohérent
// avec ce qui est effectivement affiché à l'utilisateur.
func TestGetAdminStatsByMonth_SummaryRevenueMatchesSumOfByDay(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_month_sum_consistency")
productID := newTestProduct(t, "StatsMonthSumConsistency", 100)
newTestOrderForStats(t, username, "approved", productID, 1, 42, 2, time.Now()) // net 40
c, rec := monthStatsContext("")
handlers.GetAdminStatsByMonth(c)
if rec.Code != http.StatusOK {
t.Fatalf("GetAdminStatsByMonth doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Summary struct {
TotalRevenue float64 `json:"total_revenue"`
} `json:"summary"`
ByDay []struct {
Revenue float64 `json:"revenue"`
} `json:"by_day"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("réponse JSON invalide: %v", err)
}
sum := 0.0
for _, d := range resp.ByDay {
sum += d.Revenue
}
if sum != resp.Summary.TotalRevenue {
t.Errorf("summary.total_revenue (%.2f) doit correspondre à la somme de by_day[].revenue (%.2f)", resp.Summary.TotalRevenue, sum)
}
}