Files
projet_gestion_commande/backend/gestion/tests/stock_coverage_test.go
T
Xor290 06abfee274
Backend - Build & Lint / build (push) Failing after 29m22s
chore: build
2026-08-20 21:46:16 +02:00

478 lines
19 KiB
Go

package tests
import (
"bytes"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
// ── Helpers ──────────────────────────────────────────────────────────────
// newTestCategory crée une catégorie de test et programme son nettoyage.
// CreateProduct/UpdateProduct passent la catégorie reçue par strings.ToLower
// avant de vérifier son existence : le nom doit donc déjà être en
// minuscules ici pour matcher.
func newTestCategory(t *testing.T, name string) string {
t.Helper()
fullName := strings.ToLower(testProductPrefix + name)
if _, err := testDB.CreateCategory(fullName, "#000000", false); err != nil {
t.Fatalf("CreateCategory %q: %v", fullName, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM categories WHERE name = ?`, fullName)
})
return fullName
}
// productCreateRequest construit une requête multipart minimale pour
// CreateProduct (un seul prix, pas de média).
func productCreateRequest(fields map[string]string) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
for k, v := range fields {
w.WriteField(k, v)
}
w.Close()
return body, w.FormDataContentType()
}
func productContext(role string, body *bytes.Buffer, contentType string, productID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"product_admin")
c.Set("role", role)
if productID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
}
return c, rec
}
func jsonStockContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products/stock", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"product_admin")
c.Set("role", role)
if productID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
}
return c, rec
}
// ── CreateProduct ────────────────────────────────────────────────────────
func TestCreateProduct_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CP1", "category": "x", "description": "d",
"stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("client", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
func TestCreateProduct_RejectsNegativeStock(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatNeg")
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPNeg", "category": cat, "description": "d",
"stock": "-5", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateProduct_RejectsStockOverMax(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatOver")
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPOver", "category": cat, "description": "d",
"stock": "1000001", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateProduct_RejectsUnknownCategory(t *testing.T) {
cleanupStockTestData(t)
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPCat", "category": "categorie-inexistante-xyz", "description": "d",
"stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("catégorie inconnue doit être rejetée: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateProduct_SuccessCreatesProductAndPrice(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatOk")
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPOk", "category": cat, "description": "d",
"stock": "42", "prices[0][quantity]": "1", "prices[0][price]": "9.99",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusCreated {
t.Fatalf("création produit valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE name = ?`, testProductPrefix+"CPOk").Scan(&count)
if count != 1 {
t.Errorf("le produit doit être créé en base: got=%d", count)
}
var priceCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM product_prices pp JOIN products p ON p.id = pp.product_id WHERE p.name = ?`, testProductPrefix+"CPOk").Scan(&priceCount)
if priceCount != 1 {
t.Errorf("le prix doit être créé en base: got=%d", priceCount)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name = ?)`, testProductPrefix+"CPOk")
testDB.GDB.Exec(`DELETE FROM products WHERE name = ?`, testProductPrefix+"CPOk")
})
}
// ── UpdateStock ──────────────────────────────────────────────────────────
func TestUpdateStock_RejectsNonAdmin(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USNonAdmin", 10)
c, rec := jsonStockContext("cabine", []byte(`{"stock":20}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role cabine doit être refusé pour UpdateStock: got=%d want=403", rec.Code)
}
}
func TestUpdateStock_RejectsNegativeStock(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USNeg", 10)
c, rec := jsonStockContext("admin", []byte(`{"stock":-1}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 10 {
t.Errorf("le stock ne doit pas changer après un rejet: got=%.2f want=10", got)
}
}
func TestUpdateStock_RejectsStockOverMax(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USOver", 10)
c, rec := jsonStockContext("admin", []byte(`{"stock":1000001}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400", rec.Code)
}
}
// Documente un bug existant plutôt que le comportement souhaité :
// GetProductByID fait un Raw(...).Scan() qui ne retourne PAS d'erreur
// quand aucune ligne ne matche (contrairement à First()), donc le check
// "produit non trouvé → 404" en tête de UpdateStock ne se déclenche
// jamais. La requête continue jusqu'à SetProductStock, qui échoue côté
// DB et remonte en 500 générique au lieu d'un 404 propre.
func TestUpdateStock_UnknownProductReturns500NotFoundBugDocumented(t *testing.T) {
cleanupStockTestData(t)
c, rec := jsonStockContext("admin", []byte(`{"stock":5}`), 99999999)
handlers.UpdateStock(c)
if rec.Code != http.StatusInternalServerError {
t.Errorf("comportement actuel (bug): produit inconnu retourne 500, pas 404: got=%d", rec.Code)
}
}
func TestUpdateStock_SuccessSetsExactValue(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USOk", 10)
c, rec := jsonStockContext("admin", []byte(`{"stock":33}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusOK {
t.Fatalf("mise à jour stock valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 33 {
t.Errorf("stock après UpdateStock: got=%.2f want=33", got)
}
}
// Documente le comportement actuel du garde-fou "réservé en panier" de
// UpdateStock : `req.Stock+reserved < reserved` est algébriquement
// équivalent à `req.Stock < 0`, donc ce garde-fou ne bloque en réalité
// jamais un stock positif inférieur à la quantité réservée. Un admin peut
// donc, aujourd'hui, mettre le stock en dessous du réservé en panier — ce
// test documente ce comportement pour éviter une régression silencieuse
// si quelqu'un "corrige" la formule sans le vouloir explicitement.
func TestUpdateStock_ReservedGuardDoesNotBlockStockBelowReserved(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "us_reserved")
productID := newTestProduct(t, "USReserved", 10)
if _, err := testDB.AddToBasket(username, productID, 8); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
// 8 sont réservés en panier ; on met le stock à 1, en dessous du réservé.
c, rec := jsonStockContext("admin", []byte(`{"stock":1}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusOK {
t.Fatalf("comportement actuel: la requête réussit malgré stock < réservé: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 1 {
t.Errorf("stock après UpdateStock: got=%.2f want=1", got)
}
}
// ── UpdateProduct ────────────────────────────────────────────────────────
func updateProductJSONContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"product_admin")
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
return c, rec
}
func TestUpdateProduct_UpdatesStockWhenProvided(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatUpd")
productID := newTestProduct(t, "UPStock", 5)
body := []byte(fmt.Sprintf(`{"name":"UPStock","category":%q,"description":"d2","unit":"kg","stock":77,"prices":[{"quantity":1,"price":5}]}`, cat))
c, rec := updateProductJSONContext("admin", body, productID)
handlers.UpdateProduct(c)
if rec.Code != http.StatusOK {
t.Fatalf("UpdateProduct valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 77 {
t.Errorf("stock après UpdateProduct: got=%.2f want=77", got)
}
}
// productPriceActiveFlags relit active_price par palier de quantité pour un
// produit, directement en base.
func productPriceActiveFlags(t *testing.T, productID int) map[float64]bool {
t.Helper()
var rows []struct {
Quantity float64 `gorm:"column:quantity"`
ActivePrice bool `gorm:"column:active_price"`
}
if err := testDB.GDB.Raw(
`SELECT quantity, active_price FROM product_prices WHERE product_id = ?`, productID,
).Scan(&rows).Error; err != nil {
t.Fatalf("lecture active_price: %v", err)
}
out := make(map[float64]bool, len(rows))
for _, r := range rows {
out[r.Quantity] = r.ActivePrice
}
return out
}
// Un prix explicitement désactivé (active_price=false) doit rester désactivé
// après UpdateProduct — piège classique de GORM : un champ bool à sa valeur
// zéro (false) avec un tag gorm "default" est omis de l'INSERT, laissant la
// base appliquer son propre défaut (TRUE) à la place. Voir le commentaire sur
// ProductPrice.ActivePrice dans models/product.go.
func TestUpdateProduct_PersistsInactivePriceFlag(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatInactive")
productID := newTestProduct(t, "UPInactive", 5)
body := []byte(fmt.Sprintf(
`{"name":"UPInactive","category":%q,"description":"d2","unit":"g","stock":10,"prices":[{"quantity":1,"price":5,"active_price":false},{"quantity":5,"price":20,"active_price":true}]}`,
cat,
))
c, rec := updateProductJSONContext("admin", body, productID)
handlers.UpdateProduct(c)
if rec.Code != http.StatusOK {
t.Fatalf("UpdateProduct valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
flags := productPriceActiveFlags(t, productID)
if flags[1] != false {
t.Errorf("palier qty=1 doit rester désactivé après UpdateProduct: got active_price=%v", flags[1])
}
if flags[5] != true {
t.Errorf("palier qty=5 doit rester actif après UpdateProduct: got active_price=%v", flags[5])
}
}
// ── DeleteProduct ────────────────────────────────────────────────────────
func TestDeleteProduct_RemovesProductWithoutMedia(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "DelNoMedia", 3)
c, rec := productContext("admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", productID)
handlers.DeleteProduct(c)
if rec.Code != http.StatusOK {
t.Fatalf("suppression produit sans média doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE id = ?`, productID).Scan(&count)
if count != 0 {
t.Errorf("le produit doit être supprimé: got=%d lignes restantes", count)
}
}
// ── GetReservedQuantityInBaskets ─────────────────────────────────────────
func TestGetReservedQuantityInBaskets_SumsNonRewardQuantities(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "ResQty", 20)
c1 := newTestClient(t, "resqty_c1")
c2 := newTestClient(t, "resqty_c2")
if _, err := testDB.AddToBasket(c1, productID, 3); err != nil {
t.Fatalf("AddToBasket c1: %v", err)
}
if _, err := testDB.AddToBasket(c2, productID, 5); err != nil {
t.Fatalf("AddToBasket c2: %v", err)
}
reserved, err := testDB.GetReservedQuantityInBaskets(productID)
if err != nil {
t.Fatalf("GetReservedQuantityInBaskets: %v", err)
}
if reserved != 8 {
t.Errorf("réservé total: got=%.2f want=8", reserved)
}
}
// ── AddToBasket merge behavior ───────────────────────────────────────────
func TestAddToBasket_MergesIntoExistingNonRewardRow(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "merge_basket")
productID := newTestProduct(t, "MergeBasket", 20)
if _, err := testDB.AddToBasket(username, productID, 2); err != nil {
t.Fatalf("AddToBasket #1: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 3); err != nil {
t.Fatalf("AddToBasket #2: %v", err)
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&count)
if count != 1 {
t.Fatalf("les deux ajouts doivent fusionner en une seule ligne: got=%d lignes", count)
}
var qty float64
testDB.GDB.Raw(`SELECT quantity FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&qty)
if qty != 5 {
t.Errorf("quantité fusionnée: got=%.2f want=5", qty)
}
}
// ── Checkout validation (db.CreateCommandWithAddress) ───────────────────
func TestCreateCommandWithAddress_RejectsEmptyBasket(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_empty")
_, err := testDB.CreateCommandWithAddress(username, "1 rue vide")
if err == nil {
t.Fatal("checkout avec panier vide doit échouer")
}
}
func TestCreateCommandWithAddress_RejectsInvalidBasketItemData(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_invalid_item")
productID := newTestProduct(t, "CheckoutInvalidItem", 10)
// Insertion directe d'une ligne de panier avec quantité invalide,
// en contournant AddToBasket qui la rejetterait.
testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`,
username, productID, 0, 10,
)
_, err := testDB.CreateCommandWithAddress(username, "1 rue invalide")
if err == nil {
t.Fatal("checkout avec donnée panier invalide (quantité=0) doit échouer")
}
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
}
func TestCreateCommandWithAddress_RejectsTotalPriceOverMax(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_overmax")
productID := newTestProduct(t, "CheckoutOverMax", 10)
testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`,
username, productID, 1, 100001,
)
_, err := testDB.CreateCommandWithAddress(username, "1 rue trop cher")
if err == nil {
t.Fatal("checkout avec montant total > 100000 doit échouer")
}
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
}
// ── DeleteCommandItem ─────────────────────────────────────────────────────
func TestDeleteCommandItem_UpdatesCommandTotalPrix(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delitem_total")
productID := newTestProduct(t, "DelItemTotal", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
testDB.GDB.Exec(`UPDATE commandes SET total_prix = 20 WHERE id = ?`, cmdID)
var itemID int
testDB.GDB.Raw(`SELECT id FROM command_items WHERE command_id = ?`, cmdID).Scan(&itemID)
if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil {
t.Fatalf("DeleteCommandItem: %v", err)
}
var totalPrix float64
testDB.GDB.Raw(`SELECT total_prix FROM commandes WHERE id = ?`, cmdID).Scan(&totalPrix)
if totalPrix != 0 {
t.Errorf("total_prix après suppression du seul item: got=%.2f want=0", totalPrix)
}
}
func TestDeleteCommandItem_UnknownItemReturnsError(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delitem_unknown")
productID := newTestProduct(t, "DelItemUnknown", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
if err := testDB.DeleteCommandItem(cmdID, 99999999); err == nil {
t.Fatal("suppression d'un item inconnu doit retourner une erreur")
}
}
func TestDeleteCommandItem_UnknownCommandReturnsError(t *testing.T) {
cleanupStockTestData(t)
if err := testDB.DeleteCommandItem(99999999, 1); err == nil {
t.Fatal("suppression d'un item sur une commande inconnue doit retourner une erreur")
}
}