init
This commit is contained in:
428
notes-domain/src/entities.rs
Normal file
428
notes-domain/src/entities.rs
Normal file
@@ -0,0 +1,428 @@
|
||||
//! Domain entities for K-Notes
|
||||
//!
|
||||
//! This module contains pure domain types with no I/O dependencies.
|
||||
//! These represent the core business concepts of the application.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Maximum number of tags allowed per note (business rule)
|
||||
pub const MAX_TAGS_PER_NOTE: usize = 10;
|
||||
|
||||
/// A user in the system.
|
||||
///
|
||||
/// Designed to be OIDC-ready: the `subject` field stores the OIDC subject claim
|
||||
/// for federated identity, while `email` is used for display purposes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
/// OIDC subject identifier (unique per identity provider)
|
||||
/// For local auth, this can be the same as email
|
||||
pub subject: String,
|
||||
pub email: String,
|
||||
/// Password hash for local authentication (Argon2 etc.)
|
||||
pub password_hash: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Create a new user with the current timestamp
|
||||
pub fn new(subject: impl Into<String>, email: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
subject: subject.into(),
|
||||
email: email.into(),
|
||||
password_hash: None,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new user with password hash
|
||||
pub fn new_local(email: impl Into<String>, password_hash: impl Into<String>) -> Self {
|
||||
let email = email.into();
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
subject: email.clone(), // Use email as subject for local auth
|
||||
email,
|
||||
password_hash: Some(password_hash.into()),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user with a specific ID (for reconstruction from storage)
|
||||
pub fn with_id(
|
||||
id: Uuid,
|
||||
subject: impl Into<String>,
|
||||
email: impl Into<String>,
|
||||
password_hash: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
subject: subject.into(),
|
||||
email: email.into(),
|
||||
password_hash,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tag that can be attached to notes.
|
||||
///
|
||||
/// Tags are user-scoped, meaning each user has their own set of tags.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub user_id: Uuid,
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
/// Create a new tag for a user
|
||||
pub fn new(name: impl Into<String>, user_id: Uuid) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
name: name.into(),
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
Self {
|
||||
id,
|
||||
name: name.into(),
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A note containing user content.
|
||||
///
|
||||
/// Notes support Markdown content and can be pinned or archived.
|
||||
/// Each note can have up to [`MAX_TAGS_PER_NOTE`] tags.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Note {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub title: String,
|
||||
/// Content stored as Markdown text
|
||||
pub content: String,
|
||||
/// Background color of the note (hex or name)
|
||||
#[serde(default = "default_color")]
|
||||
pub color: String,
|
||||
pub is_pinned: bool,
|
||||
pub is_archived: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub tags: Vec<Tag>,
|
||||
}
|
||||
|
||||
fn default_color() -> String {
|
||||
"DEFAULT".to_string()
|
||||
}
|
||||
|
||||
impl Note {
|
||||
/// Create a new note with the current timestamp
|
||||
pub fn new(user_id: Uuid, title: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
user_id,
|
||||
title: title.into(),
|
||||
content: content.into(),
|
||||
color: default_color(),
|
||||
is_pinned: false,
|
||||
is_archived: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the color of the note
|
||||
pub fn set_color(&mut self, color: impl Into<String>) {
|
||||
self.color = color.into();
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Pin or unpin the note
|
||||
pub fn set_pinned(&mut self, pinned: bool) {
|
||||
self.is_pinned = pinned;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Archive or unarchive the note
|
||||
pub fn set_archived(&mut self, archived: bool) {
|
||||
self.is_archived = archived;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Update the note's title
|
||||
pub fn set_title(&mut self, title: impl Into<String>) {
|
||||
self.title = title.into();
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Update the note's content
|
||||
pub fn set_content(&mut self, content: impl Into<String>) {
|
||||
self.content = content.into();
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Check if adding a tag would exceed the limit
|
||||
pub fn can_add_tag(&self) -> bool {
|
||||
self.tags.len() < MAX_TAGS_PER_NOTE
|
||||
}
|
||||
|
||||
/// Get the number of tags on this note
|
||||
pub fn tag_count(&self) -> usize {
|
||||
self.tags.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter options for querying notes
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct NoteFilter {
|
||||
pub is_pinned: Option<bool>,
|
||||
pub is_archived: Option<bool>,
|
||||
pub tag_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl NoteFilter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn pinned(mut self) -> Self {
|
||||
self.is_pinned = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn archived(mut self) -> Self {
|
||||
self.is_archived = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn not_archived(mut self) -> Self {
|
||||
self.is_archived = Some(false);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tag(mut self, tag_id: Uuid) -> Self {
|
||||
self.tag_id = Some(tag_id);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod user_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_user_has_unique_id() {
|
||||
let user1 = User::new("subject1", "user1@example.com");
|
||||
let user2 = User::new("subject2", "user2@example.com");
|
||||
|
||||
assert_ne!(user1.id, user2.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_user_sets_fields_correctly() {
|
||||
let user = User::new("oidc|123456", "test@example.com");
|
||||
|
||||
assert_eq!(user.subject, "oidc|123456");
|
||||
assert_eq!(user.email, "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");
|
||||
|
||||
assert_eq!(user.subject, "local@example.com");
|
||||
assert_eq!(user.email, "local@example.com");
|
||||
assert_eq!(user.password_hash, Some("hashed_secret".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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,
|
||||
);
|
||||
|
||||
assert_eq!(user.id, id);
|
||||
assert_eq!(user.subject, "subject");
|
||||
assert_eq!(user.email, "email@test.com");
|
||||
assert_eq!(user.password_hash, Some("hash".to_string()));
|
||||
assert_eq!(user.created_at, created_at);
|
||||
}
|
||||
}
|
||||
|
||||
mod tag_tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
|
||||
assert_ne!(tag1.id, tag2.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_tag_associates_with_user() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let tag = Tag::new("important", user_id);
|
||||
|
||||
assert_eq!(tag.user_id, user_id);
|
||||
assert_eq!(tag.name, "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);
|
||||
|
||||
assert_eq!(tag.id, id);
|
||||
assert_eq!(tag.name, "my-tag");
|
||||
assert_eq!(tag.user_id, user_id);
|
||||
}
|
||||
}
|
||||
|
||||
mod note_tests {
|
||||
use super::*;
|
||||
|
||||
#[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");
|
||||
|
||||
assert_ne!(note1.id, note2.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_note_defaults() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let note = Note::new(user_id, "My Note", "# Hello World");
|
||||
|
||||
assert_eq!(note.user_id, user_id);
|
||||
assert_eq!(note.title, "My Note");
|
||||
assert_eq!(note.content, "# Hello World");
|
||||
assert!(!note.is_pinned);
|
||||
assert!(!note.is_archived);
|
||||
assert!(note.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_set_pinned_updates_timestamp() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let mut note = Note::new(user_id, "Title", "Content");
|
||||
let original_updated_at = note.updated_at;
|
||||
|
||||
// Small delay to ensure timestamp changes
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
note.set_pinned(true);
|
||||
|
||||
assert!(note.is_pinned);
|
||||
assert!(note.updated_at > original_updated_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_set_archived_updates_timestamp() {
|
||||
let user_id = Uuid::new_v4();
|
||||
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_archived(true);
|
||||
|
||||
assert!(note.is_archived);
|
||||
assert!(note.updated_at > original_updated_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_can_add_tag_when_under_limit() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let note = Note::new(user_id, "Title", "Content");
|
||||
|
||||
assert!(note.can_add_tag());
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
// Add MAX_TAGS_PER_NOTE tags
|
||||
for i in 0..MAX_TAGS_PER_NOTE {
|
||||
note.tags.push(Tag::new(format!("tag-{}", i), user_id));
|
||||
}
|
||||
|
||||
assert!(!note.can_add_tag());
|
||||
assert_eq!(note.tag_count(), MAX_TAGS_PER_NOTE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_set_title_updates_timestamp() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let mut note = Note::new(user_id, "Original", "Content");
|
||||
let original_updated_at = note.updated_at;
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
note.set_title("Updated Title");
|
||||
|
||||
assert_eq!(note.title, "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 original_updated_at = note.updated_at;
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
note.set_content("Updated content");
|
||||
|
||||
assert_eq!(note.content, "Updated content");
|
||||
assert!(note.updated_at > original_updated_at);
|
||||
}
|
||||
}
|
||||
|
||||
mod note_filter_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_filter_has_no_constraints() {
|
||||
let filter = NoteFilter::default();
|
||||
|
||||
assert!(filter.is_pinned.is_none());
|
||||
assert!(filter.is_archived.is_none());
|
||||
assert!(filter.tag_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_builder_pattern() {
|
||||
let tag_id = Uuid::new_v4();
|
||||
let filter = NoteFilter::new().pinned().not_archived().with_tag(tag_id);
|
||||
|
||||
assert_eq!(filter.is_pinned, Some(true));
|
||||
assert_eq!(filter.is_archived, Some(false));
|
||||
assert_eq!(filter.tag_id, Some(tag_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
133
notes-domain/src/errors.rs
Normal file
133
notes-domain/src/errors.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! Domain errors for K-Notes
|
||||
//!
|
||||
//! Uses `thiserror` for ergonomic error definitions.
|
||||
//! These errors represent domain-level failures and will be mapped
|
||||
//! to HTTP status codes in the API layer.
|
||||
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entities::MAX_TAGS_PER_NOTE;
|
||||
|
||||
/// Domain-level errors for K-Notes operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DomainError {
|
||||
/// The requested note was not found
|
||||
#[error("Note not found: {0}")]
|
||||
NoteNotFound(Uuid),
|
||||
|
||||
/// The requested user was not found
|
||||
#[error("User not found: {0}")]
|
||||
UserNotFound(Uuid),
|
||||
|
||||
/// The requested tag was not found
|
||||
#[error("Tag not found: {0}")]
|
||||
TagNotFound(Uuid),
|
||||
|
||||
/// User with this email/subject already exists
|
||||
#[error("User already exists: {0}")]
|
||||
UserAlreadyExists(String),
|
||||
|
||||
/// Tag with this name already exists for the user
|
||||
#[error("Tag already exists: {0}")]
|
||||
TagAlreadyExists(String),
|
||||
|
||||
/// Attempted to add too many tags to a note
|
||||
#[error("Tag limit exceeded: maximum {max} tags allowed, note has {current}")]
|
||||
TagLimitExceeded { max: usize, current: usize },
|
||||
|
||||
/// A validation error occurred
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
/// User is not authorized to perform this action
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
/// A repository/infrastructure error occurred
|
||||
#[error("Repository error: {0}")]
|
||||
RepositoryError(String),
|
||||
}
|
||||
|
||||
impl DomainError {
|
||||
/// Create a tag limit exceeded error with the current count
|
||||
pub fn tag_limit_exceeded(current: usize) -> Self {
|
||||
Self::TagLimitExceeded {
|
||||
max: MAX_TAGS_PER_NOTE,
|
||||
current,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a validation error
|
||||
pub fn validation(message: impl Into<String>) -> Self {
|
||||
Self::ValidationError(message.into())
|
||||
}
|
||||
|
||||
/// Create an unauthorized error
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::Unauthorized(message.into())
|
||||
}
|
||||
|
||||
/// Check if this error indicates a "not found" condition
|
||||
pub fn is_not_found(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
DomainError::NoteNotFound(_)
|
||||
| DomainError::UserNotFound(_)
|
||||
| DomainError::TagNotFound(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this error indicates a conflict (already exists)
|
||||
pub fn is_conflict(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
DomainError::UserAlreadyExists(_) | DomainError::TagAlreadyExists(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for domain operations
|
||||
pub type DomainResult<T> = Result<T, DomainError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_tag_limit_exceeded_uses_constant() {
|
||||
let error = DomainError::tag_limit_exceeded(15);
|
||||
|
||||
if let DomainError::TagLimitExceeded { max, current } = error {
|
||||
assert_eq!(max, MAX_TAGS_PER_NOTE);
|
||||
assert_eq!(current, 15);
|
||||
} else {
|
||||
panic!("Expected TagLimitExceeded error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display_messages() {
|
||||
let note_id = Uuid::new_v4();
|
||||
let error = DomainError::NoteNotFound(note_id);
|
||||
assert!(error.to_string().contains(¬e_id.to_string()));
|
||||
|
||||
let error = DomainError::validation("Title cannot be empty");
|
||||
assert_eq!(error.to_string(), "Validation error: Title cannot be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_found() {
|
||||
assert!(DomainError::NoteNotFound(Uuid::new_v4()).is_not_found());
|
||||
assert!(DomainError::UserNotFound(Uuid::new_v4()).is_not_found());
|
||||
assert!(DomainError::TagNotFound(Uuid::new_v4()).is_not_found());
|
||||
assert!(!DomainError::validation("test").is_not_found());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_conflict() {
|
||||
assert!(DomainError::UserAlreadyExists("test@example.com".into()).is_conflict());
|
||||
assert!(DomainError::TagAlreadyExists("work".into()).is_conflict());
|
||||
assert!(!DomainError::NoteNotFound(Uuid::new_v4()).is_conflict());
|
||||
}
|
||||
}
|
||||
20
notes-domain/src/lib.rs
Normal file
20
notes-domain/src/lib.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! K-Notes Domain Layer
|
||||
//!
|
||||
//! This crate contains pure domain logic with no I/O dependencies.
|
||||
//! It follows hexagonal architecture principles where:
|
||||
//!
|
||||
//! - **Entities**: Core business objects (Note, Tag, User)
|
||||
//! - **Errors**: Domain-specific error types
|
||||
//! - **Repositories**: Port traits defining data access interfaces
|
||||
//! - **Services**: Use cases orchestrating business logic
|
||||
|
||||
pub mod entities;
|
||||
pub mod errors;
|
||||
pub mod repositories;
|
||||
pub mod services;
|
||||
|
||||
// Re-export commonly used types at crate root
|
||||
pub use entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, Tag, User};
|
||||
pub use errors::{DomainError, DomainResult};
|
||||
pub use repositories::{NoteRepository, TagRepository, UserRepository};
|
||||
pub use services::{CreateNoteRequest, NoteService, TagService, UpdateNoteRequest, UserService};
|
||||
197
notes-domain/src/repositories.rs
Normal file
197
notes-domain/src/repositories.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Repository ports (traits) for K-Notes
|
||||
//!
|
||||
//! These traits define the interface for data persistence without
|
||||
//! specifying the implementation. This is the "port" in hexagonal architecture.
|
||||
//! Concrete implementations (adapters) live in the `notes-infra` crate.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entities::{Note, NoteFilter, Tag, User};
|
||||
use crate::errors::DomainResult;
|
||||
|
||||
/// Repository port for Note persistence
|
||||
#[async_trait]
|
||||
pub trait NoteRepository: Send + Sync {
|
||||
/// Find a note by its ID
|
||||
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<Note>>;
|
||||
|
||||
/// Find all notes for a user, optionally filtered
|
||||
async fn find_by_user(&self, user_id: Uuid, filter: NoteFilter) -> DomainResult<Vec<Note>>;
|
||||
|
||||
/// Save a new note or update an existing one
|
||||
async fn save(&self, note: &Note) -> DomainResult<()>;
|
||||
|
||||
/// Delete a note by its ID
|
||||
async fn delete(&self, id: Uuid) -> DomainResult<()>;
|
||||
|
||||
/// Full-text search across note titles and content
|
||||
async fn search(&self, user_id: Uuid, query: &str) -> DomainResult<Vec<Note>>;
|
||||
}
|
||||
|
||||
/// Repository port for User persistence
|
||||
#[async_trait]
|
||||
pub trait UserRepository: Send + Sync {
|
||||
/// Find a user by their internal ID
|
||||
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>>;
|
||||
|
||||
/// Find a user by their OIDC subject (used for authentication)
|
||||
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>>;
|
||||
|
||||
/// Find a user by their email
|
||||
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>>;
|
||||
|
||||
/// Save a new user or update an existing one
|
||||
async fn save(&self, user: &User) -> DomainResult<()>;
|
||||
|
||||
/// Delete a user by their ID
|
||||
async fn delete(&self, id: Uuid) -> DomainResult<()>;
|
||||
}
|
||||
|
||||
/// Repository port for Tag persistence
|
||||
#[async_trait]
|
||||
pub trait TagRepository: Send + Sync {
|
||||
/// Find a tag by its ID
|
||||
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<Tag>>;
|
||||
|
||||
/// Find all tags for a user
|
||||
async fn find_by_user(&self, user_id: Uuid) -> DomainResult<Vec<Tag>>;
|
||||
|
||||
/// Find a tag by name for a specific user
|
||||
async fn find_by_name(&self, user_id: Uuid, name: &str) -> DomainResult<Option<Tag>>;
|
||||
|
||||
/// Save a new tag or update an existing one
|
||||
async fn save(&self, tag: &Tag) -> DomainResult<()>;
|
||||
|
||||
/// Delete a tag by its ID
|
||||
async fn delete(&self, id: Uuid) -> DomainResult<()>;
|
||||
|
||||
/// Add a tag to a note
|
||||
async fn add_to_note(&self, tag_id: Uuid, note_id: Uuid) -> DomainResult<()>;
|
||||
|
||||
/// Remove a tag from a note
|
||||
async fn remove_from_note(&self, tag_id: Uuid, note_id: Uuid) -> DomainResult<()>;
|
||||
|
||||
/// Get all tags for a specific note
|
||||
async fn find_by_note(&self, note_id: Uuid) -> DomainResult<Vec<Tag>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// In-memory mock implementation for testing
|
||||
pub struct MockNoteRepository {
|
||||
notes: Mutex<HashMap<Uuid, Note>>,
|
||||
}
|
||||
|
||||
impl MockNoteRepository {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
notes: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NoteRepository for MockNoteRepository {
|
||||
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<Note>> {
|
||||
Ok(self.notes.lock().unwrap().get(&id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: Uuid, filter: NoteFilter) -> DomainResult<Vec<Note>> {
|
||||
let notes = self.notes.lock().unwrap();
|
||||
let mut result: Vec<Note> = notes
|
||||
.values()
|
||||
.filter(|n| n.user_id == user_id)
|
||||
.filter(|n| filter.is_pinned.is_none() || filter.is_pinned == Some(n.is_pinned))
|
||||
.filter(|n| {
|
||||
filter.is_archived.is_none() || filter.is_archived == Some(n.is_archived)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
result.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn save(&self, note: &Note) -> DomainResult<()> {
|
||||
self.notes.lock().unwrap().insert(note.id, note.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> DomainResult<()> {
|
||||
self.notes.lock().unwrap().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn search(&self, user_id: Uuid, query: &str) -> DomainResult<Vec<Note>> {
|
||||
let notes = self.notes.lock().unwrap();
|
||||
let query_lower = query.to_lowercase();
|
||||
Ok(notes
|
||||
.values()
|
||||
.filter(|n| n.user_id == user_id)
|
||||
.filter(|n| {
|
||||
n.title.to_lowercase().contains(&query_lower)
|
||||
|| n.content.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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 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");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_note_repository_filter() {
|
||||
let repo = MockNoteRepository::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let mut pinned_note = Note::new(user_id, "Pinned", "Content");
|
||||
pinned_note.is_pinned = true;
|
||||
repo.save(&pinned_note).await.unwrap();
|
||||
|
||||
let regular_note = Note::new(user_id, "Regular", "Content");
|
||||
repo.save(®ular_note).await.unwrap();
|
||||
|
||||
let pinned_only = repo
|
||||
.find_by_user(user_id, NoteFilter::new().pinned())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pinned_only.len(), 1);
|
||||
assert_eq!(pinned_only[0].title, "Pinned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mock_note_repository_search() {
|
||||
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");
|
||||
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");
|
||||
|
||||
let results = repo.search(user_id, "notes").await.unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title, "Meeting Notes");
|
||||
}
|
||||
}
|
||||
699
notes-domain/src/services.rs
Normal file
699
notes-domain/src/services.rs
Normal file
@@ -0,0 +1,699 @@
|
||||
//! Domain services for K-Notes
|
||||
//!
|
||||
//! Services orchestrate business logic, enforce rules, and coordinate
|
||||
//! between repositories. They are the "use cases" of the application.
|
||||
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, Tag, User};
|
||||
use crate::errors::{DomainError, DomainResult};
|
||||
use crate::repositories::{NoteRepository, TagRepository, UserRepository};
|
||||
|
||||
/// Request to create a new note
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateNoteRequest {
|
||||
pub user_id: Uuid,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub tags: Vec<String>,
|
||||
pub color: Option<String>,
|
||||
pub is_pinned: bool,
|
||||
}
|
||||
|
||||
/// Request to update an existing note
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateNoteRequest {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid, // For authorization check
|
||||
pub title: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub is_pinned: Option<bool>,
|
||||
pub is_archived: Option<bool>,
|
||||
pub color: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Service for Note operations
|
||||
pub struct NoteService {
|
||||
note_repo: Arc<dyn NoteRepository>,
|
||||
tag_repo: Arc<dyn TagRepository>,
|
||||
}
|
||||
|
||||
impl NoteService {
|
||||
pub fn new(note_repo: Arc<dyn NoteRepository>, tag_repo: Arc<dyn TagRepository>) -> Self {
|
||||
Self {
|
||||
note_repo,
|
||||
tag_repo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new note with optional tags
|
||||
pub async fn create_note(&self, req: CreateNoteRequest) -> DomainResult<Note> {
|
||||
// Validate title is not empty
|
||||
if req.title.trim().is_empty() {
|
||||
return Err(DomainError::validation("Title cannot be empty"));
|
||||
}
|
||||
|
||||
// Validate tag count
|
||||
if req.tags.len() > MAX_TAGS_PER_NOTE {
|
||||
return Err(DomainError::tag_limit_exceeded(req.tags.len()));
|
||||
}
|
||||
|
||||
// Create the note
|
||||
let mut note = Note::new(req.user_id, req.title, req.content);
|
||||
note.is_pinned = req.is_pinned;
|
||||
if let Some(color) = req.color {
|
||||
note.set_color(color);
|
||||
}
|
||||
|
||||
// Process tags
|
||||
for tag_name in &req.tags {
|
||||
let tag = self.get_or_create_tag(req.user_id, tag_name).await?;
|
||||
note.tags.push(tag);
|
||||
}
|
||||
|
||||
// Save the note
|
||||
self.note_repo.save(¬e).await?;
|
||||
|
||||
// Associate tags with the note
|
||||
for tag in ¬e.tags {
|
||||
self.tag_repo.add_to_note(tag.id, note.id).await?;
|
||||
}
|
||||
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// Update an existing note
|
||||
pub async fn update_note(&self, req: UpdateNoteRequest) -> DomainResult<Note> {
|
||||
// Find the note
|
||||
let mut note = self
|
||||
.note_repo
|
||||
.find_by_id(req.id)
|
||||
.await?
|
||||
.ok_or(DomainError::NoteNotFound(req.id))?;
|
||||
|
||||
// Authorization check
|
||||
if note.user_id != req.user_id {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Cannot modify another user's note",
|
||||
));
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if let Some(title) = req.title {
|
||||
if title.trim().is_empty() {
|
||||
return Err(DomainError::validation("Title cannot be empty"));
|
||||
}
|
||||
note.set_title(title);
|
||||
}
|
||||
|
||||
if let Some(content) = req.content {
|
||||
note.set_content(content);
|
||||
}
|
||||
|
||||
if let Some(pinned) = req.is_pinned {
|
||||
note.set_pinned(pinned);
|
||||
}
|
||||
|
||||
if let Some(archived) = req.is_archived {
|
||||
note.set_archived(archived);
|
||||
}
|
||||
|
||||
if let Some(color) = req.color {
|
||||
note.set_color(color);
|
||||
}
|
||||
|
||||
// Handle tag updates
|
||||
if let Some(tag_names) = req.tags {
|
||||
if tag_names.len() > MAX_TAGS_PER_NOTE {
|
||||
return Err(DomainError::tag_limit_exceeded(tag_names.len()));
|
||||
}
|
||||
|
||||
// Remove old tags
|
||||
for tag in ¬e.tags {
|
||||
self.tag_repo.remove_from_note(tag.id, note.id).await?;
|
||||
}
|
||||
|
||||
// Add new tags
|
||||
note.tags.clear();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
self.note_repo.save(¬e).await?;
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// Get a note by ID with authorization check
|
||||
pub async fn get_note(&self, id: Uuid, user_id: Uuid) -> DomainResult<Note> {
|
||||
let note = self
|
||||
.note_repo
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::NoteNotFound(id))?;
|
||||
|
||||
if note.user_id != user_id {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Cannot access another user's note",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// List notes for a user with optional filters
|
||||
pub async fn list_notes(&self, user_id: Uuid, filter: NoteFilter) -> DomainResult<Vec<Note>> {
|
||||
self.note_repo.find_by_user(user_id, filter).await
|
||||
}
|
||||
|
||||
/// Delete a note with authorization check
|
||||
pub async fn delete_note(&self, id: Uuid, user_id: Uuid) -> DomainResult<()> {
|
||||
let note = self
|
||||
.note_repo
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::NoteNotFound(id))?;
|
||||
|
||||
if note.user_id != user_id {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Cannot delete another user's note",
|
||||
));
|
||||
}
|
||||
|
||||
// Remove tag associations
|
||||
for tag in ¬e.tags {
|
||||
self.tag_repo.remove_from_note(tag.id, id).await?;
|
||||
}
|
||||
|
||||
self.note_repo.delete(id).await
|
||||
}
|
||||
|
||||
/// Search notes by query
|
||||
pub async fn search_notes(&self, user_id: Uuid, query: &str) -> DomainResult<Vec<Note>> {
|
||||
if query.trim().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.note_repo.search(user_id, query).await
|
||||
}
|
||||
|
||||
/// Get or create a tag by name
|
||||
async fn get_or_create_tag(&self, user_id: Uuid, name: &str) -> DomainResult<Tag> {
|
||||
let name = name.trim().to_lowercase();
|
||||
if let Some(tag) = self.tag_repo.find_by_name(user_id, &name).await? {
|
||||
Ok(tag)
|
||||
} else {
|
||||
let tag = Tag::new(name, user_id);
|
||||
self.tag_repo.save(&tag).await?;
|
||||
Ok(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for Tag operations
|
||||
pub struct TagService {
|
||||
tag_repo: Arc<dyn TagRepository>,
|
||||
}
|
||||
|
||||
impl TagService {
|
||||
pub fn new(tag_repo: Arc<dyn TagRepository>) -> Self {
|
||||
Self { tag_repo }
|
||||
}
|
||||
|
||||
/// Create a new tag
|
||||
pub async fn create_tag(&self, user_id: Uuid, name: &str) -> 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
|
||||
if self.tag_repo.find_by_name(user_id, &name).await?.is_some() {
|
||||
return Err(DomainError::TagAlreadyExists(name));
|
||||
}
|
||||
|
||||
let tag = Tag::new(name, user_id);
|
||||
self.tag_repo.save(&tag).await?;
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
/// List all tags for a user
|
||||
pub async fn list_tags(&self, user_id: Uuid) -> DomainResult<Vec<Tag>> {
|
||||
self.tag_repo.find_by_user(user_id).await
|
||||
}
|
||||
|
||||
/// Delete a tag
|
||||
pub async fn delete_tag(&self, id: Uuid, user_id: Uuid) -> DomainResult<()> {
|
||||
let tag = self
|
||||
.tag_repo
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::TagNotFound(id))?;
|
||||
|
||||
if tag.user_id != user_id {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Cannot delete another user's tag",
|
||||
));
|
||||
}
|
||||
|
||||
self.tag_repo.delete(id).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for User operations (OIDC-ready)
|
||||
pub struct UserService {
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub fn new(user_repo: Arc<dyn UserRepository>) -> Self {
|
||||
Self { user_repo }
|
||||
}
|
||||
|
||||
/// Find or create a user by OIDC subject
|
||||
/// This is the main entry point for OIDC authentication
|
||||
pub async fn find_or_create_by_subject(
|
||||
&self,
|
||||
subject: &str,
|
||||
email: &str,
|
||||
) -> DomainResult<User> {
|
||||
if let Some(user) = self.user_repo.find_by_subject(subject).await? {
|
||||
Ok(user)
|
||||
} else {
|
||||
let user = User::new(subject, email);
|
||||
self.user_repo.save(&user).await?;
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a user by ID
|
||||
pub async fn get_user(&self, id: Uuid) -> DomainResult<User> {
|
||||
self.user_repo
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::UserNotFound(id))
|
||||
}
|
||||
|
||||
/// Delete a user and all associated data
|
||||
pub async fn delete_user(&self, id: Uuid) -> DomainResult<()> {
|
||||
// Note: In practice, we'd also need to delete notes and tags
|
||||
// This would be handled by cascade delete in the database
|
||||
// or by coordinating with other services
|
||||
self.user_repo.delete(id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repositories::tests::MockNoteRepository;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Mock implementations for testing
|
||||
struct MockTagRepository {
|
||||
tags: Mutex<HashMap<Uuid, Tag>>,
|
||||
note_tags: Mutex<HashMap<(Uuid, Uuid), ()>>,
|
||||
}
|
||||
|
||||
impl MockTagRepository {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
tags: Mutex::new(HashMap::new()),
|
||||
note_tags: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TagRepository for MockTagRepository {
|
||||
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<Tag>> {
|
||||
Ok(self.tags.lock().unwrap().get(&id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_user(&self, user_id: Uuid) -> DomainResult<Vec<Tag>> {
|
||||
Ok(self
|
||||
.tags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|t| t.user_id == user_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_by_name(&self, user_id: Uuid, name: &str) -> DomainResult<Option<Tag>> {
|
||||
Ok(self
|
||||
.tags
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|t| t.user_id == user_id && t.name == name)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn save(&self, tag: &Tag) -> DomainResult<()> {
|
||||
self.tags.lock().unwrap().insert(tag.id, tag.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> DomainResult<()> {
|
||||
self.tags.lock().unwrap().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_to_note(&self, tag_id: Uuid, note_id: Uuid) -> DomainResult<()> {
|
||||
self.note_tags.lock().unwrap().insert((tag_id, note_id), ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_from_note(&self, tag_id: Uuid, note_id: Uuid) -> DomainResult<()> {
|
||||
self.note_tags.lock().unwrap().remove(&(tag_id, note_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_note(&self, note_id: Uuid) -> DomainResult<Vec<Tag>> {
|
||||
let note_tags = self.note_tags.lock().unwrap();
|
||||
let tags = self.tags.lock().unwrap();
|
||||
Ok(note_tags
|
||||
.keys()
|
||||
.filter(|(_, nid)| *nid == note_id)
|
||||
.filter_map(|(tid, _)| tags.get(tid).cloned())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
struct MockUserRepository {
|
||||
users: Mutex<HashMap<Uuid, User>>,
|
||||
}
|
||||
|
||||
impl MockUserRepository {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
users: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl UserRepository for MockUserRepository {
|
||||
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<User>> {
|
||||
Ok(self.users.lock().unwrap().get(&id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
|
||||
Ok(self
|
||||
.users
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|u| u.subject == subject)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_by_email(&self, email: &str) -> DomainResult<Option<User>> {
|
||||
Ok(self
|
||||
.users
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|u| u.email == email)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn save(&self, user: &User) -> DomainResult<()> {
|
||||
self.users.lock().unwrap().insert(user.id, user.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> DomainResult<()> {
|
||||
self.users.lock().unwrap().remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
mod note_service_tests {
|
||||
use super::*;
|
||||
|
||||
fn create_note_service() -> (NoteService, Uuid) {
|
||||
let note_repo = Arc::new(MockNoteRepository::new());
|
||||
let tag_repo = Arc::new(MockTagRepository::new());
|
||||
let user_id = Uuid::new_v4();
|
||||
(NoteService::new(note_repo, tag_repo), user_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_note_success() {
|
||||
let (service, user_id) = create_note_service();
|
||||
|
||||
let req = CreateNoteRequest {
|
||||
user_id,
|
||||
title: "My Note".to_string(),
|
||||
content: "# Hello World".to_string(),
|
||||
tags: vec![],
|
||||
color: None,
|
||||
is_pinned: false,
|
||||
};
|
||||
|
||||
let note = service.create_note(req).await.unwrap();
|
||||
|
||||
assert_eq!(note.title, "My Note");
|
||||
assert_eq!(note.content, "# Hello World");
|
||||
assert_eq!(note.user_id, user_id);
|
||||
assert_eq!(note.color, "DEFAULT");
|
||||
assert!(!note.is_pinned);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_note_with_tags() {
|
||||
let (service, user_id) = create_note_service();
|
||||
|
||||
let req = CreateNoteRequest {
|
||||
user_id,
|
||||
title: "Tagged Note".to_string(),
|
||||
content: "Content".to_string(),
|
||||
tags: vec!["work".to_string(), "important".to_string()],
|
||||
color: None,
|
||||
is_pinned: false,
|
||||
};
|
||||
|
||||
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(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_note_too_many_tags_fails() {
|
||||
let (service, user_id) = create_note_service();
|
||||
|
||||
let tags: Vec<String> = (0..=MAX_TAGS_PER_NOTE)
|
||||
.map(|i| format!("tag-{}", i))
|
||||
.collect();
|
||||
|
||||
let req = CreateNoteRequest {
|
||||
user_id,
|
||||
title: "Note".to_string(),
|
||||
content: "Content".to_string(),
|
||||
tags,
|
||||
color: None,
|
||||
is_pinned: false,
|
||||
};
|
||||
|
||||
let result = service.create_note(req).await;
|
||||
assert!(matches!(result, Err(DomainError::TagLimitExceeded { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_note_success() {
|
||||
let (service, user_id) = create_note_service();
|
||||
|
||||
// Create a note first
|
||||
let create_req = CreateNoteRequest {
|
||||
user_id,
|
||||
title: "Original".to_string(),
|
||||
content: "Original content".to_string(),
|
||||
tags: vec![],
|
||||
color: None,
|
||||
is_pinned: false,
|
||||
};
|
||||
let note = service.create_note(create_req).await.unwrap();
|
||||
|
||||
// Update it
|
||||
let update_req = UpdateNoteRequest {
|
||||
id: note.id,
|
||||
user_id,
|
||||
title: Some("Updated".to_string()),
|
||||
content: None,
|
||||
is_pinned: Some(true),
|
||||
is_archived: None,
|
||||
color: Some("red".to_string()),
|
||||
tags: None,
|
||||
};
|
||||
let updated = service.update_note(update_req).await.unwrap();
|
||||
|
||||
assert_eq!(updated.title, "Updated");
|
||||
assert_eq!(updated.content, "Original content"); // Unchanged
|
||||
assert!(updated.is_pinned);
|
||||
assert_eq!(updated.color, "red");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_note_unauthorized() {
|
||||
let (service, user_id) = create_note_service();
|
||||
let other_user = Uuid::new_v4();
|
||||
|
||||
// Create a note
|
||||
let create_req = CreateNoteRequest {
|
||||
user_id,
|
||||
title: "My Note".to_string(),
|
||||
content: "Content".to_string(),
|
||||
tags: vec![],
|
||||
color: None,
|
||||
is_pinned: false,
|
||||
};
|
||||
let note = service.create_note(create_req).await.unwrap();
|
||||
|
||||
// Try to update with different user
|
||||
let update_req = UpdateNoteRequest {
|
||||
id: note.id,
|
||||
user_id: other_user,
|
||||
title: Some("Hacked".to_string()),
|
||||
content: None,
|
||||
is_pinned: None,
|
||||
is_archived: None,
|
||||
color: None,
|
||||
tags: None,
|
||||
};
|
||||
let result = service.update_note(update_req).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_note_success() {
|
||||
let (service, user_id) = create_note_service();
|
||||
|
||||
let create_req = CreateNoteRequest {
|
||||
user_id,
|
||||
title: "To Delete".to_string(),
|
||||
content: "Content".to_string(),
|
||||
tags: vec![],
|
||||
color: None,
|
||||
is_pinned: false,
|
||||
};
|
||||
let note = service.create_note(create_req).await.unwrap();
|
||||
|
||||
service.delete_note(note.id, user_id).await.unwrap();
|
||||
|
||||
let result = service.get_note(note.id, user_id).await;
|
||||
assert!(matches!(result, Err(DomainError::NoteNotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_empty_query_returns_empty() {
|
||||
let (service, user_id) = create_note_service();
|
||||
|
||||
let results = service.search_notes(user_id, " ").await.unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
mod tag_service_tests {
|
||||
use super::*;
|
||||
|
||||
fn create_tag_service() -> (TagService, Uuid) {
|
||||
let tag_repo = Arc::new(MockTagRepository::new());
|
||||
let user_id = Uuid::new_v4();
|
||||
(TagService::new(tag_repo), user_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_tag_success() {
|
||||
let (service, user_id) = create_tag_service();
|
||||
|
||||
let tag = service.create_tag(user_id, "Work").await.unwrap();
|
||||
|
||||
assert_eq!(tag.name, "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
|
||||
|
||||
assert!(matches!(result, Err(DomainError::TagAlreadyExists(_))));
|
||||
}
|
||||
}
|
||||
|
||||
mod user_service_tests {
|
||||
use super::*;
|
||||
|
||||
fn create_user_service() -> UserService {
|
||||
let user_repo = Arc::new(MockUserRepository::new());
|
||||
UserService::new(user_repo)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_or_create_creates_new_user() {
|
||||
let service = create_user_service();
|
||||
|
||||
let user = service
|
||||
.find_or_create_by_subject("oidc|123", "test@example.com")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user.subject, "oidc|123");
|
||||
assert_eq!(user.email, "test@example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_or_create_returns_existing_user() {
|
||||
let service = create_user_service();
|
||||
|
||||
let user1 = service
|
||||
.find_or_create_by_subject("oidc|123", "test@example.com")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user2 = service
|
||||
.find_or_create_by_subject("oidc|123", "test@example.com")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(user1.id, user2.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user