//! User settings model. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// User settings record from the database. #[derive(Debug, Clone, Serialize)] pub struct UserSettings { #[serde(skip_serializing)] pub user_id: Uuid, pub theme: String, pub max_age_days: i32, pub categories: Vec, pub max_items_per_category: i32, pub max_articles_per_source: i32, pub use_llm_for_source_links: bool, pub use_brave_search: bool, pub article_history_days: i32, pub batch_size: i32, pub summary_length: i32, pub source_extraction_window: i32, pub search_agent_behavior: String, pub ai_provider: String, pub ai_model: String, pub ai_model_websearch: String, pub rate_limit_max_requests: Option, pub rate_limit_time_window_seconds: Option, #[serde(skip_serializing)] pub updated_at: DateTime, } /// Request body for `PUT /api/v1/settings`. #[derive(Debug, Deserialize)] pub struct UpdateSettingsRequest { pub theme: String, pub max_age_days: i32, pub categories: Vec, pub max_items_per_category: i32, pub max_articles_per_source: i32, pub use_llm_for_source_links: bool, pub use_brave_search: bool, pub article_history_days: i32, pub batch_size: i32, pub summary_length: i32, pub source_extraction_window: i32, pub search_agent_behavior: String, pub ai_provider: String, pub ai_model: String, pub ai_model_websearch: String, pub rate_limit_max_requests: Option, pub rate_limit_time_window_seconds: Option, } impl UpdateSettingsRequest { /// Validate the settings update request. /// /// Returns `Ok(())` if all fields are within acceptable bounds, /// or `Err(message)` describing the first validation failure. pub fn validate(&self) -> Result<(), String> { if self.theme.trim().is_empty() { return Err("Theme cannot be empty".into()); } if self.theme.len() > 200 { return Err("Theme must be at most 200 characters".into()); } if !(1..=365).contains(&self.max_age_days) { return Err("max_age_days must be between 1 and 365".into()); } if self.categories.is_empty() { return Err("Categories cannot be empty".into()); } if self.categories.len() > 20 { return Err("At most 20 categories are allowed".into()); } for (i, cat) in self.categories.iter().enumerate() { if cat.trim().is_empty() { return Err(format!("Category at index {} cannot be empty", i)); } if cat.len() > 200 { return Err(format!( "Category at index {} must be at most 200 characters", i )); } } if !(1..=50).contains(&self.max_items_per_category) { return Err("max_items_per_category must be between 1 and 50".into()); } if !(1..=10).contains(&self.max_articles_per_source) { return Err("max_articles_per_source must be between 1 and 10".into()); } if !(0..=365).contains(&self.article_history_days) { return Err("article_history_days must be between 0 and 365".into()); } if !(1..=20).contains(&self.batch_size) { return Err("batch_size must be between 1 and 20".into()); } if !(1..=3).contains(&self.summary_length) { return Err("summary_length must be between 1 and 3".into()); } if !(1..=10).contains(&self.source_extraction_window) { return Err("source_extraction_window must be between 1 and 10".into()); } if self.search_agent_behavior.len() > 2000 { return Err("search_agent_behavior must be at most 2000 characters".into()); } if self.ai_provider.len() > 100 { return Err("ai_provider must be at most 100 characters".into()); } if self.ai_model.len() > 100 { return Err("ai_model must be at most 100 characters".into()); } if self.ai_model_websearch.len() > 100 { return Err("ai_model_websearch must be at most 100 characters".into()); } if let Some(max_req) = self.rate_limit_max_requests { if max_req < 1 { return Err("rate_limit_max_requests must be >= 1".into()); } } if let Some(window) = self.rate_limit_time_window_seconds { if window < 1 { return Err("rate_limit_time_window_seconds must be >= 1".into()); } } Ok(()) } } /// Default settings values used when a user has no saved settings. impl Default for UserSettings { fn default() -> Self { Self { user_id: Uuid::nil(), theme: "Intelligence Artificielle".to_string(), max_age_days: 7, categories: vec![ "Annonces majeures".to_string(), "Recherche et innovation".to_string(), "Industrie et entreprises".to_string(), "Secteur public".to_string(), "Opinions et analyses".to_string(), ], max_items_per_category: 4, max_articles_per_source: 3, use_llm_for_source_links: false, use_brave_search: false, article_history_days: 90, batch_size: 5, summary_length: 3, source_extraction_window: 3, search_agent_behavior: String::new(), ai_provider: String::new(), ai_model: String::new(), ai_model_websearch: String::new(), rate_limit_max_requests: None, rate_limit_time_window_seconds: None, updated_at: Utc::now(), } } } #[cfg(test)] mod tests { use super::*; /// Helper to create a valid request with all new fields populated. fn valid_request() -> UpdateSettingsRequest { UpdateSettingsRequest { theme: "Intelligence Artificielle".into(), max_age_days: 7, categories: vec!["Category 1".into(), "Category 2".into()], max_items_per_category: 4, max_articles_per_source: 3, use_llm_for_source_links: false, use_brave_search: false, article_history_days: 90, batch_size: 5, summary_length: 3, source_extraction_window: 3, search_agent_behavior: String::new(), ai_provider: String::new(), ai_model: String::new(), ai_model_websearch: String::new(), rate_limit_max_requests: None, rate_limit_time_window_seconds: None, } } #[test] fn test_valid_settings() { let req = valid_request(); assert!(req.validate().is_ok()); } #[test] fn test_empty_theme() { let req = UpdateSettingsRequest { theme: " ".into(), ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("Theme")); } #[test] fn test_theme_too_long() { let req = UpdateSettingsRequest { theme: "a".repeat(201), ..valid_request() }; assert!(req.validate().is_err()); } #[test] fn test_max_age_days_below_range() { let req = UpdateSettingsRequest { max_age_days: 0, ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("max_age_days")); } #[test] fn test_max_age_days_above_range() { let req = UpdateSettingsRequest { max_age_days: 366, ..valid_request() }; assert!(req.validate().is_err()); } #[test] fn test_empty_categories() { let req = UpdateSettingsRequest { categories: vec![], ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("Categories")); } #[test] fn test_too_many_categories() { let cats: Vec = (0..21).map(|i| format!("Cat {}", i)).collect(); let req = UpdateSettingsRequest { categories: cats, ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("20")); } #[test] fn test_empty_category_item() { let req = UpdateSettingsRequest { categories: vec!["Good".into(), " ".into()], ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("index 1")); } #[test] fn test_max_items_below_range() { let req = UpdateSettingsRequest { max_items_per_category: 0, ..valid_request() }; assert!(req.validate().is_err()); } #[test] fn test_max_items_above_range() { let req = UpdateSettingsRequest { max_items_per_category: 51, ..valid_request() }; assert!(req.validate().is_err()); } #[test] fn test_search_agent_behavior_too_long() { let req = UpdateSettingsRequest { search_agent_behavior: "a".repeat(2001), ..valid_request() }; assert!(req.validate().is_err()); } #[test] fn test_boundary_values_valid() { let req = UpdateSettingsRequest { theme: "A".into(), max_age_days: 1, categories: vec!["Cat".into()], max_items_per_category: 1, search_agent_behavior: String::new(), ..valid_request() }; assert!(req.validate().is_ok()); let req = UpdateSettingsRequest { theme: "A".into(), max_age_days: 365, categories: (0..20).map(|i| format!("Cat {}", i)).collect(), max_items_per_category: 50, search_agent_behavior: "a".repeat(2000), ..valid_request() }; assert!(req.validate().is_ok()); } #[test] fn test_validate_with_model_fields() { let req = UpdateSettingsRequest { ai_provider: "google".into(), ai_model: "gemini-2.5-pro".into(), ai_model_websearch: "gemini-2.5-flash".into(), ..valid_request() }; assert!(req.validate().is_ok()); } #[test] fn test_validate_rate_limit_zero_rejected() { let req = UpdateSettingsRequest { rate_limit_max_requests: Some(0), ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("rate_limit_max_requests")); } #[test] fn test_validate_rate_limit_zero_window_rejected() { let req = UpdateSettingsRequest { rate_limit_time_window_seconds: Some(0), ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("rate_limit_time_window_seconds")); } #[test] fn test_validate_rate_limit_valid() { let req = UpdateSettingsRequest { rate_limit_max_requests: Some(29), rate_limit_time_window_seconds: Some(60), ..valid_request() }; assert!(req.validate().is_ok()); } #[test] fn test_validate_ai_provider_too_long_rejected() { let req = UpdateSettingsRequest { ai_provider: "a".repeat(101), ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("ai_provider")); } #[test] fn test_validate_ai_model_too_long_rejected() { let req = UpdateSettingsRequest { ai_model: "a".repeat(101), ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("ai_model")); } #[test] fn test_validate_batch_size_below_range() { let req = UpdateSettingsRequest { batch_size: 0, ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("batch_size")); } #[test] fn test_validate_batch_size_above_range() { let req = UpdateSettingsRequest { batch_size: 21, ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("batch_size")); } #[test] fn test_validate_ai_model_websearch_too_long_rejected() { let req = UpdateSettingsRequest { ai_model_websearch: "a".repeat(101), ..valid_request() }; let err = req.validate().unwrap_err(); assert!(err.contains("ai_model_websearch")); } }