You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
408 lines
13 KiB
Rust
408 lines
13 KiB
Rust
//! 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<String>,
|
|
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<i32>,
|
|
pub rate_limit_time_window_seconds: Option<i32>,
|
|
#[serde(skip_serializing)]
|
|
pub updated_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Request body for `PUT /api/v1/settings`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateSettingsRequest {
|
|
pub theme: String,
|
|
pub max_age_days: i32,
|
|
pub categories: Vec<String>,
|
|
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<i32>,
|
|
pub rate_limit_time_window_seconds: Option<i32>,
|
|
}
|
|
|
|
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<String> = (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"));
|
|
}
|
|
|
|
}
|