diff --git a/k-notes-frontend/src/pages/privacy-policy.tsx b/k-notes-frontend/src/pages/privacy-policy.tsx index c2490b9..6c11b15 100644 --- a/k-notes-frontend/src/pages/privacy-policy.tsx +++ b/k-notes-frontend/src/pages/privacy-policy.tsx @@ -6,14 +6,14 @@ export default function PrivacyPolicyPage() { const appName = "K-Notes"; return ( -
+
{/* Header */}
-

+

Privacy Policy

diff --git a/notes-api/src/dto.rs b/notes-api/src/dto.rs index 0e31a23..5dd4200 100644 --- a/notes-api/src/dto.rs +++ b/notes-api/src/dto.rs @@ -68,7 +68,7 @@ impl From for TagResponse { fn from(tag: Tag) -> Self { Self { id: tag.id, - name: tag.name, + name: tag.name.into_inner(), // Convert TagName to String } } } @@ -91,7 +91,7 @@ impl From for NoteResponse { fn from(note: Note) -> Self { Self { id: note.id, - title: note.title, + title: note.title_str().to_string(), // Convert Option to String content: note.content, color: note.color, is_pinned: note.is_pinned, @@ -160,7 +160,7 @@ impl From for NoteVersionResponse { Self { id: version.id, note_id: version.note_id, - title: version.title, + title: version.title.unwrap_or_default(), // Convert Option to String content: version.content, created_at: version.created_at, } diff --git a/notes-api/src/main.rs b/notes-api/src/main.rs index dde665d..e5abb72 100644 --- a/notes-api/src/main.rs +++ b/notes-api/src/main.rs @@ -188,7 +188,7 @@ async fn main() -> anyhow::Result<()> { } async fn create_dev_user(pool: ¬es_infra::db::DatabasePool) -> anyhow::Result<()> { - use notes_domain::User; + use notes_domain::{Email, User}; use notes_infra::factory::build_user_repository; use password_auth::generate_hash; use uuid::Uuid; @@ -201,10 +201,12 @@ async fn create_dev_user(pool: ¬es_infra::db::DatabasePool) -> anyhow::Result let dev_user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); if user_repo.find_by_id(dev_user_id).await?.is_none() { let hash = generate_hash("password"); + let dev_email = Email::try_from("dev@localhost.com") + .map_err(|e| anyhow::anyhow!("Invalid dev email: {}", e))?; let user = User::with_id( dev_user_id, "dev|local", - "dev@localhost.com", + dev_email, Some(hash), chrono::Utc::now(), ); diff --git a/notes-api/src/routes/auth.rs b/notes-api/src/routes/auth.rs index 22ff697..9418805 100644 --- a/notes-api/src/routes/auth.rs +++ b/notes-api/src/routes/auth.rs @@ -4,7 +4,7 @@ use axum::{Json, extract::State, http::StatusCode}; use axum_login::AuthSession; use validator::Validate; -use notes_domain::User; +use notes_domain::{Email, User}; use password_auth::generate_hash; use crate::auth::{AuthBackend, AuthUser, Credentials}; @@ -43,9 +43,12 @@ pub async fn register( // Hash password let password_hash = generate_hash(&payload.password); - // Create use - // For local registration, we use email as subject - let user = User::new_local(&payload.email, &password_hash); + // Parse email string to Email newtype + let email = Email::try_from(payload.email) + .map_err(|e| ApiError::validation(format!("Invalid email: {}", e)))?; + + // Create user - for local registration, we use email as subject + let user = User::new_local(email, &password_hash); state.user_repo.save(&user).await.map_err(ApiError::from)?; @@ -108,7 +111,7 @@ pub async fn me( Ok(Json(crate::dto::UserResponse { id: user.0.id, - email: user.0.email.clone(), + email: user.0.email_str().to_string(), // Convert Email to String created_at: user.0.created_at, })) } diff --git a/notes-api/src/routes/notes.rs b/notes-api/src/routes/notes.rs index 8479ebe..f6dc189 100644 --- a/notes-api/src/routes/notes.rs +++ b/notes-api/src/routes/notes.rs @@ -10,7 +10,10 @@ use uuid::Uuid; use validator::Validate; use axum_login::AuthUser; -use notes_domain::{CreateNoteRequest as DomainCreateNote, UpdateNoteRequest as DomainUpdateNote}; +use notes_domain::{ + CreateNoteRequest as DomainCreateNote, NoteTitle, TagName, + UpdateNoteRequest as DomainUpdateNote, +}; use crate::auth::AuthBackend; use crate::dto::{CreateNoteRequest, ListNotesQuery, NoteResponse, SearchQuery, UpdateNoteRequest}; @@ -71,11 +74,30 @@ pub async fn create_note( .validate() .map_err(|e| ApiError::validation(e.to_string()))?; + // Parse title into NoteTitle (optional - empty string becomes None) + let title: Option = if payload.title.trim().is_empty() { + None + } else { + Some( + NoteTitle::try_from(payload.title) + .map_err(|e| ApiError::validation(format!("Invalid title: {}", e)))?, + ) + }; + + // Parse tags into TagName values + let tags: Vec = payload + .tags + .into_iter() + .map(|s| { + TagName::try_from(s).map_err(|e| ApiError::validation(format!("Invalid tag: {}", e))) + }) + .collect::, _>>()?; + let domain_req = DomainCreateNote { user_id, - title: payload.title, + title, content: payload.content, - tags: payload.tags, + tags, color: payload.color, is_pinned: payload.is_pinned, }; @@ -126,15 +148,40 @@ pub async fn update_note( .validate() .map_err(|e| ApiError::validation(e.to_string()))?; + // Parse optional title - Some(string) -> Some(Some(NoteTitle)) or Some(None) for empty + let title: Option> = match payload.title { + Some(t) if t.trim().is_empty() => Some(None), // Set title to None + Some(t) => { + Some(Some(NoteTitle::try_from(t).map_err(|e| { + ApiError::validation(format!("Invalid title: {}", e)) + })?)) + } + None => None, // Don't update title + }; + + // Parse optional tags + let tags: Option> = match payload.tags { + Some(tag_strings) => Some( + tag_strings + .into_iter() + .map(|s| { + TagName::try_from(s) + .map_err(|e| ApiError::validation(format!("Invalid tag: {}", e))) + }) + .collect::, _>>()?, + ), + None => None, + }; + let domain_req = DomainUpdateNote { id, user_id, - title: payload.title, + title, content: payload.content, is_pinned: payload.is_pinned, is_archived: payload.is_archived, color: payload.color, - tags: payload.tags, + tags, }; let note = state.note_service.update_note(domain_req).await?; diff --git a/notes-api/src/routes/tags.rs b/notes-api/src/routes/tags.rs index b3e084a..911d30a 100644 --- a/notes-api/src/routes/tags.rs +++ b/notes-api/src/routes/tags.rs @@ -9,6 +9,8 @@ use axum_login::{AuthSession, AuthUser}; use uuid::Uuid; use validator::Validate; +use notes_domain::TagName; + use crate::auth::AuthBackend; use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse}; use crate::error::{ApiError, ApiResult}; @@ -51,7 +53,11 @@ pub async fn create_tag( .validate() .map_err(|e| ApiError::validation(e.to_string()))?; - let tag = state.tag_service.create_tag(user_id, &payload.name).await?; + // Parse string to TagName at API boundary + let tag_name = TagName::try_from(payload.name) + .map_err(|e| ApiError::validation(format!("Invalid tag name: {}", e)))?; + + let tag = state.tag_service.create_tag(user_id, tag_name).await?; Ok((StatusCode::CREATED, Json(TagResponse::from(tag)))) } @@ -75,10 +81,11 @@ pub async fn rename_tag( .validate() .map_err(|e| ApiError::validation(e.to_string()))?; - let tag = state - .tag_service - .rename_tag(id, user_id, &payload.name) - .await?; + // Parse string to TagName at API boundary + let new_name = TagName::try_from(payload.name) + .map_err(|e| ApiError::validation(format!("Invalid tag name: {}", e)))?; + + let tag = state.tag_service.rename_tag(id, user_id, new_name).await?; Ok(Json(TagResponse::from(tag))) } diff --git a/notes-domain/src/entities.rs b/notes-domain/src/entities.rs index 969be8d..a0cd687 100644 --- a/notes-domain/src/entities.rs +++ b/notes-domain/src/entities.rs @@ -7,6 +7,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::value_objects::{Email, NoteTitle, TagName}; + /// Maximum number of tags allowed per note (business rule) pub const MAX_TAGS_PER_NOTE: usize = 10; @@ -20,7 +22,8 @@ pub struct User { /// OIDC subject identifier (unique per identity provider) /// For local auth, this can be the same as email pub subject: String, - pub email: String, + /// Validated email address + pub email: Email, /// Password hash for local authentication (Argon2 etc.) pub password_hash: Option, pub created_at: DateTime, @@ -28,22 +31,22 @@ pub struct User { impl User { /// Create a new user with the current timestamp - pub fn new(subject: impl Into, email: impl Into) -> Self { + pub fn new(subject: impl Into, email: Email) -> Self { Self { id: Uuid::new_v4(), subject: subject.into(), - email: email.into(), + email, password_hash: None, created_at: Utc::now(), } } /// Create a new user with password hash - pub fn new_local(email: impl Into, password_hash: impl Into) -> Self { - let email = email.into(); + pub fn new_local(email: Email, password_hash: impl Into) -> Self { + let subject = email.as_ref().to_string(); Self { id: Uuid::new_v4(), - subject: email.clone(), // Use email as subject for local auth + subject, // Use email as subject for local auth email, password_hash: Some(password_hash.into()), created_at: Utc::now(), @@ -51,21 +54,27 @@ impl User { } /// Create a user with a specific ID (for reconstruction from storage) + /// This accepts raw strings for compatibility with database reads. pub fn with_id( id: Uuid, subject: impl Into, - email: impl Into, + email: Email, password_hash: Option, created_at: DateTime, ) -> Self { Self { id, subject: subject.into(), - email: email.into(), + email, password_hash, created_at, } } + + /// Get email as string reference (convenience method) + pub fn email_str(&self) -> &str { + self.email.as_ref() + } } /// A tag that can be attached to notes. @@ -74,27 +83,29 @@ impl User { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Tag { pub id: Uuid, - pub name: String, + /// Validated tag name (1-50 chars, trimmed, lowercase) + pub name: TagName, pub user_id: Uuid, } impl Tag { /// Create a new tag for a user - pub fn new(name: impl Into, user_id: Uuid) -> Self { + pub fn new(name: TagName, user_id: Uuid) -> Self { Self { id: Uuid::new_v4(), - name: name.into(), + name, user_id, } } /// Create a tag with a specific ID (for reconstruction from storage) - pub fn with_id(id: Uuid, name: impl Into, user_id: Uuid) -> Self { - Self { - id, - name: name.into(), - user_id, - } + pub fn with_id(id: Uuid, name: TagName, user_id: Uuid) -> Self { + Self { id, name, user_id } + } + + /// Get name as string reference (convenience method) + pub fn name_str(&self) -> &str { + self.name.as_ref() } } @@ -102,11 +113,13 @@ impl Tag { /// /// Notes support Markdown content and can be pinned or archived. /// Each note can have up to [`MAX_TAGS_PER_NOTE`] tags. +/// Title is optional - users may create notes without a title. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Note { pub id: Uuid, pub user_id: Uuid, - pub title: String, + /// Optional title (max 200 chars when present) + pub title: Option, /// Content stored as Markdown text pub content: String, /// Background color of the note (hex or name) @@ -125,12 +138,12 @@ fn default_color() -> String { impl Note { /// Create a new note with the current timestamp - pub fn new(user_id: Uuid, title: impl Into, content: impl Into) -> Self { + pub fn new(user_id: Uuid, title: Option, content: impl Into) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), user_id, - title: title.into(), + title, content: content.into(), color: default_color(), is_pinned: false, @@ -160,8 +173,8 @@ impl Note { } /// Update the note's title - pub fn set_title(&mut self, title: impl Into) { - self.title = title.into(); + pub fn set_title(&mut self, title: Option) { + self.title = title; self.updated_at = Utc::now(); } @@ -180,6 +193,11 @@ impl Note { pub fn tag_count(&self) -> usize { self.tags.len() } + + /// Get title as string reference, returns empty string if None + pub fn title_str(&self) -> &str { + self.title.as_ref().map(|t| t.as_ref()).unwrap_or("") + } } /// A snapshot of a note's state at a specific point in time. @@ -187,13 +205,14 @@ impl Note { pub struct NoteVersion { pub id: Uuid, pub note_id: Uuid, - pub title: String, + /// Title at the time of snapshot (stored as string for historical purposes) + pub title: Option, pub content: String, pub created_at: DateTime, } impl NoteVersion { - pub fn new(note_id: Uuid, title: String, content: String) -> Self { + pub fn new(note_id: Uuid, title: Option, content: String) -> Self { Self { id: Uuid::new_v4(), note_id, @@ -268,27 +287,31 @@ mod tests { #[test] fn test_new_user_has_unique_id() { - let user1 = User::new("subject1", "user1@example.com"); - let user2 = User::new("subject2", "user2@example.com"); + let email1 = Email::try_from("user1@example.com").unwrap(); + let email2 = Email::try_from("user2@example.com").unwrap(); + let user1 = User::new("subject1", email1); + let user2 = User::new("subject2", email2); assert_ne!(user1.id, user2.id); } #[test] fn test_new_user_sets_fields_correctly() { - let user = User::new("oidc|123456", "test@example.com"); + let email = Email::try_from("test@example.com").unwrap(); + let user = User::new("oidc|123456", email); assert_eq!(user.subject, "oidc|123456"); - assert_eq!(user.email, "test@example.com"); + assert_eq!(user.email_str(), "test@example.com"); assert!(user.password_hash.is_none()); } #[test] fn test_new_local_user_sets_fields_correctly() { - let user = User::new_local("local@example.com", "hashed_secret"); + let email = Email::try_from("local@example.com").unwrap(); + let user = User::new_local(email, "hashed_secret"); assert_eq!(user.subject, "local@example.com"); - assert_eq!(user.email, "local@example.com"); + assert_eq!(user.email_str(), "local@example.com"); assert_eq!(user.password_hash, Some("hashed_secret".to_string())); } @@ -296,17 +319,12 @@ mod tests { fn test_user_with_id_preserves_all_fields() { let id = Uuid::new_v4(); let created_at = Utc::now(); - let user = User::with_id( - id, - "subject", - "email@test.com", - Some("hash".to_string()), - created_at, - ); + let email = Email::try_from("email@test.com").unwrap(); + let user = User::with_id(id, "subject", email, Some("hash".to_string()), created_at); assert_eq!(user.id, id); assert_eq!(user.subject, "subject"); - assert_eq!(user.email, "email@test.com"); + assert_eq!(user.email_str(), "email@test.com"); assert_eq!(user.password_hash, Some("hash".to_string())); assert_eq!(user.created_at, created_at); } @@ -318,8 +336,10 @@ mod tests { #[test] fn test_new_tag_has_unique_id() { let user_id = Uuid::new_v4(); - let tag1 = Tag::new("work", user_id); - let tag2 = Tag::new("personal", user_id); + let name1 = TagName::try_from("work").unwrap(); + let name2 = TagName::try_from("personal").unwrap(); + let tag1 = Tag::new(name1, user_id); + let tag2 = Tag::new(name2, user_id); assert_ne!(tag1.id, tag2.id); } @@ -327,20 +347,22 @@ mod tests { #[test] fn test_new_tag_associates_with_user() { let user_id = Uuid::new_v4(); - let tag = Tag::new("important", user_id); + let name = TagName::try_from("important").unwrap(); + let tag = Tag::new(name, user_id); assert_eq!(tag.user_id, user_id); - assert_eq!(tag.name, "important"); + assert_eq!(tag.name_str(), "important"); } #[test] fn test_tag_with_id_preserves_all_fields() { let id = Uuid::new_v4(); let user_id = Uuid::new_v4(); - let tag = Tag::with_id(id, "my-tag", user_id); + let name = TagName::try_from("my-tag").unwrap(); + let tag = Tag::with_id(id, name, user_id); assert_eq!(tag.id, id); - assert_eq!(tag.name, "my-tag"); + assert_eq!(tag.name_str(), "my-tag"); assert_eq!(tag.user_id, user_id); } } @@ -351,8 +373,10 @@ mod tests { #[test] fn test_new_note_has_unique_id() { let user_id = Uuid::new_v4(); - let note1 = Note::new(user_id, "Title 1", "Content 1"); - let note2 = Note::new(user_id, "Title 2", "Content 2"); + let title1 = NoteTitle::try_from("Title 1").ok(); + let title2 = NoteTitle::try_from("Title 2").ok(); + let note1 = Note::new(user_id, title1, "Content 1"); + let note2 = Note::new(user_id, title2, "Content 2"); assert_ne!(note1.id, note2.id); } @@ -360,20 +384,32 @@ mod tests { #[test] fn test_new_note_defaults() { let user_id = Uuid::new_v4(); - let note = Note::new(user_id, "My Note", "# Hello World"); + let title = NoteTitle::try_from("My Note").ok(); + let note = Note::new(user_id, title, "# Hello World"); assert_eq!(note.user_id, user_id); - assert_eq!(note.title, "My Note"); + assert_eq!(note.title_str(), "My Note"); assert_eq!(note.content, "# Hello World"); assert!(!note.is_pinned); assert!(!note.is_archived); assert!(note.tags.is_empty()); } + #[test] + fn test_new_note_without_title() { + let user_id = Uuid::new_v4(); + let note = Note::new(user_id, None, "Content without title"); + + assert!(note.title.is_none()); + assert_eq!(note.title_str(), ""); + assert_eq!(note.content, "Content without title"); + } + #[test] fn test_note_set_pinned_updates_timestamp() { let user_id = Uuid::new_v4(); - let mut note = Note::new(user_id, "Title", "Content"); + let title = NoteTitle::try_from("Title").ok(); + let mut note = Note::new(user_id, title, "Content"); let original_updated_at = note.updated_at; // Small delay to ensure timestamp changes @@ -387,7 +423,8 @@ mod tests { #[test] fn test_note_set_archived_updates_timestamp() { let user_id = Uuid::new_v4(); - let mut note = Note::new(user_id, "Title", "Content"); + let title = NoteTitle::try_from("Title").ok(); + let mut note = Note::new(user_id, title, "Content"); let original_updated_at = note.updated_at; std::thread::sleep(std::time::Duration::from_millis(10)); @@ -400,7 +437,8 @@ mod tests { #[test] fn test_note_can_add_tag_when_under_limit() { let user_id = Uuid::new_v4(); - let note = Note::new(user_id, "Title", "Content"); + let title = NoteTitle::try_from("Title").ok(); + let note = Note::new(user_id, title, "Content"); assert!(note.can_add_tag()); } @@ -408,11 +446,13 @@ mod tests { #[test] fn test_note_cannot_add_tag_when_at_limit() { let user_id = Uuid::new_v4(); - let mut note = Note::new(user_id, "Title", "Content"); + let title = NoteTitle::try_from("Title").ok(); + let mut note = Note::new(user_id, title, "Content"); // Add MAX_TAGS_PER_NOTE tags for i in 0..MAX_TAGS_PER_NOTE { - note.tags.push(Tag::new(format!("tag-{}", i), user_id)); + let tag_name = TagName::try_from(format!("tag-{}", i)).unwrap(); + note.tags.push(Tag::new(tag_name, user_id)); } assert!(!note.can_add_tag()); @@ -422,20 +462,23 @@ mod tests { #[test] fn test_note_set_title_updates_timestamp() { let user_id = Uuid::new_v4(); - let mut note = Note::new(user_id, "Original", "Content"); + let title = NoteTitle::try_from("Original").ok(); + let mut note = Note::new(user_id, title, "Content"); let original_updated_at = note.updated_at; std::thread::sleep(std::time::Duration::from_millis(10)); - note.set_title("Updated Title"); + let new_title = NoteTitle::try_from("Updated Title").ok(); + note.set_title(new_title); - assert_eq!(note.title, "Updated Title"); + assert_eq!(note.title_str(), "Updated Title"); assert!(note.updated_at > original_updated_at); } #[test] fn test_note_set_content_updates_timestamp() { let user_id = Uuid::new_v4(); - let mut note = Note::new(user_id, "Title", "Original"); + let title = NoteTitle::try_from("Title").ok(); + let mut note = Note::new(user_id, title, "Original"); let original_updated_at = note.updated_at; std::thread::sleep(std::time::Duration::from_millis(10)); diff --git a/notes-domain/src/lib.rs b/notes-domain/src/lib.rs index d4b8b7a..0ee58eb 100644 --- a/notes-domain/src/lib.rs +++ b/notes-domain/src/lib.rs @@ -7,12 +7,14 @@ //! - **Errors**: Domain-specific error types //! - **Repositories**: Port traits defining data access interfaces //! - **Services**: Use cases orchestrating business logic +//! - **Value Objects**: Validated newtypes for domain primitives pub mod entities; pub mod errors; pub mod ports; pub mod repositories; pub mod services; +pub mod value_objects; // Re-export commonly used types at crate root pub use entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, User}; @@ -20,3 +22,7 @@ pub use errors::{DomainError, DomainResult}; pub use ports::MessageBroker; pub use repositories::{NoteRepository, TagRepository, UserRepository}; pub use services::{CreateNoteRequest, NoteService, TagService, UpdateNoteRequest, UserService}; +pub use value_objects::{ + Email, MAX_NOTE_TITLE_LENGTH, MAX_TAG_NAME_LENGTH, MIN_PASSWORD_LENGTH, NoteTitle, Password, + TagName, ValidationError, +}; diff --git a/notes-domain/src/repositories.rs b/notes-domain/src/repositories.rs index 2c50652..a6b8964 100644 --- a/notes-domain/src/repositories.rs +++ b/notes-domain/src/repositories.rs @@ -88,6 +88,7 @@ pub trait TagRepository: Send + Sync { #[cfg(test)] pub(crate) mod tests { use super::*; + use crate::value_objects::NoteTitle; use std::collections::HashMap; use std::sync::Mutex; @@ -144,7 +145,7 @@ pub(crate) mod tests { .values() .filter(|n| n.user_id == user_id) .filter(|n| { - n.title.to_lowercase().contains(&query_lower) + n.title_str().to_lowercase().contains(&query_lower) || n.content.to_lowercase().contains(&query_lower) }) .cloned() @@ -171,14 +172,15 @@ pub(crate) mod tests { async fn test_mock_note_repository_save_and_find() { let repo = MockNoteRepository::new(); let user_id = Uuid::new_v4(); - let note = Note::new(user_id, "Test Note", "Test content"); + let title = NoteTitle::try_from("Test Note").ok(); + let note = Note::new(user_id, title, "Test content"); let note_id = note.id; repo.save(¬e).await.unwrap(); let found = repo.find_by_id(note_id).await.unwrap(); assert!(found.is_some()); - assert_eq!(found.unwrap().title, "Test Note"); + assert_eq!(found.unwrap().title_str(), "Test Note"); } #[tokio::test] @@ -186,11 +188,13 @@ pub(crate) mod tests { let repo = MockNoteRepository::new(); let user_id = Uuid::new_v4(); - let mut pinned_note = Note::new(user_id, "Pinned", "Content"); + let title_pinned = NoteTitle::try_from("Pinned").ok(); + let mut pinned_note = Note::new(user_id, title_pinned, "Content"); pinned_note.is_pinned = true; repo.save(&pinned_note).await.unwrap(); - let regular_note = Note::new(user_id, "Regular", "Content"); + let title_regular = NoteTitle::try_from("Regular").ok(); + let regular_note = Note::new(user_id, title_regular, "Content"); repo.save(®ular_note).await.unwrap(); let pinned_only = repo @@ -199,7 +203,7 @@ pub(crate) mod tests { .unwrap(); assert_eq!(pinned_only.len(), 1); - assert_eq!(pinned_only[0].title, "Pinned"); + assert_eq!(pinned_only[0].title_str(), "Pinned"); } #[tokio::test] @@ -207,17 +211,19 @@ pub(crate) mod tests { let repo = MockNoteRepository::new(); let user_id = Uuid::new_v4(); - let note1 = Note::new(user_id, "Shopping List", "Buy milk and eggs"); - let note2 = Note::new(user_id, "Meeting Notes", "Discuss project timeline"); + let title1 = NoteTitle::try_from("Shopping List").ok(); + let title2 = NoteTitle::try_from("Meeting Notes").ok(); + let note1 = Note::new(user_id, title1, "Buy milk and eggs"); + let note2 = Note::new(user_id, title2, "Discuss project timeline"); repo.save(¬e1).await.unwrap(); repo.save(¬e2).await.unwrap(); let results = repo.search(user_id, "milk").await.unwrap(); assert_eq!(results.len(), 1); - assert_eq!(results[0].title, "Shopping List"); + assert_eq!(results[0].title_str(), "Shopping List"); let results = repo.search(user_id, "notes").await.unwrap(); assert_eq!(results.len(), 1); - assert_eq!(results[0].title, "Meeting Notes"); + assert_eq!(results[0].title_str(), "Meeting Notes"); } } diff --git a/notes-domain/src/services.rs b/notes-domain/src/services.rs index e5c0384..348ff20 100644 --- a/notes-domain/src/services.rs +++ b/notes-domain/src/services.rs @@ -1,7 +1,7 @@ //! Domain services for K-Notes //! //! Services orchestrate business logic, enforce rules, and coordinate -//! between repositories. They are the "use cases" of the application. +//! between repositories. They are the \"use cases\" of the application. use std::sync::Arc; use uuid::Uuid; @@ -10,14 +10,17 @@ use crate::entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, Use use crate::errors::{DomainError, DomainResult}; use crate::ports::MessageBroker; use crate::repositories::{NoteRepository, TagRepository, UserRepository}; +use crate::value_objects::{Email, NoteTitle, TagName}; /// Request to create a new note #[derive(Debug, Clone)] pub struct CreateNoteRequest { pub user_id: Uuid, - pub title: String, + /// Title is optional - notes can have no title + pub title: Option, pub content: String, - pub tags: Vec, + /// Tags are pre-validated TagName values + pub tags: Vec, pub color: Option, pub is_pinned: bool, } @@ -27,12 +30,14 @@ pub struct CreateNoteRequest { pub struct UpdateNoteRequest { pub id: Uuid, pub user_id: Uuid, // For authorization check - pub title: Option, + /// None means "don't change", Some(None) means "remove title", Some(Some(t)) means "set title" + pub title: Option>, pub content: Option, pub is_pinned: Option, pub is_archived: Option, pub color: Option, - pub tags: Option>, + /// Pre-validated TagName values + pub tags: Option>, } /// Service for Note operations @@ -70,10 +75,8 @@ impl NoteService { /// Create a new note with optional tags pub async fn create_note(&self, req: CreateNoteRequest) -> DomainResult { - // Validate title is not empty - if req.title.trim().is_empty() { - return Err(DomainError::validation("Title cannot be empty")); - } + // Title validation is handled by NoteTitle type - no need for runtime check + // Tags are pre-validated as TagName values // Validate tag count if req.tags.len() > MAX_TAGS_PER_NOTE { @@ -89,7 +92,9 @@ impl NoteService { // Process tags for tag_name in &req.tags { - let tag = self.get_or_create_tag(req.user_id, tag_name).await?; + let tag = self + .get_or_create_tag(req.user_id, tag_name.clone()) + .await?; note.tags.push(tag); } @@ -124,14 +129,15 @@ impl NoteService { } // Create version snapshot (save current state) - let version = NoteVersion::new(note.id, note.title.clone(), note.content.clone()); + let version = NoteVersion::new( + note.id, + note.title.as_ref().map(|t| t.as_ref().to_string()), + note.content.clone(), + ); self.note_repo.save_version(&version).await?; - // Apply updates + // Apply updates - title is already validated via NoteTitle type if let Some(title) = req.title { - if title.trim().is_empty() { - return Err(DomainError::validation("Title cannot be empty")); - } note.set_title(title); } @@ -164,7 +170,7 @@ impl NoteService { // Add new tags note.tags.clear(); - for tag_name in &tag_names { + for tag_name in tag_names { let tag = self.get_or_create_tag(note.user_id, tag_name).await?; self.tag_repo.add_to_note(tag.id, note.id).await?; note.tags.push(tag); @@ -247,11 +253,9 @@ impl NoteService { /// /// Handles race conditions gracefully: if a concurrent request creates /// the same tag, we catch the unique constraint violation and retry the lookup. - async fn get_or_create_tag(&self, user_id: Uuid, name: &str) -> DomainResult { - let name = name.trim().to_lowercase(); - + async fn get_or_create_tag(&self, user_id: Uuid, name: TagName) -> DomainResult { // First, try to find existing tag - if let Some(tag) = self.tag_repo.find_by_name(user_id, &name).await? { + if let Some(tag) = self.tag_repo.find_by_name(user_id, name.as_ref()).await? { return Ok(tag); } @@ -264,7 +268,7 @@ impl NoteService { // Retry the lookup tracing::debug!(tag_name = %name, "Tag creation race condition detected, retrying lookup"); self.tag_repo - .find_by_name(user_id, &name) + .find_by_name(user_id, name.as_ref()) .await? .ok_or_else(|| DomainError::validation("Tag creation race condition")) } @@ -283,16 +287,16 @@ impl TagService { Self { tag_repo } } - /// Create a new tag - pub async fn create_tag(&self, user_id: Uuid, name: &str) -> DomainResult { - let name = name.trim().to_lowercase(); - if name.is_empty() { - return Err(DomainError::validation("Tag name cannot be empty")); - } - + /// Create a new tag (TagName is pre-validated) + pub async fn create_tag(&self, user_id: Uuid, name: TagName) -> DomainResult { // Check if tag already exists - if self.tag_repo.find_by_name(user_id, &name).await?.is_some() { - return Err(DomainError::TagAlreadyExists(name)); + if self + .tag_repo + .find_by_name(user_id, name.as_ref()) + .await? + .is_some() + { + return Err(DomainError::TagAlreadyExists(name.into_inner())); } let tag = Tag::new(name, user_id); @@ -322,13 +326,13 @@ impl TagService { self.tag_repo.delete(id).await } - /// Rename a tag - pub async fn rename_tag(&self, id: Uuid, user_id: Uuid, new_name: &str) -> DomainResult { - let new_name = new_name.trim().to_lowercase(); - if new_name.is_empty() { - return Err(DomainError::validation("Tag name cannot be empty")); - } - + /// Rename a tag (new_name is pre-validated TagName) + pub async fn rename_tag( + &self, + id: Uuid, + user_id: Uuid, + new_name: TagName, + ) -> DomainResult { // Find the existing tag let mut tag = self .tag_repo @@ -344,9 +348,13 @@ impl TagService { } // Check if new name already exists (and it's not the same tag) - if let Some(existing) = self.tag_repo.find_by_name(user_id, &new_name).await? { + if let Some(existing) = self + .tag_repo + .find_by_name(user_id, new_name.as_ref()) + .await? + { if existing.id != id { - return Err(DomainError::TagAlreadyExists(new_name)); + return Err(DomainError::TagAlreadyExists(new_name.into_inner())); } } @@ -372,7 +380,7 @@ impl UserService { pub async fn find_or_create_by_subject( &self, subject: &str, - email: &str, + email: Email, ) -> DomainResult { if let Some(user) = self.user_repo.find_by_subject(subject).await? { Ok(user) @@ -505,7 +513,7 @@ mod tests { .lock() .unwrap() .values() - .find(|t| t.user_id == user_id && t.name == name) + .find(|t| t.user_id == user_id && t.name.as_ref() == name) .cloned()) } @@ -574,7 +582,7 @@ mod tests { .lock() .unwrap() .values() - .find(|u| u.email == email) + .find(|u| u.email_str() == email) .cloned()) } @@ -603,9 +611,10 @@ mod tests { async fn test_create_note_success() { let (service, user_id) = create_note_service(); + let title = NoteTitle::try_from("My Note").ok(); let req = CreateNoteRequest { user_id, - title: "My Note".to_string(), + title, content: "# Hello World".to_string(), tags: vec![], color: None, @@ -614,7 +623,7 @@ mod tests { let note = service.create_note(req).await.unwrap(); - assert_eq!(note.title, "My Note"); + assert_eq!(note.title_str(), "My Note"); assert_eq!(note.content, "# Hello World"); assert_eq!(note.user_id, user_id); assert_eq!(note.color, "DEFAULT"); @@ -622,14 +631,39 @@ mod tests { } #[tokio::test] - async fn test_create_note_with_tags() { + async fn test_create_note_without_title() { let (service, user_id) = create_note_service(); let req = CreateNoteRequest { user_id, - title: "Tagged Note".to_string(), + title: None, + content: "Content without title".to_string(), + tags: vec![], + color: None, + is_pinned: false, + }; + + let note = service.create_note(req).await.unwrap(); + + assert!(note.title.is_none()); + assert_eq!(note.title_str(), ""); + assert_eq!(note.content, "Content without title"); + } + + #[tokio::test] + async fn test_create_note_with_tags() { + let (service, user_id) = create_note_service(); + + let title = NoteTitle::try_from("Tagged Note").ok(); + let tags = vec![ + TagName::try_from("work").unwrap(), + TagName::try_from("important").unwrap(), + ]; + let req = CreateNoteRequest { + user_id, + title, content: "Content".to_string(), - tags: vec!["work".to_string(), "important".to_string()], + tags, color: None, is_pinned: false, }; @@ -637,38 +671,22 @@ mod tests { let note = service.create_note(req).await.unwrap(); assert_eq!(note.tags.len(), 2); - assert!(note.tags.iter().any(|t| t.name == "work")); - assert!(note.tags.iter().any(|t| t.name == "important")); - } - - #[tokio::test] - async fn test_create_note_empty_title_fails() { - let (service, user_id) = create_note_service(); - - let req = CreateNoteRequest { - user_id, - title: " ".to_string(), // Whitespace only - content: "Content".to_string(), - tags: vec![], - color: None, - is_pinned: false, - }; - - let result = service.create_note(req).await; - assert!(matches!(result, Err(DomainError::ValidationError(_)))); + assert!(note.tags.iter().any(|t| t.name_str() == "work")); + assert!(note.tags.iter().any(|t| t.name_str() == "important")); } #[tokio::test] async fn test_create_note_too_many_tags_fails() { let (service, user_id) = create_note_service(); - let tags: Vec = (0..=MAX_TAGS_PER_NOTE) - .map(|i| format!("tag-{}", i)) + let tags: Vec = (0..=MAX_TAGS_PER_NOTE) + .map(|i| TagName::try_from(format!("tag-{}", i)).unwrap()) .collect(); + let title = NoteTitle::try_from("Note").ok(); let req = CreateNoteRequest { user_id, - title: "Note".to_string(), + title, content: "Content".to_string(), tags, color: None, @@ -684,9 +702,10 @@ mod tests { let (service, user_id) = create_note_service(); // Create a note first + let title = NoteTitle::try_from("Original").ok(); let create_req = CreateNoteRequest { user_id, - title: "Original".to_string(), + title, content: "Original content".to_string(), tags: vec![], color: None, @@ -695,10 +714,11 @@ mod tests { let note = service.create_note(create_req).await.unwrap(); // Update it + let new_title = NoteTitle::try_from("Updated").ok(); let update_req = UpdateNoteRequest { id: note.id, user_id, - title: Some("Updated".to_string()), + title: Some(new_title), content: None, is_pinned: Some(true), is_archived: None, @@ -707,7 +727,7 @@ mod tests { }; let updated = service.update_note(update_req).await.unwrap(); - assert_eq!(updated.title, "Updated"); + assert_eq!(updated.title_str(), "Updated"); assert_eq!(updated.content, "Original content"); // Unchanged assert!(updated.is_pinned); assert_eq!(updated.color, "red"); @@ -719,9 +739,10 @@ mod tests { let other_user = Uuid::new_v4(); // Create a note + let title = NoteTitle::try_from("My Note").ok(); let create_req = CreateNoteRequest { user_id, - title: "My Note".to_string(), + title, content: "Content".to_string(), tags: vec![], color: None, @@ -730,10 +751,11 @@ mod tests { let note = service.create_note(create_req).await.unwrap(); // Try to update with different user + let new_title = NoteTitle::try_from("Hacked").ok(); let update_req = UpdateNoteRequest { id: note.id, user_id: other_user, - title: Some("Hacked".to_string()), + title: Some(new_title), content: None, is_pinned: None, is_archived: None, @@ -749,9 +771,10 @@ mod tests { async fn test_delete_note_success() { let (service, user_id) = create_note_service(); + let title = NoteTitle::try_from("To Delete").ok(); let create_req = CreateNoteRequest { user_id, - title: "To Delete".to_string(), + title, content: "Content".to_string(), tags: vec![], color: None, @@ -772,14 +795,16 @@ mod tests { let results = service.search_notes(user_id, " ").await.unwrap(); assert!(results.is_empty()); } + #[tokio::test] async fn test_update_note_creates_version() { let (service, user_id) = create_note_service(); // Create original note + let title = NoteTitle::try_from("Original Title").ok(); let create_req = CreateNoteRequest { user_id, - title: "Original Title".to_string(), + title, content: "Original Content".to_string(), tags: vec![], color: None, @@ -788,10 +813,11 @@ mod tests { let note = service.create_note(create_req).await.unwrap(); // Update the note + let new_title = NoteTitle::try_from("New Title").ok(); let update_req = UpdateNoteRequest { id: note.id, user_id, - title: Some("New Title".to_string()), + title: Some(new_title), content: Some("New Content".to_string()), is_pinned: None, is_archived: None, @@ -809,7 +835,7 @@ mod tests { assert_eq!(versions.len(), 1); let version = &versions[0]; - assert_eq!(version.title, "Original Title"); + assert_eq!(version.title, Some("Original Title".to_string())); assert_eq!(version.content, "Original Content"); assert_eq!(version.note_id, note.id); } @@ -828,26 +854,22 @@ mod tests { async fn test_create_tag_success() { let (service, user_id) = create_tag_service(); - let tag = service.create_tag(user_id, "Work").await.unwrap(); + let name = TagName::try_from("Work").unwrap(); + let tag = service.create_tag(user_id, name).await.unwrap(); - assert_eq!(tag.name, "work"); // Lowercase + assert_eq!(tag.name_str(), "work"); // Lowercase assert_eq!(tag.user_id, user_id); } - #[tokio::test] - async fn test_create_tag_empty_fails() { - let (service, user_id) = create_tag_service(); - - let result = service.create_tag(user_id, " ").await; - assert!(matches!(result, Err(DomainError::ValidationError(_)))); - } - #[tokio::test] async fn test_create_duplicate_tag_fails() { let (service, user_id) = create_tag_service(); - service.create_tag(user_id, "work").await.unwrap(); - let result = service.create_tag(user_id, "WORK").await; // Case-insensitive + let name1 = TagName::try_from("work").unwrap(); + service.create_tag(user_id, name1).await.unwrap(); + + let name2 = TagName::try_from("WORK").unwrap(); // Case-insensitive + let result = service.create_tag(user_id, name2).await; assert!(matches!(result, Err(DomainError::TagAlreadyExists(_)))); } @@ -865,26 +887,29 @@ mod tests { async fn test_find_or_create_creates_new_user() { let service = create_user_service(); + let email = Email::try_from("test@example.com").unwrap(); let user = service - .find_or_create_by_subject("oidc|123", "test@example.com") + .find_or_create_by_subject("oidc|123", email) .await .unwrap(); assert_eq!(user.subject, "oidc|123"); - assert_eq!(user.email, "test@example.com"); + assert_eq!(user.email_str(), "test@example.com"); } #[tokio::test] async fn test_find_or_create_returns_existing_user() { let service = create_user_service(); + let email1 = Email::try_from("test@example.com").unwrap(); let user1 = service - .find_or_create_by_subject("oidc|123", "test@example.com") + .find_or_create_by_subject("oidc|123", email1) .await .unwrap(); + let email2 = Email::try_from("test@example.com").unwrap(); let user2 = service - .find_or_create_by_subject("oidc|123", "test@example.com") + .find_or_create_by_subject("oidc|123", email2) .await .unwrap(); diff --git a/notes-domain/src/value_objects.rs b/notes-domain/src/value_objects.rs new file mode 100644 index 0000000..979de18 --- /dev/null +++ b/notes-domain/src/value_objects.rs @@ -0,0 +1,496 @@ +//! Value Objects for K-Notes Domain +//! +//! Newtypes that encapsulate validation logic, following the "parse, don't validate" pattern. +//! These types can only be constructed if the input is valid, providing compile-time guarantees. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; +use thiserror::Error; + +// ============================================================================ +// Validation Error +// ============================================================================ + +/// Errors that occur when parsing/validating value objects +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum ValidationError { + #[error("Invalid email format: {0}")] + InvalidEmail(String), + + #[error("Password must be at least {min} characters, got {actual}")] + PasswordTooShort { min: usize, actual: usize }, + + #[error("Tag name must be 1-{max} characters, got {actual}")] + InvalidTagNameLength { max: usize, actual: usize }, + + #[error("Tag name cannot be empty")] + EmptyTagName, + + #[error("Note title cannot exceed {max} characters, got {actual}")] + TitleTooLong { max: usize, actual: usize }, +} + +// ============================================================================ +// Email +// ============================================================================ + +/// A validated email address. +/// +/// Simple validation: must contain exactly one `@` with non-empty parts on both sides. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Email(String); + +impl Email { + /// Minimum validation: contains @ with non-empty local and domain parts + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let trimmed = value.trim().to_lowercase(); + + // Basic email validation + let parts: Vec<&str> = trimmed.split('@').collect(); + if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + return Err(ValidationError::InvalidEmail(value)); + } + + // Domain must contain at least one dot + if !parts[1].contains('.') { + return Err(ValidationError::InvalidEmail(value)); + } + + Ok(Self(trimmed)) + } + + /// Get the inner value + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Email { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Email { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for Email { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl TryFrom<&str> for Email { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl Serialize for Email { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Email { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(serde::de::Error::custom) + } +} + +// ============================================================================ +// Password +// ============================================================================ + +/// A validated password input (NOT the hash). +/// +/// Enforces minimum length of 6 characters. +#[derive(Clone, PartialEq, Eq)] +pub struct Password(String); + +/// Minimum password length +pub const MIN_PASSWORD_LENGTH: usize = 6; + +impl Password { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + + if value.len() < MIN_PASSWORD_LENGTH { + return Err(ValidationError::PasswordTooShort { + min: MIN_PASSWORD_LENGTH, + actual: value.len(), + }); + } + + Ok(Self(value)) + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Password { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// Intentionally hide password content in Debug +impl fmt::Debug for Password { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Password(***)") + } +} + +impl TryFrom for Password { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl TryFrom<&str> for Password { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl<'de> Deserialize<'de> for Password { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(serde::de::Error::custom) + } +} + +// Note: Password should NOT implement Serialize to prevent accidental exposure + +// ============================================================================ +// TagName +// ============================================================================ + +/// A validated tag name. +/// +/// Enforces: 1-50 characters, trimmed and lowercase. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TagName(String); + +/// Maximum tag name length +pub const MAX_TAG_NAME_LENGTH: usize = 50; + +impl TagName { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let trimmed = value.trim().to_lowercase(); + + if trimmed.is_empty() { + return Err(ValidationError::EmptyTagName); + } + + if trimmed.len() > MAX_TAG_NAME_LENGTH { + return Err(ValidationError::InvalidTagNameLength { + max: MAX_TAG_NAME_LENGTH, + actual: trimmed.len(), + }); + } + + Ok(Self(trimmed)) + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for TagName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for TagName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for TagName { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl TryFrom<&str> for TagName { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl Serialize for TagName { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for TagName { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(serde::de::Error::custom) + } +} + +// ============================================================================ +// NoteTitle +// ============================================================================ + +/// A validated note title. +/// +/// Enforces: maximum 200 characters when present. Trimmed but preserves case. +/// Note: This is for the inner value; the title on a Note is Option. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct NoteTitle(String); + +/// Maximum note title length +pub const MAX_NOTE_TITLE_LENGTH: usize = 200; + +impl NoteTitle { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let trimmed = value.trim().to_string(); + + if trimmed.len() > MAX_NOTE_TITLE_LENGTH { + return Err(ValidationError::TitleTooLong { + max: MAX_NOTE_TITLE_LENGTH, + actual: trimmed.len(), + }); + } + + // Allow empty strings - this becomes None at the Note level + Ok(Self(trimmed)) + } + + /// Create from optional string, returning None for empty/whitespace + pub fn from_optional(value: Option) -> Result, ValidationError> { + match value { + None => Ok(None), + Some(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Self::new(trimmed).map(Some) + } + } + } + } + + pub fn into_inner(self) -> String { + self.0 + } + + /// Check if the title is empty + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl AsRef for NoteTitle { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for NoteTitle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for NoteTitle { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl TryFrom<&str> for NoteTitle { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl Serialize for NoteTitle { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for NoteTitle { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(serde::de::Error::custom) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + mod email_tests { + use super::*; + + #[test] + fn test_valid_email() { + assert!(Email::new("user@example.com").is_ok()); + assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase + assert!(Email::new(" user@example.com ").is_ok()); // Should trim + } + + #[test] + fn test_email_normalizes() { + let email = Email::new(" USER@EXAMPLE.COM ").unwrap(); + assert_eq!(email.as_ref(), "user@example.com"); + } + + #[test] + fn test_invalid_email_no_at() { + assert!(Email::new("userexample.com").is_err()); + } + + #[test] + fn test_invalid_email_no_domain() { + assert!(Email::new("user@").is_err()); + } + + #[test] + fn test_invalid_email_no_local() { + assert!(Email::new("@example.com").is_err()); + } + + #[test] + fn test_invalid_email_no_dot_in_domain() { + assert!(Email::new("user@localhost").is_err()); + } + } + + mod password_tests { + use super::*; + + #[test] + fn test_valid_password() { + assert!(Password::new("secret123").is_ok()); + assert!(Password::new("123456").is_ok()); // Exactly 6 chars + } + + #[test] + fn test_password_too_short() { + assert!(Password::new("12345").is_err()); // 5 chars + assert!(Password::new("").is_err()); + } + + #[test] + fn test_password_debug_hides_content() { + let password = Password::new("supersecret").unwrap(); + let debug = format!("{:?}", password); + assert!(!debug.contains("supersecret")); + assert!(debug.contains("***")); + } + } + + mod tag_name_tests { + use super::*; + + #[test] + fn test_valid_tag_name() { + assert!(TagName::new("work").is_ok()); + assert!(TagName::new(" WORK ").is_ok()); // Should trim and lowercase + } + + #[test] + fn test_tag_name_normalizes() { + let tag = TagName::new(" Important ").unwrap(); + assert_eq!(tag.as_ref(), "important"); + } + + #[test] + fn test_empty_tag_name_fails() { + assert!(TagName::new("").is_err()); + assert!(TagName::new(" ").is_err()); + } + + #[test] + fn test_tag_name_max_length() { + let long_name = "a".repeat(MAX_TAG_NAME_LENGTH); + assert!(TagName::new(&long_name).is_ok()); + + let too_long = "a".repeat(MAX_TAG_NAME_LENGTH + 1); + assert!(TagName::new(&too_long).is_err()); + } + } + + mod note_title_tests { + use super::*; + + #[test] + fn test_valid_title() { + assert!(NoteTitle::new("My Note").is_ok()); + assert!(NoteTitle::new("").is_ok()); // Empty is valid for NoteTitle + } + + #[test] + fn test_title_trims() { + let title = NoteTitle::new(" My Note ").unwrap(); + assert_eq!(title.as_ref(), "My Note"); + } + + #[test] + fn test_title_max_length() { + let long_title = "a".repeat(MAX_NOTE_TITLE_LENGTH); + assert!(NoteTitle::new(&long_title).is_ok()); + + let too_long = "a".repeat(MAX_NOTE_TITLE_LENGTH + 1); + assert!(NoteTitle::new(&too_long).is_err()); + } + + #[test] + fn test_from_optional_none() { + assert_eq!(NoteTitle::from_optional(None).unwrap(), None); + } + + #[test] + fn test_from_optional_empty() { + assert_eq!(NoteTitle::from_optional(Some("".into())).unwrap(), None); + assert_eq!(NoteTitle::from_optional(Some(" ".into())).unwrap(), None); + } + + #[test] + fn test_from_optional_valid() { + let result = NoteTitle::from_optional(Some("My Note".into())).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().as_ref(), "My Note"); + } + } +} diff --git a/notes-infra/src/note_repository.rs b/notes-infra/src/note_repository.rs index ec2578a..ca246c2 100644 --- a/notes-infra/src/note_repository.rs +++ b/notes-infra/src/note_repository.rs @@ -5,7 +5,10 @@ use chrono::{DateTime, Utc}; use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool}; use uuid::Uuid; -use notes_domain::{DomainError, DomainResult, Note, NoteFilter, NoteRepository, NoteVersion, Tag}; +use notes_domain::{ + DomainError, DomainResult, Note, NoteFilter, NoteRepository, NoteTitle, NoteVersion, Tag, + TagName, +}; /// SQLite adapter for NoteRepository pub struct SqliteNoteRepository { @@ -23,7 +26,7 @@ impl SqliteNoteRepository { struct NoteRowWithTags { id: String, user_id: String, - title: String, + title: Option, // Title can be NULL in the database content: String, color: String, is_pinned: i32, @@ -68,7 +71,12 @@ fn parse_tags_json(tags_json: &str) -> Result, DomainError> { let user_id = Uuid::parse_str(user_id_str) .map_err(|e| DomainError::RepositoryError(format!("Invalid tag user_id: {}", e)))?; - Ok(Tag::with_id(id, name.to_string(), user_id)) + // Parse TagName from stored string + let tag_name = TagName::try_from(name.to_string()).map_err(|e| { + DomainError::RepositoryError(format!("Invalid tag name in DB: {}", e)) + })?; + + Ok(Tag::with_id(id, tag_name, user_id)) }) .collect() } @@ -84,10 +92,18 @@ impl NoteRowWithTags { let updated_at = parse_datetime(&self.updated_at)?; let tags = parse_tags_json(&self.tags_json)?; + // Parse optional title - empty string or NULL maps to None + let title: Option = match self.title { + Some(t) if !t.trim().is_empty() => Some(NoteTitle::try_from(t).map_err(|e| { + DomainError::RepositoryError(format!("Invalid title in DB: {}", e)) + })?), + _ => None, + }; + Ok(Note { id, user_id, - title: self.title, + title, content: self.content, color: self.color, is_pinned: self.is_pinned != 0, @@ -103,7 +119,7 @@ impl NoteRowWithTags { struct NoteVersionRow { id: String, note_id: String, - title: String, + title: Option, // Title can be NULL content: String, created_at: String, } @@ -126,7 +142,7 @@ impl NoteVersionRow { Ok(NoteVersion { id, note_id, - title: self.title, + title: self.title, // Already Option content: self.content, created_at, }) @@ -222,6 +238,8 @@ impl NoteRepository for SqliteNoteRepository { let is_archived: i32 = if note.is_archived { 1 } else { 0 }; let created_at = note.created_at.to_rfc3339(); let updated_at = note.updated_at.to_rfc3339(); + // Convert Option to Option<&str> for binding + let title_str: Option<&str> = note.title.as_ref().map(|t| t.as_ref()); sqlx::query( r#" @@ -238,7 +256,7 @@ impl NoteRepository for SqliteNoteRepository { ) .bind(&id) .bind(&user_id) - .bind(¬e.title) + .bind(title_str) .bind(¬e.content) .bind(¬e.color) .bind(is_pinned) diff --git a/notes-infra/src/tag_repository.rs b/notes-infra/src/tag_repository.rs index 31ba761..e6155d3 100644 --- a/notes-infra/src/tag_repository.rs +++ b/notes-infra/src/tag_repository.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use sqlx::{FromRow, SqlitePool}; use uuid::Uuid; -use notes_domain::{DomainError, DomainResult, Tag, TagRepository}; +use notes_domain::{DomainError, DomainResult, Tag, TagName, TagRepository}; /// SQLite adapter for TagRepository pub struct SqliteTagRepository { @@ -33,7 +33,11 @@ impl TryFrom for Tag { let user_id = Uuid::parse_str(&row.user_id) .map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?; - Ok(Tag::with_id(id, row.name, user_id)) + // Parse TagName from stored string - was validated when originally stored + let name = TagName::try_from(row.name) + .map_err(|e| DomainError::RepositoryError(format!("Invalid tag name in DB: {}", e)))?; + + Ok(Tag::with_id(id, name, user_id)) } } @@ -87,7 +91,7 @@ impl TagRepository for SqliteTagRepository { "#, ) .bind(&id) - .bind(&tag.name) + .bind(tag.name.as_ref()) // Use .as_ref() to get the inner &str .bind(&user_id) .execute(&self.pool) .await @@ -160,7 +164,7 @@ mod tests { use super::*; use crate::db::{DatabaseConfig, DatabasePool, create_pool, run_migrations}; use crate::user_repository::SqliteUserRepository; - use notes_domain::{User, UserRepository}; + use notes_domain::{Email, User, UserRepository}; async fn setup_test_db() -> SqlitePool { let config = DatabaseConfig::in_memory(); @@ -172,7 +176,8 @@ mod tests { async fn create_test_user(pool: &SqlitePool) -> User { let user_repo = SqliteUserRepository::new(pool.clone()); - let user = User::new("test|user", "test@example.com"); + let email = Email::try_from("test@example.com").unwrap(); + let user = User::new("test|user", email); user_repo.save(&user).await.unwrap(); user } @@ -183,12 +188,13 @@ mod tests { let user = create_test_user(&pool).await; let repo = SqliteTagRepository::new(pool); - let tag = Tag::new("work", user.id); + let name = TagName::try_from("work").unwrap(); + let tag = Tag::new(name, user.id); repo.save(&tag).await.unwrap(); let found = repo.find_by_id(tag.id).await.unwrap(); assert!(found.is_some()); - assert_eq!(found.unwrap().name, "work"); + assert_eq!(found.unwrap().name_str(), "work"); } #[tokio::test] @@ -197,7 +203,8 @@ mod tests { let user = create_test_user(&pool).await; let repo = SqliteTagRepository::new(pool); - let tag = Tag::new("important", user.id); + let name = TagName::try_from("important").unwrap(); + let tag = Tag::new(name, user.id); repo.save(&tag).await.unwrap(); let found = repo.find_by_name(user.id, "important").await.unwrap(); @@ -211,13 +218,15 @@ mod tests { let user = create_test_user(&pool).await; let repo = SqliteTagRepository::new(pool); - repo.save(&Tag::new("alpha", user.id)).await.unwrap(); - repo.save(&Tag::new("beta", user.id)).await.unwrap(); + let name_alpha = TagName::try_from("alpha").unwrap(); + let name_beta = TagName::try_from("beta").unwrap(); + repo.save(&Tag::new(name_alpha, user.id)).await.unwrap(); + repo.save(&Tag::new(name_beta, user.id)).await.unwrap(); let tags = repo.find_by_user(user.id).await.unwrap(); assert_eq!(tags.len(), 2); // Should be sorted alphabetically - assert_eq!(tags[0].name, "alpha"); - assert_eq!(tags[1].name, "beta"); + assert_eq!(tags[0].name_str(), "alpha"); + assert_eq!(tags[1].name_str(), "beta"); } } diff --git a/notes-infra/src/user_repository.rs b/notes-infra/src/user_repository.rs index 056768b..1f8b242 100644 --- a/notes-infra/src/user_repository.rs +++ b/notes-infra/src/user_repository.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Utc}; use sqlx::{FromRow, SqlitePool}; use uuid::Uuid; -use notes_domain::{DomainError, DomainResult, User, UserRepository}; +use notes_domain::{DomainError, DomainResult, Email, User, UserRepository}; /// SQLite adapter for UserRepository pub struct SqliteUserRepository { @@ -43,10 +43,14 @@ impl TryFrom for User { }) .map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?; + // Parse email from string - it was validated when originally stored + let email = Email::try_from(row.email) + .map_err(|e| DomainError::RepositoryError(format!("Invalid email in DB: {}", e)))?; + Ok(User::with_id( id, row.subject, - row.email, + email, row.password_hash, created_at, )) @@ -108,7 +112,7 @@ impl UserRepository for SqliteUserRepository { ) .bind(&id) .bind(&user.subject) - .bind(&user.email) + .bind(user.email.as_ref()) // Use .as_ref() to get the inner &str .bind(&user.password_hash) .bind(&created_at) .execute(&self.pool) @@ -148,14 +152,15 @@ mod tests { let pool = setup_test_db().await; let repo = SqliteUserRepository::new(pool); - let user = User::new("oidc|123", "test@example.com"); + let email = Email::try_from("test@example.com").unwrap(); + let user = User::new("oidc|123", email); repo.save(&user).await.unwrap(); let found = repo.find_by_id(user.id).await.unwrap(); assert!(found.is_some()); let found = found.unwrap(); assert_eq!(found.subject, "oidc|123"); - assert_eq!(found.email, "test@example.com"); + assert_eq!(found.email_str(), "test@example.com"); assert!(found.password_hash.is_none()); } @@ -164,13 +169,14 @@ mod tests { let pool = setup_test_db().await; let repo = SqliteUserRepository::new(pool); - let user = User::new_local("local@example.com", "hashed_pw"); + let email = Email::try_from("local@example.com").unwrap(); + let user = User::new_local(email, "hashed_pw"); repo.save(&user).await.unwrap(); let found = repo.find_by_id(user.id).await.unwrap(); assert!(found.is_some()); let found = found.unwrap(); - assert_eq!(found.email, "local@example.com"); + assert_eq!(found.email_str(), "local@example.com"); assert_eq!(found.password_hash, Some("hashed_pw".to_string())); } @@ -179,7 +185,8 @@ mod tests { let pool = setup_test_db().await; let repo = SqliteUserRepository::new(pool); - let user = User::new("google|456", "user@gmail.com"); + let email = Email::try_from("user@gmail.com").unwrap(); + let user = User::new("google|456", email); repo.save(&user).await.unwrap(); let found = repo.find_by_subject("google|456").await.unwrap(); @@ -192,7 +199,8 @@ mod tests { let pool = setup_test_db().await; let repo = SqliteUserRepository::new(pool); - let user = User::new("test|789", "delete@test.com"); + let email = Email::try_from("delete@test.com").unwrap(); + let user = User::new("test|789", email); repo.save(&user).await.unwrap(); repo.delete(user.id).await.unwrap();