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
@@ -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)
}
}