From 30eebd12d20238ab972b1d0ddbd90238a2e3500b Mon Sep 17 00:00:00 2001 From: Xor290 Date: Tue, 25 Aug 2026 18:52:55 +0200 Subject: [PATCH] chore: build --- backend/gestion/db/db_settings.go | 14 ++++- backend/gestion/tests/rewards_handler_test.go | 61 +++++++++++++++++++ backend/gestion/tests/rewards_test.go | 36 +++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index 1affe22a..b173c13c 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -125,9 +125,17 @@ func (d *Database) GetSettings() (models.AppSettings, error) { settings.PointsPools = pools } case "points_reward": - var reward models.PointsReward - if err := json.Unmarshal([]byte(row.Value), &reward); err == nil { - settings.PointsReward = &reward + // row.Value peut valoir la chaîne littérale "null" (récompense + // désactivée puis sauvegardée : json.Marshal(nil *PointsReward) + // produit "null"). json.Unmarshal d'un null JSON dans une valeur + // non-pointeur est un no-op sans erreur (voir doc encoding/json), + // donc sans ce garde-fou &reward pointerait vers une struct vide + // mais non-nil, et la récompense réapparaîtrait activée. + if row.Value != "null" && row.Value != "" { + var reward models.PointsReward + if err := json.Unmarshal([]byte(row.Value), &reward); err == nil { + settings.PointsReward = &reward + } } case "referral_enabled": settings.ReferralEnabled = row.Value == "true" diff --git a/backend/gestion/tests/rewards_handler_test.go b/backend/gestion/tests/rewards_handler_test.go index 32d3bc52..5fbecc37 100644 --- a/backend/gestion/tests/rewards_handler_test.go +++ b/backend/gestion/tests/rewards_handler_test.go @@ -175,3 +175,64 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) t.Errorf("la récompense ne doit PAS être consommée si le produit est introuvable: got redeemed=%d want=0", redeemed["pool_0"]) } } + +// Une même catégorie peut avoir les deux types de récompense actifs en +// parallèle (un lot de produits offerts + un lot de produits à -50%), chacun +// avec sa propre sélection de produits (voir eligibleRewardProducts, qui +// n'impose aucune unicité de catégorie dans CategoryConfigs). Un seul claim +// doit alors ajouter les deux produits au panier, chacun tarifé selon son +// propre type. +func TestClaimMyReward_HTTPFlow_CategoryWithBothTypesSimultaneously(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_dual_type") + freeProductID := newTestProduct(t, "RewardHTTPDualFree", 5) + halfProductID := newTestProduct(t, "RewardHTTPDualHalf", 5) + // newTestProduct crée les deux produits dans la catégorie "test", avec un + // prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go). + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + Description: "Un produit offert + un produit à -50%", + CategoryConfigs: []models.RewardCategoryConfig{ + {Category: "test", Type: "free_product", ProductIDs: []int{freeProductID}}, + {Category: "test", Type: "half_price_product", ProductIDs: []int{halfProductID}}, + }, + RewardItems: []models.RewardItem{ + {ProductID: freeProductID, Quantity: 1, Price: 12}, + {ProductID: halfProductID, Quantity: 1, Price: 12}, + }, + }) + 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) != 2 { + t.Fatalf("les deux produits récompense doivent être dans le panier: %+v", rows) + } + + var freeRow, halfRow *rewardBasketRow + for i := range rows { + switch rows[i].ProductID { + case freeProductID: + freeRow = &rows[i] + case halfProductID: + halfRow = &rows[i] + } + } + if freeRow == nil || halfRow == nil { + t.Fatalf("les deux produits attendus doivent être présents: %+v", rows) + } + if freeRow.Price != 0 { + t.Errorf("produit de la config free_product: le prix en panier doit être 0: got=%.2f", freeRow.Price) + } + if halfRow.Price != 5.0 { + t.Errorf("produit de la config half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", halfRow.Price) + } +} diff --git a/backend/gestion/tests/rewards_test.go b/backend/gestion/tests/rewards_test.go index f2425f35..121baadf 100644 --- a/backend/gestion/tests/rewards_test.go +++ b/backend/gestion/tests/rewards_test.go @@ -1,6 +1,7 @@ package tests import ( + "gestion/db" "gestion/models" "strings" "sync" @@ -40,6 +41,41 @@ func basketRewardItems(t *testing.T, username string) []rewardBasketRow { return rows } +// Désactiver la récompense (PointsReward = nil) puis sauvegarder ne doit pas +// la faire réapparaître activée au rechargement — régression : json.Marshal +// d'un pointeur nil produit la chaîne "null", et json.Unmarshal d'un null +// JSON dans une valeur non-pointeur est un no-op sans erreur, ce qui laissait +// settings.PointsReward pointer vers une struct vide mais non-nil. +func TestUpdateSettings_DisablingPointsRewardPersistsAsNil(t *testing.T) { + settings := db.DefaultSettings() + settings.PointsReward = &models.PointsReward{ + Threshold: 20, + Description: "Un produit offert", + } + if err := testDB.UpdateSettings(settings); err != nil { + t.Fatalf("UpdateSettings (activation): %v", err) + } + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (activation): %v", err) + } + if loaded.PointsReward == nil { + t.Fatal("la récompense devrait être active après la première sauvegarde") + } + + settings.PointsReward = nil + if err := testDB.UpdateSettings(settings); err != nil { + t.Fatalf("UpdateSettings (désactivation): %v", err) + } + loaded, err = testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (désactivation): %v", err) + } + if loaded.PointsReward != nil { + t.Errorf("la récompense désactivée ne doit pas réapparaître après sauvegarde: got=%+v", loaded.PointsReward) + } +} + // ── ClaimPoolReward : seuil, atomicité, épuisement ────────────────────────── func TestClaimPoolReward_BelowThresholdFails(t *testing.T) {