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.

254 lines
6.6 KiB
Rust

//! 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<Uuid>,
pub is_preferred: bool,
pub rss_url: Option<String>,
pub rss_discovered_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
/// Response shape for source endpoints.
#[derive(Debug, Serialize)]
pub struct SourceResponse {
pub id: Uuid,
pub title: String,
pub url: String,
pub theme_id: Option<Uuid>,
pub is_preferred: bool,
pub created_at: DateTime<Utc>,
}
impl From<Source> 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<Uuid>,
}
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<Uuid>,
pub theme_id: Uuid,
}
/// Request body for `POST /api/v1/sources/bulk`.
#[derive(Debug, Deserialize)]
pub struct BulkImportRequest {
pub sources: Vec<CreateSourceRequest>,
pub theme_id: Option<Uuid>,
}
/// Response for bulk import operations (JSON and CSV).
#[derive(Debug, Serialize)]
pub struct BulkImportResponse {
pub imported: usize,
pub skipped: usize,
pub errors: Vec<String>,
}
/// 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());
}
}