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