chore: build
Backend - Build & Lint / build (push) Failing after 28m7s
Frontend Admin - EAS Build / build (push) Failing after 1h39m13s
Frontend Client - EAS Build / build (push) Failing after 1h38m12s
Frontend Web - Build & Lint / build (push) Failing after 10m56s

This commit is contained in:
Xor290
2026-08-19 17:49:04 +02:00
parent 1623a9eafd
commit 901d013830
33 changed files with 1459 additions and 263 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)
}
}
+19 -36
View File
@@ -14,10 +14,13 @@ import (
"github.com/gin-gonic/gin"
)
// UpdateDeliveryStatus (handlers/deleviry.go) refuse de valider une livraison
// (statut "livre") si le livreur se trouve à plus de 350m de la destination
// (contrôle anti-fraude — seuil relevé de 100m à 350m à la demande explicite,
// pour tolérer l'imprécision GPS réelle en zone urbaine/immeuble).
// UpdateDeliveryStatus (handlers/deleviry.go) n'impose plus aucune limite de
// distance entre le livreur et la destination pour valider une livraison
// (statut "livre") — la vérification GPS a été volontairement retirée pour ne
// pas bloquer le livreur (l'imprécision GPS réelle en zone urbaine/immeuble
// provoquait des rejets sur des livraisons pourtant légitimes). Les
// coordonnées GPS restent obligatoires et la distance est toujours calculée
// et loguée à des fins de suivi, mais elle n'entraîne plus de rejet.
const earthRadiusMeters = 6371000.0
@@ -58,7 +61,7 @@ func deliveryStatusContextJSON(username string, commandID int, body []byte) (*gi
const nantesLat, nantesLon = 47.2184, -1.5536
func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T) {
func TestUpdateDeliveryStatus_GPS_ValidatesDeliveryAtModerateDistance(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_within")
client := newTestClient(t, "gps_client_within")
@@ -66,7 +69,7 @@ func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200) // 200m < 350m
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200)
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
@@ -79,47 +82,27 @@ func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T)
}
}
func TestUpdateDeliveryStatus_GPS_BeyondThresholdRejectsValidation(t *testing.T) {
// Aucune distance, aussi grande soit-elle, ne doit bloquer la validation : la
// vérification GPS a été retirée pour ne jamais empêcher un livreur de
// marquer une commande "livre".
func TestUpdateDeliveryStatus_GPS_FarBeyondOldThresholdStillValidatesDelivery(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_beyond")
client := newTestClient(t, "gps_client_beyond")
productID := newTestProduct(t, "GPSBeyond", 10)
livreur := newTestClient(t, "gps_livreur_far")
client := newTestClient(t, "gps_client_far")
productID := newTestProduct(t, "GPSFar", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 400) // 400m > 350m
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("le statut ne doit pas passer à 'livre' au-delà de 350m: got=%s want=en_route", got)
}
}
// Preuve directe du changement demandé : une distance de 150m, qui aurait
// échoué sous l'ancien seuil de 100m, doit maintenant réussir sous 350m.
func TestUpdateDeliveryStatus_GPS_150Meters_PassesUnderNewThreshold(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_150m")
client := newTestClient(t, "gps_client_150m")
productID := newTestProduct(t, "GPS150m", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 150)
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 5000) // 5km : loin de toute ancienne limite
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("150m doit être accepté sous le nouveau seuil de 350m: got=%d body=%s", rec.Code, rec.Body.String())
t.Fatalf("aucune distance ne doit bloquer la validation: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "livre" {
t.Errorf("statut après validation à 150m: got=%s want=livre", got)
t.Errorf("statut après validation à 5km: got=%s want=livre", got)
}
}
@@ -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". ValidateDeliveryAtomic (validation admin en masse)
// accepte désormais "arrived" aussi, pour la même raison (voir plus bas).
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)
@@ -145,9 +183,12 @@ func TestApproveDeliveryAtomicByStaff_NoOwnershipCheck_AnyStaffCanConfirmAnyClie
// Divergence de règle métier volontaire (confirmée) : contrairement aux deux
// autres chemins d'approbation (client et staff), qui exigent tous deux le
// statut 'livre', ValidateDeliveryAtomic accepte "pending", "assigned",
// "en_route" ET "livre" — c'est un override admin assumé pour régulariser une
// commande gérée hors flux normal, pas un bug. Les tests suivants documentent
// ce comportement réel pour qu'une future régression involontaire soit détectée.
// "en_route", "arrived" ET "livre" — c'est un override admin assumé pour
// régulariser une commande gérée hors flux normal, pas un bug. "arrived" a été
// ajouté pour que l'admin puisse toujours finaliser une commande arrivée à
// destination (le seul bouton de finalisation côté admin passe par ce chemin),
// à l'image de ApproveDeliveryAtomicByStaff. Les tests suivants documentent ce
// comportement réel pour qu'une future régression involontaire soit détectée.
func TestValidateDeliveryAtomic_AcceptsAllDocumentedStatusesAndCreditsPoints(t *testing.T) {
cleanupStockTestData(t)
@@ -155,7 +196,7 @@ func TestValidateDeliveryAtomic_AcceptsAllDocumentedStatusesAndCreditsPoints(t *
{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", "livre"} {
for _, status := range []string{"pending", "assigned", "en_route", "arrived", "livre"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "validate_status_"+status)
productID := newTestProduct(t, "ValidateStatus"+status, 20)
@@ -180,7 +221,7 @@ func TestValidateDeliveryAtomic_AcceptsAllDocumentedStatusesAndCreditsPoints(t *
func TestValidateDeliveryAtomic_RejectsStatusOutsideAllowedList(t *testing.T) {
cleanupStockTestData(t)
for _, status := range []string{"cancelled", "pending_payment", "arrived"} {
for _, status := range []string{"cancelled", "pending_payment"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "validate_invalid_"+status)
productID := newTestProduct(t, "ValidateInvalid"+status, 20)
@@ -204,7 +204,7 @@ func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoi
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 3}}},
})
setPointsRewardSettings(t, models.PointsReward{Threshold: 20, Type: "free_product"})
setPointsRewardSettings(t, models.PointsReward{Threshold: 20})
setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande
cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10)
+55 -10
View File
@@ -24,9 +24,14 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R
return c, rec
}
// configureRewardSettings applique la récompense donnée, avec pool_0 mappé
// sur la catégorie "test" — nécessaire pour que eligibleRewardProducts
// (qui croise pool.Categories et reward.CategoryConfigs) considère les
// reward_items comme éligibles.
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
t.Helper()
settings := db.DefaultSettings()
settings.PointsPools[0].Categories = []string{"test"}
settings.PointsReward = reward
if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings: %v", err)
@@ -41,10 +46,10 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5)
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Type: "free_product",
Description: "Un produit offert",
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
Threshold: 20,
Description: "Un produit offert",
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
})
setClientPoolPoints(t, username, "pool_0", 20)
@@ -75,6 +80,43 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
if len(rows) != 1 || rows[0].ProductID != rewardProductID {
t.Errorf("le produit récompense doit être dans le panier: %+v", rows)
}
if rows[0].Price != 0 {
t.Errorf("catégorie free_product: le prix en panier doit être 0: got=%.2f", rows[0].Price)
}
}
// Catégorie configurée en "half_price_product" : le produit récompense doit
// être ajouté au panier à 50% du prix catalogue actif (pas 0€, pas le prix
// indicatif RewardItem.Price saisi par l'admin).
func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_halfprice")
rewardProductID := newTestProduct(t, "RewardHTTPHalfPrice", 5)
// newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go).
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Description: "Un produit à moitié prix",
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "half_price_product", AllProducts: true}},
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré
})
setClientPoolPoints(t, username, "pool_0", 20)
body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
c, rec := claimRewardContext(username, body)
handlers.ClaimMyReward(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
rows := basketRewardItems(t, username)
if len(rows) != 1 || rows[0].ProductID != rewardProductID {
t.Fatalf("le produit récompense doit être dans le panier: %+v", rows)
}
if rows[0].Price != 5.0 {
t.Errorf("catégorie half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", rows[0].Price)
}
}
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
@@ -83,9 +125,9 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Type: "free_product",
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
Threshold: 20,
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
})
setClientPoolPoints(t, username, "pool_0", 5)
@@ -108,9 +150,12 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T)
username := newTestClient(t, "reward_http_missing_product")
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Type: "free_product",
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
Threshold: 20,
// ProductIDs explicite (pas AllProducts) : le produit n'existe pas en
// base, donc il n'apparaîtrait jamais dans productCategories et ne
// serait jamais éligible via une correspondance AllProducts.
CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", ProductIDs: []int{999999999}}},
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
})
setClientPoolPoints(t, username, "pool_0", 20)
+8 -3
View File
@@ -206,7 +206,12 @@ func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.
// ── AddRewardsToBasket : flags et remplacement ──────────────────────────────
func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
// AddRewardsToBasket ne recalcule plus le prix : elle stocke tel quel le
// RewardItem.Price fourni par l'appelant (0 pour "free_product", prix -50%
// déjà résolu par handlers/points.go pour "half_price_product") — voir
// TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice
// pour le flux complet qui résout ce prix par type de catégorie.
func TestAddRewardsToBasket_SetsRewardFlagsAndStoresGivenPrice(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_basket_flags")
productID := newTestProduct(t, "RewardBasketFlags", 20)
@@ -231,8 +236,8 @@ func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
if row.RewardPoolKey != "pool_0" {
t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0")
}
if row.Price != 0 {
t.Errorf("prix affiché doit être 0 (gratuit): got=%.2f", row.Price)
if row.Price != 15.0 {
t.Errorf("le prix fourni par l'appelant doit être stocké tel quel: got=%.2f want=15.00", row.Price)
}
if row.Quantity != 2 {
t.Errorf("quantité: got=%.2f want=2", row.Quantity)
@@ -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)
}
}