Newtypes and broker refactor #10

Merged
GKaszewski merged 3 commits from newtypes into master 2026-01-02 00:22:55 +00:00
14 changed files with 881 additions and 211 deletions
Showing only changes of commit 93170d17dc - Show all commits

View File

@@ -6,14 +6,14 @@ export default function PrivacyPolicyPage() {
const appName = "K-Notes"; const appName = "K-Notes";
return ( return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20"> <div className="min-h-screen bg-linear-to-br from-background via-background to-muted/20">
<div className="max-w-4xl mx-auto px-4 py-12 space-y-8"> <div className="max-w-4xl mx-auto px-4 py-12 space-y-8">
{/* Header */} {/* Header */}
<div className="text-center space-y-4 mb-12"> <div className="text-center space-y-4 mb-12">
<div className="flex justify-center"> <div className="flex justify-center">
<Shield className="h-16 w-16 text-primary" /> <Shield className="h-16 w-16 text-primary" />
</div> </div>
<h1 className="text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60"> <h1 className="text-4xl font-bold bg-clip-text text-transparent bg-linear-gradient-to-r from-primary to-primary/60">
Privacy Policy Privacy Policy
</h1> </h1>
<div className="flex items-center justify-center gap-2 text-muted-foreground"> <div className="flex items-center justify-center gap-2 text-muted-foreground">

View File

@@ -68,7 +68,7 @@ impl From<Tag> for TagResponse {
fn from(tag: Tag) -> Self { fn from(tag: Tag) -> Self {
Self { Self {
id: tag.id, id: tag.id,
name: tag.name, name: tag.name.into_inner(), // Convert TagName to String
} }
} }
} }
@@ -91,7 +91,7 @@ impl From<Note> for NoteResponse {
fn from(note: Note) -> Self { fn from(note: Note) -> Self {
Self { Self {
id: note.id, id: note.id,
title: note.title, title: note.title_str().to_string(), // Convert Option<NoteTitle> to String
content: note.content, content: note.content,
color: note.color, color: note.color,
is_pinned: note.is_pinned, is_pinned: note.is_pinned,
@@ -160,7 +160,7 @@ impl From<notes_domain::NoteVersion> for NoteVersionResponse {
Self { Self {
id: version.id, id: version.id,
note_id: version.note_id, note_id: version.note_id,
title: version.title, title: version.title.unwrap_or_default(), // Convert Option<String> to String
content: version.content, content: version.content,
created_at: version.created_at, created_at: version.created_at,
} }

View File

@@ -188,7 +188,7 @@ async fn main() -> anyhow::Result<()> {
} }
async fn create_dev_user(pool: &notes_infra::db::DatabasePool) -> anyhow::Result<()> { async fn create_dev_user(pool: &notes_infra::db::DatabasePool) -> anyhow::Result<()> {
use notes_domain::User; use notes_domain::{Email, User};
use notes_infra::factory::build_user_repository; use notes_infra::factory::build_user_repository;
use password_auth::generate_hash; use password_auth::generate_hash;
use uuid::Uuid; use uuid::Uuid;
@@ -201,10 +201,12 @@ async fn create_dev_user(pool: &notes_infra::db::DatabasePool) -> anyhow::Result
let dev_user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); 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() { if user_repo.find_by_id(dev_user_id).await?.is_none() {
let hash = generate_hash("password"); 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( let user = User::with_id(
dev_user_id, dev_user_id,
"dev|local", "dev|local",
"dev@localhost.com", dev_email,
Some(hash), Some(hash),
chrono::Utc::now(), chrono::Utc::now(),
); );

View File

@@ -4,7 +4,7 @@ use axum::{Json, extract::State, http::StatusCode};
use axum_login::AuthSession; use axum_login::AuthSession;
use validator::Validate; use validator::Validate;
use notes_domain::User; use notes_domain::{Email, User};
use password_auth::generate_hash; use password_auth::generate_hash;
use crate::auth::{AuthBackend, AuthUser, Credentials}; use crate::auth::{AuthBackend, AuthUser, Credentials};
@@ -43,9 +43,12 @@ pub async fn register(
// Hash password // Hash password
let password_hash = generate_hash(&payload.password); let password_hash = generate_hash(&payload.password);
// Create use // Parse email string to Email newtype
// For local registration, we use email as subject let email = Email::try_from(payload.email)
let user = User::new_local(&payload.email, &password_hash); .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)?; state.user_repo.save(&user).await.map_err(ApiError::from)?;
@@ -108,7 +111,7 @@ pub async fn me(
Ok(Json(crate::dto::UserResponse { Ok(Json(crate::dto::UserResponse {
id: user.0.id, 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, created_at: user.0.created_at,
})) }))
} }

View File

@@ -10,7 +10,10 @@ use uuid::Uuid;
use validator::Validate; use validator::Validate;
use axum_login::AuthUser; 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::auth::AuthBackend;
use crate::dto::{CreateNoteRequest, ListNotesQuery, NoteResponse, SearchQuery, UpdateNoteRequest}; use crate::dto::{CreateNoteRequest, ListNotesQuery, NoteResponse, SearchQuery, UpdateNoteRequest};
@@ -71,11 +74,30 @@ pub async fn create_note(
.validate() .validate()
.map_err(|e| ApiError::validation(e.to_string()))?; .map_err(|e| ApiError::validation(e.to_string()))?;
// Parse title into NoteTitle (optional - empty string becomes None)
let title: Option<NoteTitle> = 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<TagName> = payload
.tags
.into_iter()
.map(|s| {
TagName::try_from(s).map_err(|e| ApiError::validation(format!("Invalid tag: {}", e)))
})
.collect::<Result<Vec<_>, _>>()?;
let domain_req = DomainCreateNote { let domain_req = DomainCreateNote {
user_id, user_id,
title: payload.title, title,
content: payload.content, content: payload.content,
tags: payload.tags, tags,
color: payload.color, color: payload.color,
is_pinned: payload.is_pinned, is_pinned: payload.is_pinned,
}; };
@@ -126,15 +148,40 @@ pub async fn update_note(
.validate() .validate()
.map_err(|e| ApiError::validation(e.to_string()))?; .map_err(|e| ApiError::validation(e.to_string()))?;
// Parse optional title - Some(string) -> Some(Some(NoteTitle)) or Some(None) for empty
let title: Option<Option<NoteTitle>> = 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<Vec<TagName>> = 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::<Result<Vec<_>, _>>()?,
),
None => None,
};
let domain_req = DomainUpdateNote { let domain_req = DomainUpdateNote {
id, id,
user_id, user_id,
title: payload.title, title,
content: payload.content, content: payload.content,
is_pinned: payload.is_pinned, is_pinned: payload.is_pinned,
is_archived: payload.is_archived, is_archived: payload.is_archived,
color: payload.color, color: payload.color,
tags: payload.tags, tags,
}; };
let note = state.note_service.update_note(domain_req).await?; let note = state.note_service.update_note(domain_req).await?;

View File

@@ -9,6 +9,8 @@ use axum_login::{AuthSession, AuthUser};
use uuid::Uuid; use uuid::Uuid;
use validator::Validate; use validator::Validate;
use notes_domain::TagName;
use crate::auth::AuthBackend; use crate::auth::AuthBackend;
use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse}; use crate::dto::{CreateTagRequest, RenameTagRequest, TagResponse};
use crate::error::{ApiError, ApiResult}; use crate::error::{ApiError, ApiResult};
@@ -51,7 +53,11 @@ pub async fn create_tag(
.validate() .validate()
.map_err(|e| ApiError::validation(e.to_string()))?; .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)))) Ok((StatusCode::CREATED, Json(TagResponse::from(tag))))
} }
@@ -75,10 +81,11 @@ pub async fn rename_tag(
.validate() .validate()
.map_err(|e| ApiError::validation(e.to_string()))?; .map_err(|e| ApiError::validation(e.to_string()))?;
let tag = state // Parse string to TagName at API boundary
.tag_service let new_name = TagName::try_from(payload.name)
.rename_tag(id, user_id, &payload.name) .map_err(|e| ApiError::validation(format!("Invalid tag name: {}", e)))?;
.await?;
let tag = state.tag_service.rename_tag(id, user_id, new_name).await?;
Ok(Json(TagResponse::from(tag))) Ok(Json(TagResponse::from(tag)))
} }

View File

@@ -7,6 +7,8 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::value_objects::{Email, NoteTitle, TagName};
/// Maximum number of tags allowed per note (business rule) /// Maximum number of tags allowed per note (business rule)
pub const MAX_TAGS_PER_NOTE: usize = 10; pub const MAX_TAGS_PER_NOTE: usize = 10;
@@ -20,7 +22,8 @@ pub struct User {
/// OIDC subject identifier (unique per identity provider) /// OIDC subject identifier (unique per identity provider)
/// For local auth, this can be the same as email /// For local auth, this can be the same as email
pub subject: String, pub subject: String,
pub email: String, /// Validated email address
pub email: Email,
/// Password hash for local authentication (Argon2 etc.) /// Password hash for local authentication (Argon2 etc.)
pub password_hash: Option<String>, pub password_hash: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
@@ -28,22 +31,22 @@ pub struct User {
impl User { impl User {
/// Create a new user with the current timestamp /// Create a new user with the current timestamp
pub fn new(subject: impl Into<String>, email: impl Into<String>) -> Self { pub fn new(subject: impl Into<String>, email: Email) -> Self {
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
subject: subject.into(), subject: subject.into(),
email: email.into(), email,
password_hash: None, password_hash: None,
created_at: Utc::now(), created_at: Utc::now(),
} }
} }
/// Create a new user with password hash /// Create a new user with password hash
pub fn new_local(email: impl Into<String>, password_hash: impl Into<String>) -> Self { pub fn new_local(email: Email, password_hash: impl Into<String>) -> Self {
let email = email.into(); let subject = email.as_ref().to_string();
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
subject: email.clone(), // Use email as subject for local auth subject, // Use email as subject for local auth
email, email,
password_hash: Some(password_hash.into()), password_hash: Some(password_hash.into()),
created_at: Utc::now(), created_at: Utc::now(),
@@ -51,21 +54,27 @@ impl User {
} }
/// Create a user with a specific ID (for reconstruction from storage) /// Create a user with a specific ID (for reconstruction from storage)
/// This accepts raw strings for compatibility with database reads.
pub fn with_id( pub fn with_id(
id: Uuid, id: Uuid,
subject: impl Into<String>, subject: impl Into<String>,
email: impl Into<String>, email: Email,
password_hash: Option<String>, password_hash: Option<String>,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
) -> Self { ) -> Self {
Self { Self {
id, id,
subject: subject.into(), subject: subject.into(),
email: email.into(), email,
password_hash, password_hash,
created_at, 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. /// A tag that can be attached to notes.
@@ -74,27 +83,29 @@ impl User {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tag { pub struct Tag {
pub id: Uuid, pub id: Uuid,
pub name: String, /// Validated tag name (1-50 chars, trimmed, lowercase)
pub name: TagName,
pub user_id: Uuid, pub user_id: Uuid,
} }
impl Tag { impl Tag {
/// Create a new tag for a user /// Create a new tag for a user
pub fn new(name: impl Into<String>, user_id: Uuid) -> Self { pub fn new(name: TagName, user_id: Uuid) -> Self {
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
name: name.into(), name,
user_id, user_id,
} }
} }
/// Create a tag with a specific ID (for reconstruction from storage) /// Create a tag with a specific ID (for reconstruction from storage)
pub fn with_id(id: Uuid, name: impl Into<String>, user_id: Uuid) -> Self { pub fn with_id(id: Uuid, name: TagName, user_id: Uuid) -> Self {
Self { Self { id, name, user_id }
id, }
name: name.into(),
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. /// Notes support Markdown content and can be pinned or archived.
/// Each note can have up to [`MAX_TAGS_PER_NOTE`] tags. /// 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Note { pub struct Note {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub title: String, /// Optional title (max 200 chars when present)
pub title: Option<NoteTitle>,
/// Content stored as Markdown text /// Content stored as Markdown text
pub content: String, pub content: String,
/// Background color of the note (hex or name) /// Background color of the note (hex or name)
@@ -125,12 +138,12 @@ fn default_color() -> String {
impl Note { impl Note {
/// Create a new note with the current timestamp /// Create a new note with the current timestamp
pub fn new(user_id: Uuid, title: impl Into<String>, content: impl Into<String>) -> Self { pub fn new(user_id: Uuid, title: Option<NoteTitle>, content: impl Into<String>) -> Self {
let now = Utc::now(); let now = Utc::now();
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
user_id, user_id,
title: title.into(), title,
content: content.into(), content: content.into(),
color: default_color(), color: default_color(),
is_pinned: false, is_pinned: false,
@@ -160,8 +173,8 @@ impl Note {
} }
/// Update the note's title /// Update the note's title
pub fn set_title(&mut self, title: impl Into<String>) { pub fn set_title(&mut self, title: Option<NoteTitle>) {
self.title = title.into(); self.title = title;
self.updated_at = Utc::now(); self.updated_at = Utc::now();
} }
@@ -180,6 +193,11 @@ impl Note {
pub fn tag_count(&self) -> usize { pub fn tag_count(&self) -> usize {
self.tags.len() 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. /// A snapshot of a note's state at a specific point in time.
@@ -187,13 +205,14 @@ impl Note {
pub struct NoteVersion { pub struct NoteVersion {
pub id: Uuid, pub id: Uuid,
pub note_id: Uuid, pub note_id: Uuid,
pub title: String, /// Title at the time of snapshot (stored as string for historical purposes)
pub title: Option<String>,
pub content: String, pub content: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
impl NoteVersion { impl NoteVersion {
pub fn new(note_id: Uuid, title: String, content: String) -> Self { pub fn new(note_id: Uuid, title: Option<String>, content: String) -> Self {
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
note_id, note_id,
@@ -268,27 +287,31 @@ mod tests {
#[test] #[test]
fn test_new_user_has_unique_id() { fn test_new_user_has_unique_id() {
let user1 = User::new("subject1", "user1@example.com"); let email1 = Email::try_from("user1@example.com").unwrap();
let user2 = User::new("subject2", "user2@example.com"); 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); assert_ne!(user1.id, user2.id);
} }
#[test] #[test]
fn test_new_user_sets_fields_correctly() { 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.subject, "oidc|123456");
assert_eq!(user.email, "test@example.com"); assert_eq!(user.email_str(), "test@example.com");
assert!(user.password_hash.is_none()); assert!(user.password_hash.is_none());
} }
#[test] #[test]
fn test_new_local_user_sets_fields_correctly() { 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.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())); assert_eq!(user.password_hash, Some("hashed_secret".to_string()));
} }
@@ -296,17 +319,12 @@ mod tests {
fn test_user_with_id_preserves_all_fields() { fn test_user_with_id_preserves_all_fields() {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let created_at = Utc::now(); let created_at = Utc::now();
let user = User::with_id( let email = Email::try_from("email@test.com").unwrap();
id, let user = User::with_id(id, "subject", email, Some("hash".to_string()), created_at);
"subject",
"email@test.com",
Some("hash".to_string()),
created_at,
);
assert_eq!(user.id, id); assert_eq!(user.id, id);
assert_eq!(user.subject, "subject"); 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.password_hash, Some("hash".to_string()));
assert_eq!(user.created_at, created_at); assert_eq!(user.created_at, created_at);
} }
@@ -318,8 +336,10 @@ mod tests {
#[test] #[test]
fn test_new_tag_has_unique_id() { fn test_new_tag_has_unique_id() {
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let tag1 = Tag::new("work", user_id); let name1 = TagName::try_from("work").unwrap();
let tag2 = Tag::new("personal", user_id); 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); assert_ne!(tag1.id, tag2.id);
} }
@@ -327,20 +347,22 @@ mod tests {
#[test] #[test]
fn test_new_tag_associates_with_user() { fn test_new_tag_associates_with_user() {
let user_id = Uuid::new_v4(); 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.user_id, user_id);
assert_eq!(tag.name, "important"); assert_eq!(tag.name_str(), "important");
} }
#[test] #[test]
fn test_tag_with_id_preserves_all_fields() { fn test_tag_with_id_preserves_all_fields() {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let user_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.id, id);
assert_eq!(tag.name, "my-tag"); assert_eq!(tag.name_str(), "my-tag");
assert_eq!(tag.user_id, user_id); assert_eq!(tag.user_id, user_id);
} }
} }
@@ -351,8 +373,10 @@ mod tests {
#[test] #[test]
fn test_new_note_has_unique_id() { fn test_new_note_has_unique_id() {
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let note1 = Note::new(user_id, "Title 1", "Content 1"); let title1 = NoteTitle::try_from("Title 1").ok();
let note2 = Note::new(user_id, "Title 2", "Content 2"); 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); assert_ne!(note1.id, note2.id);
} }
@@ -360,20 +384,32 @@ mod tests {
#[test] #[test]
fn test_new_note_defaults() { fn test_new_note_defaults() {
let user_id = Uuid::new_v4(); 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.user_id, user_id);
assert_eq!(note.title, "My Note"); assert_eq!(note.title_str(), "My Note");
assert_eq!(note.content, "# Hello World"); assert_eq!(note.content, "# Hello World");
assert!(!note.is_pinned); assert!(!note.is_pinned);
assert!(!note.is_archived); assert!(!note.is_archived);
assert!(note.tags.is_empty()); 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] #[test]
fn test_note_set_pinned_updates_timestamp() { fn test_note_set_pinned_updates_timestamp() {
let user_id = Uuid::new_v4(); 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; let original_updated_at = note.updated_at;
// Small delay to ensure timestamp changes // Small delay to ensure timestamp changes
@@ -387,7 +423,8 @@ mod tests {
#[test] #[test]
fn test_note_set_archived_updates_timestamp() { fn test_note_set_archived_updates_timestamp() {
let user_id = Uuid::new_v4(); 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; let original_updated_at = note.updated_at;
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
@@ -400,7 +437,8 @@ mod tests {
#[test] #[test]
fn test_note_can_add_tag_when_under_limit() { fn test_note_can_add_tag_when_under_limit() {
let user_id = Uuid::new_v4(); 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()); assert!(note.can_add_tag());
} }
@@ -408,11 +446,13 @@ mod tests {
#[test] #[test]
fn test_note_cannot_add_tag_when_at_limit() { fn test_note_cannot_add_tag_when_at_limit() {
let user_id = Uuid::new_v4(); 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 // Add MAX_TAGS_PER_NOTE tags
for i in 0..MAX_TAGS_PER_NOTE { 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()); assert!(!note.can_add_tag());
@@ -422,20 +462,23 @@ mod tests {
#[test] #[test]
fn test_note_set_title_updates_timestamp() { fn test_note_set_title_updates_timestamp() {
let user_id = Uuid::new_v4(); 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; let original_updated_at = note.updated_at;
std::thread::sleep(std::time::Duration::from_millis(10)); 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); assert!(note.updated_at > original_updated_at);
} }
#[test] #[test]
fn test_note_set_content_updates_timestamp() { fn test_note_set_content_updates_timestamp() {
let user_id = Uuid::new_v4(); 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; let original_updated_at = note.updated_at;
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));

View File

@@ -7,12 +7,14 @@
//! - **Errors**: Domain-specific error types //! - **Errors**: Domain-specific error types
//! - **Repositories**: Port traits defining data access interfaces //! - **Repositories**: Port traits defining data access interfaces
//! - **Services**: Use cases orchestrating business logic //! - **Services**: Use cases orchestrating business logic
//! - **Value Objects**: Validated newtypes for domain primitives
pub mod entities; pub mod entities;
pub mod errors; pub mod errors;
pub mod ports; pub mod ports;
pub mod repositories; pub mod repositories;
pub mod services; pub mod services;
pub mod value_objects;
// Re-export commonly used types at crate root // Re-export commonly used types at crate root
pub use entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, User}; 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 ports::MessageBroker;
pub use repositories::{NoteRepository, TagRepository, UserRepository}; pub use repositories::{NoteRepository, TagRepository, UserRepository};
pub use services::{CreateNoteRequest, NoteService, TagService, UpdateNoteRequest, UserService}; 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,
};

View File

@@ -88,6 +88,7 @@ pub trait TagRepository: Send + Sync {
#[cfg(test)] #[cfg(test)]
pub(crate) mod tests { pub(crate) mod tests {
use super::*; use super::*;
use crate::value_objects::NoteTitle;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
@@ -144,7 +145,7 @@ pub(crate) mod tests {
.values() .values()
.filter(|n| n.user_id == user_id) .filter(|n| n.user_id == user_id)
.filter(|n| { .filter(|n| {
n.title.to_lowercase().contains(&query_lower) n.title_str().to_lowercase().contains(&query_lower)
|| n.content.to_lowercase().contains(&query_lower) || n.content.to_lowercase().contains(&query_lower)
}) })
.cloned() .cloned()
@@ -171,14 +172,15 @@ pub(crate) mod tests {
async fn test_mock_note_repository_save_and_find() { async fn test_mock_note_repository_save_and_find() {
let repo = MockNoteRepository::new(); let repo = MockNoteRepository::new();
let user_id = Uuid::new_v4(); 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; let note_id = note.id;
repo.save(&note).await.unwrap(); repo.save(&note).await.unwrap();
let found = repo.find_by_id(note_id).await.unwrap(); let found = repo.find_by_id(note_id).await.unwrap();
assert!(found.is_some()); assert!(found.is_some());
assert_eq!(found.unwrap().title, "Test Note"); assert_eq!(found.unwrap().title_str(), "Test Note");
} }
#[tokio::test] #[tokio::test]
@@ -186,11 +188,13 @@ pub(crate) mod tests {
let repo = MockNoteRepository::new(); let repo = MockNoteRepository::new();
let user_id = Uuid::new_v4(); 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; pinned_note.is_pinned = true;
repo.save(&pinned_note).await.unwrap(); 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(&regular_note).await.unwrap(); repo.save(&regular_note).await.unwrap();
let pinned_only = repo let pinned_only = repo
@@ -199,7 +203,7 @@ pub(crate) mod tests {
.unwrap(); .unwrap();
assert_eq!(pinned_only.len(), 1); assert_eq!(pinned_only.len(), 1);
assert_eq!(pinned_only[0].title, "Pinned"); assert_eq!(pinned_only[0].title_str(), "Pinned");
} }
#[tokio::test] #[tokio::test]
@@ -207,17 +211,19 @@ pub(crate) mod tests {
let repo = MockNoteRepository::new(); let repo = MockNoteRepository::new();
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let note1 = Note::new(user_id, "Shopping List", "Buy milk and eggs"); let title1 = NoteTitle::try_from("Shopping List").ok();
let note2 = Note::new(user_id, "Meeting Notes", "Discuss project timeline"); 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(&note1).await.unwrap(); repo.save(&note1).await.unwrap();
repo.save(&note2).await.unwrap(); repo.save(&note2).await.unwrap();
let results = repo.search(user_id, "milk").await.unwrap(); let results = repo.search(user_id, "milk").await.unwrap();
assert_eq!(results.len(), 1); 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(); let results = repo.search(user_id, "notes").await.unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Meeting Notes"); assert_eq!(results[0].title_str(), "Meeting Notes");
} }
} }

View File

@@ -1,7 +1,7 @@
//! Domain services for K-Notes //! Domain services for K-Notes
//! //!
//! Services orchestrate business logic, enforce rules, and coordinate //! 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 std::sync::Arc;
use uuid::Uuid; 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::errors::{DomainError, DomainResult};
use crate::ports::MessageBroker; use crate::ports::MessageBroker;
use crate::repositories::{NoteRepository, TagRepository, UserRepository}; use crate::repositories::{NoteRepository, TagRepository, UserRepository};
use crate::value_objects::{Email, NoteTitle, TagName};
/// Request to create a new note /// Request to create a new note
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CreateNoteRequest { pub struct CreateNoteRequest {
pub user_id: Uuid, pub user_id: Uuid,
pub title: String, /// Title is optional - notes can have no title
pub title: Option<NoteTitle>,
pub content: String, pub content: String,
pub tags: Vec<String>, /// Tags are pre-validated TagName values
pub tags: Vec<TagName>,
pub color: Option<String>, pub color: Option<String>,
pub is_pinned: bool, pub is_pinned: bool,
} }
@@ -27,12 +30,14 @@ pub struct CreateNoteRequest {
pub struct UpdateNoteRequest { pub struct UpdateNoteRequest {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, // For authorization check pub user_id: Uuid, // For authorization check
pub title: Option<String>, /// None means "don't change", Some(None) means "remove title", Some(Some(t)) means "set title"
pub title: Option<Option<NoteTitle>>,
pub content: Option<String>, pub content: Option<String>,
pub is_pinned: Option<bool>, pub is_pinned: Option<bool>,
pub is_archived: Option<bool>, pub is_archived: Option<bool>,
pub color: Option<String>, pub color: Option<String>,
pub tags: Option<Vec<String>>, /// Pre-validated TagName values
pub tags: Option<Vec<TagName>>,
} }
/// Service for Note operations /// Service for Note operations
@@ -70,10 +75,8 @@ impl NoteService {
/// Create a new note with optional tags /// Create a new note with optional tags
pub async fn create_note(&self, req: CreateNoteRequest) -> DomainResult<Note> { pub async fn create_note(&self, req: CreateNoteRequest) -> DomainResult<Note> {
// Validate title is not empty // Title validation is handled by NoteTitle type - no need for runtime check
if req.title.trim().is_empty() { // Tags are pre-validated as TagName values
return Err(DomainError::validation("Title cannot be empty"));
}
// Validate tag count // Validate tag count
if req.tags.len() > MAX_TAGS_PER_NOTE { if req.tags.len() > MAX_TAGS_PER_NOTE {
@@ -89,7 +92,9 @@ impl NoteService {
// Process tags // Process tags
for tag_name in &req.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); note.tags.push(tag);
} }
@@ -124,14 +129,15 @@ impl NoteService {
} }
// Create version snapshot (save current state) // 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?; 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 let Some(title) = req.title {
if title.trim().is_empty() {
return Err(DomainError::validation("Title cannot be empty"));
}
note.set_title(title); note.set_title(title);
} }
@@ -164,7 +170,7 @@ impl NoteService {
// Add new tags // Add new tags
note.tags.clear(); 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?; let tag = self.get_or_create_tag(note.user_id, tag_name).await?;
self.tag_repo.add_to_note(tag.id, note.id).await?; self.tag_repo.add_to_note(tag.id, note.id).await?;
note.tags.push(tag); note.tags.push(tag);
@@ -247,11 +253,9 @@ impl NoteService {
/// ///
/// Handles race conditions gracefully: if a concurrent request creates /// Handles race conditions gracefully: if a concurrent request creates
/// the same tag, we catch the unique constraint violation and retry the lookup. /// 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<Tag> { async fn get_or_create_tag(&self, user_id: Uuid, name: TagName) -> DomainResult<Tag> {
let name = name.trim().to_lowercase();
// First, try to find existing tag // 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); return Ok(tag);
} }
@@ -264,7 +268,7 @@ impl NoteService {
// Retry the lookup // Retry the lookup
tracing::debug!(tag_name = %name, "Tag creation race condition detected, retrying lookup"); tracing::debug!(tag_name = %name, "Tag creation race condition detected, retrying lookup");
self.tag_repo self.tag_repo
.find_by_name(user_id, &name) .find_by_name(user_id, name.as_ref())
.await? .await?
.ok_or_else(|| DomainError::validation("Tag creation race condition")) .ok_or_else(|| DomainError::validation("Tag creation race condition"))
} }
@@ -283,16 +287,16 @@ impl TagService {
Self { tag_repo } Self { tag_repo }
} }
/// Create a new tag /// Create a new tag (TagName is pre-validated)
pub async fn create_tag(&self, user_id: Uuid, name: &str) -> DomainResult<Tag> { pub async fn create_tag(&self, user_id: Uuid, name: TagName) -> DomainResult<Tag> {
let name = name.trim().to_lowercase();
if name.is_empty() {
return Err(DomainError::validation("Tag name cannot be empty"));
}
// Check if tag already exists // Check if tag already exists
if self.tag_repo.find_by_name(user_id, &name).await?.is_some() { if self
return Err(DomainError::TagAlreadyExists(name)); .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); let tag = Tag::new(name, user_id);
@@ -322,13 +326,13 @@ impl TagService {
self.tag_repo.delete(id).await self.tag_repo.delete(id).await
} }
/// Rename a tag /// Rename a tag (new_name is pre-validated TagName)
pub async fn rename_tag(&self, id: Uuid, user_id: Uuid, new_name: &str) -> DomainResult<Tag> { pub async fn rename_tag(
let new_name = new_name.trim().to_lowercase(); &self,
if new_name.is_empty() { id: Uuid,
return Err(DomainError::validation("Tag name cannot be empty")); user_id: Uuid,
} new_name: TagName,
) -> DomainResult<Tag> {
// Find the existing tag // Find the existing tag
let mut tag = self let mut tag = self
.tag_repo .tag_repo
@@ -344,9 +348,13 @@ impl TagService {
} }
// Check if new name already exists (and it's not the same tag) // 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 { 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( pub async fn find_or_create_by_subject(
&self, &self,
subject: &str, subject: &str,
email: &str, email: Email,
) -> DomainResult<User> { ) -> DomainResult<User> {
if let Some(user) = self.user_repo.find_by_subject(subject).await? { if let Some(user) = self.user_repo.find_by_subject(subject).await? {
Ok(user) Ok(user)
@@ -505,7 +513,7 @@ mod tests {
.lock() .lock()
.unwrap() .unwrap()
.values() .values()
.find(|t| t.user_id == user_id && t.name == name) .find(|t| t.user_id == user_id && t.name.as_ref() == name)
.cloned()) .cloned())
} }
@@ -574,7 +582,7 @@ mod tests {
.lock() .lock()
.unwrap() .unwrap()
.values() .values()
.find(|u| u.email == email) .find(|u| u.email_str() == email)
.cloned()) .cloned())
} }
@@ -603,9 +611,10 @@ mod tests {
async fn test_create_note_success() { async fn test_create_note_success() {
let (service, user_id) = create_note_service(); let (service, user_id) = create_note_service();
let title = NoteTitle::try_from("My Note").ok();
let req = CreateNoteRequest { let req = CreateNoteRequest {
user_id, user_id,
title: "My Note".to_string(), title,
content: "# Hello World".to_string(), content: "# Hello World".to_string(),
tags: vec![], tags: vec![],
color: None, color: None,
@@ -614,7 +623,7 @@ mod tests {
let note = service.create_note(req).await.unwrap(); 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.content, "# Hello World");
assert_eq!(note.user_id, user_id); assert_eq!(note.user_id, user_id);
assert_eq!(note.color, "DEFAULT"); assert_eq!(note.color, "DEFAULT");
@@ -622,14 +631,39 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn test_create_note_with_tags() { async fn test_create_note_without_title() {
let (service, user_id) = create_note_service(); let (service, user_id) = create_note_service();
let req = CreateNoteRequest { let req = CreateNoteRequest {
user_id, 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(), content: "Content".to_string(),
tags: vec!["work".to_string(), "important".to_string()], tags,
color: None, color: None,
is_pinned: false, is_pinned: false,
}; };
@@ -637,38 +671,22 @@ mod tests {
let note = service.create_note(req).await.unwrap(); let note = service.create_note(req).await.unwrap();
assert_eq!(note.tags.len(), 2); assert_eq!(note.tags.len(), 2);
assert!(note.tags.iter().any(|t| t.name == "work")); assert!(note.tags.iter().any(|t| t.name_str() == "work"));
assert!(note.tags.iter().any(|t| t.name == "important")); assert!(note.tags.iter().any(|t| t.name_str() == "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(_))));
} }
#[tokio::test] #[tokio::test]
async fn test_create_note_too_many_tags_fails() { async fn test_create_note_too_many_tags_fails() {
let (service, user_id) = create_note_service(); let (service, user_id) = create_note_service();
let tags: Vec<String> = (0..=MAX_TAGS_PER_NOTE) let tags: Vec<TagName> = (0..=MAX_TAGS_PER_NOTE)
.map(|i| format!("tag-{}", i)) .map(|i| TagName::try_from(format!("tag-{}", i)).unwrap())
.collect(); .collect();
let title = NoteTitle::try_from("Note").ok();
let req = CreateNoteRequest { let req = CreateNoteRequest {
user_id, user_id,
title: "Note".to_string(), title,
content: "Content".to_string(), content: "Content".to_string(),
tags, tags,
color: None, color: None,
@@ -684,9 +702,10 @@ mod tests {
let (service, user_id) = create_note_service(); let (service, user_id) = create_note_service();
// Create a note first // Create a note first
let title = NoteTitle::try_from("Original").ok();
let create_req = CreateNoteRequest { let create_req = CreateNoteRequest {
user_id, user_id,
title: "Original".to_string(), title,
content: "Original content".to_string(), content: "Original content".to_string(),
tags: vec![], tags: vec![],
color: None, color: None,
@@ -695,10 +714,11 @@ mod tests {
let note = service.create_note(create_req).await.unwrap(); let note = service.create_note(create_req).await.unwrap();
// Update it // Update it
let new_title = NoteTitle::try_from("Updated").ok();
let update_req = UpdateNoteRequest { let update_req = UpdateNoteRequest {
id: note.id, id: note.id,
user_id, user_id,
title: Some("Updated".to_string()), title: Some(new_title),
content: None, content: None,
is_pinned: Some(true), is_pinned: Some(true),
is_archived: None, is_archived: None,
@@ -707,7 +727,7 @@ mod tests {
}; };
let updated = service.update_note(update_req).await.unwrap(); 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_eq!(updated.content, "Original content"); // Unchanged
assert!(updated.is_pinned); assert!(updated.is_pinned);
assert_eq!(updated.color, "red"); assert_eq!(updated.color, "red");
@@ -719,9 +739,10 @@ mod tests {
let other_user = Uuid::new_v4(); let other_user = Uuid::new_v4();
// Create a note // Create a note
let title = NoteTitle::try_from("My Note").ok();
let create_req = CreateNoteRequest { let create_req = CreateNoteRequest {
user_id, user_id,
title: "My Note".to_string(), title,
content: "Content".to_string(), content: "Content".to_string(),
tags: vec![], tags: vec![],
color: None, color: None,
@@ -730,10 +751,11 @@ mod tests {
let note = service.create_note(create_req).await.unwrap(); let note = service.create_note(create_req).await.unwrap();
// Try to update with different user // Try to update with different user
let new_title = NoteTitle::try_from("Hacked").ok();
let update_req = UpdateNoteRequest { let update_req = UpdateNoteRequest {
id: note.id, id: note.id,
user_id: other_user, user_id: other_user,
title: Some("Hacked".to_string()), title: Some(new_title),
content: None, content: None,
is_pinned: None, is_pinned: None,
is_archived: None, is_archived: None,
@@ -749,9 +771,10 @@ mod tests {
async fn test_delete_note_success() { async fn test_delete_note_success() {
let (service, user_id) = create_note_service(); let (service, user_id) = create_note_service();
let title = NoteTitle::try_from("To Delete").ok();
let create_req = CreateNoteRequest { let create_req = CreateNoteRequest {
user_id, user_id,
title: "To Delete".to_string(), title,
content: "Content".to_string(), content: "Content".to_string(),
tags: vec![], tags: vec![],
color: None, color: None,
@@ -772,14 +795,16 @@ mod tests {
let results = service.search_notes(user_id, " ").await.unwrap(); let results = service.search_notes(user_id, " ").await.unwrap();
assert!(results.is_empty()); assert!(results.is_empty());
} }
#[tokio::test] #[tokio::test]
async fn test_update_note_creates_version() { async fn test_update_note_creates_version() {
let (service, user_id) = create_note_service(); let (service, user_id) = create_note_service();
// Create original note // Create original note
let title = NoteTitle::try_from("Original Title").ok();
let create_req = CreateNoteRequest { let create_req = CreateNoteRequest {
user_id, user_id,
title: "Original Title".to_string(), title,
content: "Original Content".to_string(), content: "Original Content".to_string(),
tags: vec![], tags: vec![],
color: None, color: None,
@@ -788,10 +813,11 @@ mod tests {
let note = service.create_note(create_req).await.unwrap(); let note = service.create_note(create_req).await.unwrap();
// Update the note // Update the note
let new_title = NoteTitle::try_from("New Title").ok();
let update_req = UpdateNoteRequest { let update_req = UpdateNoteRequest {
id: note.id, id: note.id,
user_id, user_id,
title: Some("New Title".to_string()), title: Some(new_title),
content: Some("New Content".to_string()), content: Some("New Content".to_string()),
is_pinned: None, is_pinned: None,
is_archived: None, is_archived: None,
@@ -809,7 +835,7 @@ mod tests {
assert_eq!(versions.len(), 1); assert_eq!(versions.len(), 1);
let version = &versions[0]; 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.content, "Original Content");
assert_eq!(version.note_id, note.id); assert_eq!(version.note_id, note.id);
} }
@@ -828,26 +854,22 @@ mod tests {
async fn test_create_tag_success() { async fn test_create_tag_success() {
let (service, user_id) = create_tag_service(); 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); 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] #[tokio::test]
async fn test_create_duplicate_tag_fails() { async fn test_create_duplicate_tag_fails() {
let (service, user_id) = create_tag_service(); let (service, user_id) = create_tag_service();
service.create_tag(user_id, "work").await.unwrap(); let name1 = TagName::try_from("work").unwrap();
let result = service.create_tag(user_id, "WORK").await; // Case-insensitive 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(_)))); assert!(matches!(result, Err(DomainError::TagAlreadyExists(_))));
} }
@@ -865,26 +887,29 @@ mod tests {
async fn test_find_or_create_creates_new_user() { async fn test_find_or_create_creates_new_user() {
let service = create_user_service(); let service = create_user_service();
let email = Email::try_from("test@example.com").unwrap();
let user = service let user = service
.find_or_create_by_subject("oidc|123", "test@example.com") .find_or_create_by_subject("oidc|123", email)
.await .await
.unwrap(); .unwrap();
assert_eq!(user.subject, "oidc|123"); assert_eq!(user.subject, "oidc|123");
assert_eq!(user.email, "test@example.com"); assert_eq!(user.email_str(), "test@example.com");
} }
#[tokio::test] #[tokio::test]
async fn test_find_or_create_returns_existing_user() { async fn test_find_or_create_returns_existing_user() {
let service = create_user_service(); let service = create_user_service();
let email1 = Email::try_from("test@example.com").unwrap();
let user1 = service let user1 = service
.find_or_create_by_subject("oidc|123", "test@example.com") .find_or_create_by_subject("oidc|123", email1)
.await .await
.unwrap(); .unwrap();
let email2 = Email::try_from("test@example.com").unwrap();
let user2 = service let user2 = service
.find_or_create_by_subject("oidc|123", "test@example.com") .find_or_create_by_subject("oidc|123", email2)
.await .await
.unwrap(); .unwrap();

View File

@@ -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<String>) -> Result<Self, ValidationError> {
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<str> 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<String> for Email {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for Email {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl Serialize for Email {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Email {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<String>) -> Result<Self, ValidationError> {
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<str> 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<String> for Password {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for Password {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl<'de> Deserialize<'de> for Password {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<String>) -> Result<Self, ValidationError> {
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<str> 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<String> for TagName {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for TagName {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl Serialize for TagName {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for TagName {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<NoteTitle>.
#[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<String>) -> Result<Self, ValidationError> {
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<String>) -> Result<Option<Self>, 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<str> 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<String> for NoteTitle {
type Error = ValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl TryFrom<&str> for NoteTitle {
type Error = ValidationError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl Serialize for NoteTitle {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for NoteTitle {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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");
}
}
}

View File

@@ -5,7 +5,10 @@ use chrono::{DateTime, Utc};
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool}; use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use uuid::Uuid; 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 /// SQLite adapter for NoteRepository
pub struct SqliteNoteRepository { pub struct SqliteNoteRepository {
@@ -23,7 +26,7 @@ impl SqliteNoteRepository {
struct NoteRowWithTags { struct NoteRowWithTags {
id: String, id: String,
user_id: String, user_id: String,
title: String, title: Option<String>, // Title can be NULL in the database
content: String, content: String,
color: String, color: String,
is_pinned: i32, is_pinned: i32,
@@ -68,7 +71,12 @@ fn parse_tags_json(tags_json: &str) -> Result<Vec<Tag>, DomainError> {
let user_id = Uuid::parse_str(user_id_str) let user_id = Uuid::parse_str(user_id_str)
.map_err(|e| DomainError::RepositoryError(format!("Invalid tag user_id: {}", e)))?; .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() .collect()
} }
@@ -84,10 +92,18 @@ impl NoteRowWithTags {
let updated_at = parse_datetime(&self.updated_at)?; let updated_at = parse_datetime(&self.updated_at)?;
let tags = parse_tags_json(&self.tags_json)?; let tags = parse_tags_json(&self.tags_json)?;
// Parse optional title - empty string or NULL maps to None
let title: Option<NoteTitle> = 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 { Ok(Note {
id, id,
user_id, user_id,
title: self.title, title,
content: self.content, content: self.content,
color: self.color, color: self.color,
is_pinned: self.is_pinned != 0, is_pinned: self.is_pinned != 0,
@@ -103,7 +119,7 @@ impl NoteRowWithTags {
struct NoteVersionRow { struct NoteVersionRow {
id: String, id: String,
note_id: String, note_id: String,
title: String, title: Option<String>, // Title can be NULL
content: String, content: String,
created_at: String, created_at: String,
} }
@@ -126,7 +142,7 @@ impl NoteVersionRow {
Ok(NoteVersion { Ok(NoteVersion {
id, id,
note_id, note_id,
title: self.title, title: self.title, // Already Option<String>
content: self.content, content: self.content,
created_at, created_at,
}) })
@@ -222,6 +238,8 @@ impl NoteRepository for SqliteNoteRepository {
let is_archived: i32 = if note.is_archived { 1 } else { 0 }; let is_archived: i32 = if note.is_archived { 1 } else { 0 };
let created_at = note.created_at.to_rfc3339(); let created_at = note.created_at.to_rfc3339();
let updated_at = note.updated_at.to_rfc3339(); let updated_at = note.updated_at.to_rfc3339();
// Convert Option<NoteTitle> to Option<&str> for binding
let title_str: Option<&str> = note.title.as_ref().map(|t| t.as_ref());
sqlx::query( sqlx::query(
r#" r#"
@@ -238,7 +256,7 @@ impl NoteRepository for SqliteNoteRepository {
) )
.bind(&id) .bind(&id)
.bind(&user_id) .bind(&user_id)
.bind(&note.title) .bind(title_str)
.bind(&note.content) .bind(&note.content)
.bind(&note.color) .bind(&note.color)
.bind(is_pinned) .bind(is_pinned)

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, SqlitePool};
use uuid::Uuid; use uuid::Uuid;
use notes_domain::{DomainError, DomainResult, Tag, TagRepository}; use notes_domain::{DomainError, DomainResult, Tag, TagName, TagRepository};
/// SQLite adapter for TagRepository /// SQLite adapter for TagRepository
pub struct SqliteTagRepository { pub struct SqliteTagRepository {
@@ -33,7 +33,11 @@ impl TryFrom<TagRow> for Tag {
let user_id = Uuid::parse_str(&row.user_id) let user_id = Uuid::parse_str(&row.user_id)
.map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?; .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(&id)
.bind(&tag.name) .bind(tag.name.as_ref()) // Use .as_ref() to get the inner &str
.bind(&user_id) .bind(&user_id)
.execute(&self.pool) .execute(&self.pool)
.await .await
@@ -160,7 +164,7 @@ mod tests {
use super::*; use super::*;
use crate::db::{DatabaseConfig, DatabasePool, create_pool, run_migrations}; use crate::db::{DatabaseConfig, DatabasePool, create_pool, run_migrations};
use crate::user_repository::SqliteUserRepository; use crate::user_repository::SqliteUserRepository;
use notes_domain::{User, UserRepository}; use notes_domain::{Email, User, UserRepository};
async fn setup_test_db() -> SqlitePool { async fn setup_test_db() -> SqlitePool {
let config = DatabaseConfig::in_memory(); let config = DatabaseConfig::in_memory();
@@ -172,7 +176,8 @@ mod tests {
async fn create_test_user(pool: &SqlitePool) -> User { async fn create_test_user(pool: &SqlitePool) -> User {
let user_repo = SqliteUserRepository::new(pool.clone()); 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_repo.save(&user).await.unwrap();
user user
} }
@@ -183,12 +188,13 @@ mod tests {
let user = create_test_user(&pool).await; let user = create_test_user(&pool).await;
let repo = SqliteTagRepository::new(pool); 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(); repo.save(&tag).await.unwrap();
let found = repo.find_by_id(tag.id).await.unwrap(); let found = repo.find_by_id(tag.id).await.unwrap();
assert!(found.is_some()); assert!(found.is_some());
assert_eq!(found.unwrap().name, "work"); assert_eq!(found.unwrap().name_str(), "work");
} }
#[tokio::test] #[tokio::test]
@@ -197,7 +203,8 @@ mod tests {
let user = create_test_user(&pool).await; let user = create_test_user(&pool).await;
let repo = SqliteTagRepository::new(pool); 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(); repo.save(&tag).await.unwrap();
let found = repo.find_by_name(user.id, "important").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 user = create_test_user(&pool).await;
let repo = SqliteTagRepository::new(pool); let repo = SqliteTagRepository::new(pool);
repo.save(&Tag::new("alpha", user.id)).await.unwrap(); let name_alpha = TagName::try_from("alpha").unwrap();
repo.save(&Tag::new("beta", user.id)).await.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(); let tags = repo.find_by_user(user.id).await.unwrap();
assert_eq!(tags.len(), 2); assert_eq!(tags.len(), 2);
// Should be sorted alphabetically // Should be sorted alphabetically
assert_eq!(tags[0].name, "alpha"); assert_eq!(tags[0].name_str(), "alpha");
assert_eq!(tags[1].name, "beta"); assert_eq!(tags[1].name_str(), "beta");
} }
} }

View File

@@ -5,7 +5,7 @@ use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, SqlitePool};
use uuid::Uuid; use uuid::Uuid;
use notes_domain::{DomainError, DomainResult, User, UserRepository}; use notes_domain::{DomainError, DomainResult, Email, User, UserRepository};
/// SQLite adapter for UserRepository /// SQLite adapter for UserRepository
pub struct SqliteUserRepository { pub struct SqliteUserRepository {
@@ -43,10 +43,14 @@ impl TryFrom<UserRow> for User {
}) })
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?; .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( Ok(User::with_id(
id, id,
row.subject, row.subject,
row.email, email,
row.password_hash, row.password_hash,
created_at, created_at,
)) ))
@@ -108,7 +112,7 @@ impl UserRepository for SqliteUserRepository {
) )
.bind(&id) .bind(&id)
.bind(&user.subject) .bind(&user.subject)
.bind(&user.email) .bind(user.email.as_ref()) // Use .as_ref() to get the inner &str
.bind(&user.password_hash) .bind(&user.password_hash)
.bind(&created_at) .bind(&created_at)
.execute(&self.pool) .execute(&self.pool)
@@ -148,14 +152,15 @@ mod tests {
let pool = setup_test_db().await; let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool); 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(); repo.save(&user).await.unwrap();
let found = repo.find_by_id(user.id).await.unwrap(); let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_some()); assert!(found.is_some());
let found = found.unwrap(); let found = found.unwrap();
assert_eq!(found.subject, "oidc|123"); 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()); assert!(found.password_hash.is_none());
} }
@@ -164,13 +169,14 @@ mod tests {
let pool = setup_test_db().await; let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool); 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(); repo.save(&user).await.unwrap();
let found = repo.find_by_id(user.id).await.unwrap(); let found = repo.find_by_id(user.id).await.unwrap();
assert!(found.is_some()); assert!(found.is_some());
let found = found.unwrap(); 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())); assert_eq!(found.password_hash, Some("hashed_pw".to_string()));
} }
@@ -179,7 +185,8 @@ mod tests {
let pool = setup_test_db().await; let pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool); 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(); repo.save(&user).await.unwrap();
let found = repo.find_by_subject("google|456").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 pool = setup_test_db().await;
let repo = SqliteUserRepository::new(pool); 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.save(&user).await.unwrap();
repo.delete(user.id).await.unwrap(); repo.delete(user.id).await.unwrap();