diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index b173c13c..fbb1f025 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -72,19 +72,19 @@ func DefaultSettings() models.AppSettings { Mode: "single", CategoryRoutes: []models.CategoryRoute{}, }, - AdminColorPrimary: "#7c3aed", - AdminColorSecondary: "#000000", - AdminColorSuccess: "#4ade80", - AdminColorDanger: "#ef4444", - AdminColorWarning: "#f59e0b", - ClientColorPrimary: "#7c3aed", - ClientColorSecondary: "#000000", - ClientColorSuccess: "#4ade80", - ClientColorDanger: "#ef4444", - ClientColorWarning: "#f59e0b", + AdminColorPrimary: "#7c3aed", + AdminColorSecondary: "#000000", + AdminColorSuccess: "#4ade80", + AdminColorDanger: "#ef4444", + AdminColorWarning: "#f59e0b", + ClientColorPrimary: "#7c3aed", + ClientColorSecondary: "#000000", + ClientColorSuccess: "#4ade80", + ClientColorDanger: "#ef4444", + ClientColorWarning: "#f59e0b", ClientTitleGradientFrom: "#a78bfa", ClientTitleGradientTo: "#22d3ee", - DeliverySchedule: DefaultDeliverySchedule(), + DeliverySchedule: DefaultDeliverySchedule(), PostalZones: []models.PostalZone{ {Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}}, {Name: "Zone 50€", MinAmount: 50, Codes: []string{ @@ -115,6 +115,11 @@ func (d *Database) GetSettings() (models.AppSettings, error) { switch row.Key { case "penalties_enabled": settings.PenaltiesEnabled = row.Value == "true" + case "penalty_tiers": + var tiers []models.PenaltyTier + if err := json.Unmarshal([]byte(row.Value), &tiers); err == nil { + settings.PenaltyTiers = tiers + } case "show_amende_score": settings.ShowAmendeScore = row.Value == "true" case "points_enabled": @@ -221,6 +226,14 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { return "false" } + if s.PenaltyTiers == nil { + s.PenaltyTiers = []models.PenaltyTier{} + } + tiersJSON, err := json.Marshal(s.PenaltyTiers) + if err != nil { + return fmt.Errorf("erreur sérialisation penalty_tiers: %w", err) + } + if s.PointsPools == nil { s.PointsPools = []models.PointsPool{} } @@ -277,6 +290,7 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { } pairs := [][2]string{ {"penalties_enabled", boolStr(s.PenaltiesEnabled)}, + {"penalty_tiers", string(tiersJSON)}, {"show_amende_score", boolStr(s.ShowAmendeScore)}, {"points_enabled", boolStr(s.PointsEnabled)}, {"points_pools", string(poolsJSON)}, diff --git a/backend/gestion/tests/settings_persistence_test.go b/backend/gestion/tests/settings_persistence_test.go new file mode 100644 index 00000000..47d56f61 --- /dev/null +++ b/backend/gestion/tests/settings_persistence_test.go @@ -0,0 +1,306 @@ +package tests + +import ( + "gestion/db" + "gestion/models" + "testing" +) + +// resetSettingsAfterTest restaure les settings par défaut à la fin du test — +// AppSettings est un état global partagé (une seule ligne par clé dans +// app_settings), donc un test qui le modifie ne doit pas laisser de résidu +// pour les tests suivants (ex: DeliveryMode utilisé par d'autres suites). +func resetSettingsAfterTest(t *testing.T) { + t.Helper() + t.Cleanup(func() { + if err := testDB.UpdateSettings(db.DefaultSettings()); err != nil { + t.Logf("⚠️ resetSettingsAfterTest: restauration des settings par défaut échouée: %v", err) + } + }) +} + +// ── Bascules booléennes (activer/désactiver une option) ───────────────────── +// +// Régression visée : chaque option doit persister à sa valeur exacte après un +// cycle save→reload, dans les deux sens (activation ET désactivation) — voir +// TestUpdateSettings_DisablingPointsRewardPersistsAsNil pour un cas où la +// désactivation ne persistait pas correctement. +func TestUpdateSettings_DisablingBooleanTogglesPersists(t *testing.T) { + resetSettingsAfterTest(t) + + set := func(v bool) models.AppSettings { + s := db.DefaultSettings() + s.PenaltiesEnabled = v + s.ShowAmendeScore = v + s.PointsEnabled = v + s.ReferralEnabled = v + s.CryptoPaymentEnabled = v + s.CryptoOnly = v + s.TelegramNotificationsEnabled = v + s.Telegram2FAEnabled = v + return s + } + + assertAll := func(t *testing.T, want bool) { + t.Helper() + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + checks := map[string]bool{ + "penalties_enabled": loaded.PenaltiesEnabled, + "show_amende_score": loaded.ShowAmendeScore, + "points_enabled": loaded.PointsEnabled, + "referral_enabled": loaded.ReferralEnabled, + "crypto_payment_enabled": loaded.CryptoPaymentEnabled, + "crypto_only": loaded.CryptoOnly, + "telegram_notifications_enabled": loaded.TelegramNotificationsEnabled, + "telegram_2fa_enabled": loaded.Telegram2FAEnabled, + } + for key, got := range checks { + if got != want { + t.Errorf("%s: got=%v want=%v", key, got, want) + } + } + } + + if err := testDB.UpdateSettings(set(true)); err != nil { + t.Fatalf("UpdateSettings (activation): %v", err) + } + assertAll(t, true) + + if err := testDB.UpdateSettings(set(false)); err != nil { + t.Fatalf("UpdateSettings (désactivation): %v", err) + } + assertAll(t, false) +} + +// ── Options non-booléennes (hors NowPayments) ─────────────────────────────── + +// Le barème des amendes (penalty_tiers) est éditable dans l'admin +// ("Barème des amendes") mais aucune clé "penalty_tiers" n'existe dans les +// pairs persistées par UpdateSettings ni dans le switch de GetSettings — la +// configuration saisie par l'admin est donc silencieusement perdue au +// prochain rechargement, et retombe toujours sur le barème par défaut. +func TestUpdateSettings_PenaltyTiersRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.PenaltyTiers = []models.PenaltyTier{ + {MinCancel: 0, Amount: 10}, + {MinCancel: 5, Amount: 999}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if len(loaded.PenaltyTiers) != 2 || loaded.PenaltyTiers[1].Amount != 999 { + t.Errorf("le barème des amendes personnalisé n'a pas été persisté: got=%+v", loaded.PenaltyTiers) + } +} + +func TestUpdateSettings_ReferralAmountRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.ReferralAmount = 12.5 + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if loaded.ReferralAmount != 12.5 { + t.Errorf("referral_amount: got=%.2f want=12.50", loaded.ReferralAmount) + } + + s.ReferralAmount = 0 + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings (remise à zéro): %v", err) + } + loaded, err = testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (remise à zéro): %v", err) + } + if loaded.ReferralAmount != 0 { + t.Errorf("referral_amount remis à 0: got=%.2f want=0.00", loaded.ReferralAmount) + } +} + +func TestUpdateSettings_PointsPoolsRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.PointsPools = []models.PointsPool{ + { + Key: "pool_custom", + Name: "Pool Custom", + Categories: []string{"catA", "catB"}, + Tiers: []models.PointsTier{{Min: 10, Max: 20, Points: 7}}, + }, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if len(loaded.PointsPools) != 1 || loaded.PointsPools[0].Key != "pool_custom" || + len(loaded.PointsPools[0].Categories) != 2 || loaded.PointsPools[0].Tiers[0].Points != 7 { + t.Errorf("points_pools personnalisé mal persisté: got=%+v", loaded.PointsPools) + } +} + +func TestUpdateSettings_DeliveryScheduleRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.DeliverySchedule.Monday = models.DaySchedule{Enabled: false, OpenTime: "10:00", CloseTime: "18:00"} + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if loaded.DeliverySchedule.Monday.Enabled != false || + loaded.DeliverySchedule.Monday.OpenTime != "10:00" || + loaded.DeliverySchedule.Monday.CloseTime != "18:00" { + t.Errorf("delivery_schedule.monday mal persisté: got=%+v", loaded.DeliverySchedule.Monday) + } +} + +func TestUpdateSettings_PostalZonesRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.PostalZones = []models.PostalZone{ + {Name: "Zone Test", MinAmount: 42, Codes: []string{"11111", "22222"}}, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if len(loaded.PostalZones) != 1 || loaded.PostalZones[0].MinAmount != 42 || + len(loaded.PostalZones[0].Codes) != 2 { + t.Errorf("postal_zones mal persisté: got=%+v", loaded.PostalZones) + } +} + +func TestUpdateSettings_DeliveryModeRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.DeliveryMode = models.DeliveryModeConfig{ + Mode: "category_based", + CategoryRoutes: []models.CategoryRoute{ + {DeliverymanUsername: "livreur_test", Categories: []string{"catA"}}, + }, + } + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if loaded.DeliveryMode.Mode != "category_based" || len(loaded.DeliveryMode.CategoryRoutes) != 1 || + loaded.DeliveryMode.CategoryRoutes[0].DeliverymanUsername != "livreur_test" { + t.Errorf("delivery_mode mal persisté: got=%+v", loaded.DeliveryMode) + } + + // Repasser en mode "single" avec une liste vide doit aussi persister + // correctement (pas de résidu de l'ancienne liste category_routes). + s.DeliveryMode = models.DeliveryModeConfig{Mode: "single", CategoryRoutes: []models.CategoryRoute{}} + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings (retour single): %v", err) + } + loaded, err = testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (retour single): %v", err) + } + if loaded.DeliveryMode.Mode != "single" || len(loaded.DeliveryMode.CategoryRoutes) != 0 { + t.Errorf("delivery_mode retour à single mal persisté: got=%+v", loaded.DeliveryMode) + } +} + +func TestUpdateSettings_ShopAndTelegramTextFieldsRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.ShopName = "Ma Boutique Test" + s.TelegramBotToken = "123456:ABC-test-token" + s.TelegramBotUsername = "mon_bot_test" + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if loaded.ShopName != "Ma Boutique Test" { + t.Errorf("shop_name: got=%q want=%q", loaded.ShopName, "Ma Boutique Test") + } + if loaded.TelegramBotToken != "123456:ABC-test-token" { + t.Errorf("telegram_bot_token: got=%q", loaded.TelegramBotToken) + } + if loaded.TelegramBotUsername != "mon_bot_test" { + t.Errorf("telegram_bot_username: got=%q", loaded.TelegramBotUsername) + } + + // Effacer le token/username (chaîne vide) doit aussi persister tel quel — + // contrairement à contact_telegram qui a un repli explicite non-vide. + s.TelegramBotToken = "" + s.TelegramBotUsername = "" + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings (effacement): %v", err) + } + loaded, err = testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings (effacement): %v", err) + } + if loaded.TelegramBotToken != "" || loaded.TelegramBotUsername != "" { + t.Errorf("token/username effacés devraient rester vides: got token=%q username=%q", loaded.TelegramBotToken, loaded.TelegramBotUsername) + } +} + +func TestUpdateSettings_ColorAndGradientFieldsRoundTrip(t *testing.T) { + resetSettingsAfterTest(t) + + s := db.DefaultSettings() + s.AdminColorPrimary = "#111111" + s.ClientColorDanger = "#222222" + s.ClientTitleGradientFrom = "#333333" + s.ClientTitleGradientTo = "#444444" + if err := testDB.UpdateSettings(s); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } + + loaded, err := testDB.GetSettings() + if err != nil { + t.Fatalf("GetSettings: %v", err) + } + if loaded.AdminColorPrimary != "#111111" { + t.Errorf("admin_color_primary: got=%q", loaded.AdminColorPrimary) + } + if loaded.ClientColorDanger != "#222222" { + t.Errorf("client_color_danger: got=%q", loaded.ClientColorDanger) + } + if loaded.ClientTitleGradientFrom != "#333333" || loaded.ClientTitleGradientTo != "#444444" { + t.Errorf("client_title_gradient: got from=%q to=%q", loaded.ClientTitleGradientFrom, loaded.ClientTitleGradientTo) + } +}