//! Source model and request/response types. //! //! Sources represent user-curated URLs (blogs, news sites, etc.) //! that the AI should prioritize during synthesis generation. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// A source record from the database. #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct Source { pub id: Uuid, pub user_id: Uuid, pub title: String, pub url: String, pub theme_id: Option, pub is_preferred: bool, pub rss_url: Option, pub rss_discovered_at: Option>, pub created_at: DateTime, } /// Response shape for source endpoints. #[derive(Debug, Serialize)] pub struct SourceResponse { pub id: Uuid, pub title: String, pub url: String, pub theme_id: Option, pub is_preferred: bool, pub created_at: DateTime, } impl From for SourceResponse { fn from(s: Source) -> Self { Self { id: s.id, title: s.title, url: s.url, theme_id: s.theme_id, is_preferred: s.is_preferred, created_at: s.created_at, } } } /// Request body for `POST /api/v1/sources`. #[derive(Debug, Deserialize)] pub struct CreateSourceRequest { pub title: String, pub url: String, #[serde(default)] pub theme_id: Option, } impl CreateSourceRequest { /// Validate the source creation request. /// /// Returns `Ok(())` if both fields are within acceptable bounds, /// or `Err(message)` describing the first validation failure. pub fn validate(&self) -> Result<(), String> { validate_title(&self.title)?; validate_url(&self.url)?; Ok(()) } } /// Request body for `PUT /api/v1/sources/preferred`. #[derive(Debug, Deserialize)] pub struct UpdatePreferredRequest { pub source_ids: Vec, pub theme_id: Uuid, } /// Request body for `POST /api/v1/sources/bulk`. #[derive(Debug, Deserialize)] pub struct BulkImportRequest { pub sources: Vec, pub theme_id: Option, } /// Response for bulk import operations (JSON and CSV). #[derive(Debug, Serialize)] pub struct BulkImportResponse { pub imported: usize, pub skipped: usize, pub errors: Vec, } /// Validate a source title. /// /// Must be non-empty (after trimming) and at most 200 characters. pub fn validate_title(title: &str) -> Result<(), String> { if title.trim().is_empty() { return Err("Title cannot be empty".into()); } if title.len() > 200 { return Err("Title must be at most 200 characters".into()); } Ok(()) } /// Validate a source URL. /// /// Must start with `http://` or `https://` and be at most 1000 characters. pub fn validate_url(url: &str) -> Result<(), String> { if url.trim().is_empty() { return Err("URL cannot be empty".into()); } if url.len() > 1000 { return Err("URL must be at most 1000 characters".into()); } if !url.starts_with("http://") && !url.starts_with("https://") { return Err("URL must start with http:// or https://".into()); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_valid_source_request() { let req = CreateSourceRequest { title: "My Blog".into(), url: "https://example.com".into(), theme_id: None, }; assert!(req.validate().is_ok()); } #[test] fn test_empty_title() { let req = CreateSourceRequest { title: " ".into(), url: "https://example.com".into(), theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("Title")); } #[test] fn test_title_too_long() { let req = CreateSourceRequest { title: "a".repeat(201), url: "https://example.com".into(), theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("200")); } #[test] fn test_empty_url() { let req = CreateSourceRequest { title: "Blog".into(), url: "".into(), theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("URL")); } #[test] fn test_url_too_long() { let long_url = format!("https://example.com/{}", "a".repeat(990)); let req = CreateSourceRequest { title: "Blog".into(), url: long_url, theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("1000")); } #[test] fn test_url_invalid_scheme_ftp() { let req = CreateSourceRequest { title: "Blog".into(), url: "ftp://example.com".into(), theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("http")); } #[test] fn test_url_invalid_scheme_javascript() { let req = CreateSourceRequest { title: "Blog".into(), url: "javascript:alert(1)".into(), theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("http")); } #[test] fn test_url_no_scheme() { let req = CreateSourceRequest { title: "Blog".into(), url: "example.com".into(), theme_id: None, }; let err = req.validate().unwrap_err(); assert!(err.contains("http")); } #[test] fn test_valid_http_url() { let req = CreateSourceRequest { title: "Blog".into(), url: "http://example.com".into(), theme_id: None, }; assert!(req.validate().is_ok()); } #[test] fn test_valid_https_url() { let req = CreateSourceRequest { title: "Blog".into(), url: "https://example.com/path?query=1".into(), theme_id: None, }; assert!(req.validate().is_ok()); } #[test] fn test_title_exactly_200_chars() { let req = CreateSourceRequest { title: "a".repeat(200), url: "https://example.com".into(), theme_id: None, }; assert!(req.validate().is_ok()); } #[test] fn test_url_exactly_1000_chars() { let url = format!("https://example.com/{}", "a".repeat(980)); assert!(url.len() == 1000); let req = CreateSourceRequest { title: "Blog".into(), url, theme_id: None, }; assert!(req.validate().is_ok()); } }