429 lines
17 KiB
Go
429 lines
17 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)
|
|
}
|
|
}
|
|
|
|
// ── 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")
|
|
}
|
|
}
|