//! 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>; /// Find all notes for a user, optionally filtered async fn find_by_user(&self, user_id: Uuid, filter: NoteFilter) -> DomainResult>; /// 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>; } /// 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>; /// Find a user by their OIDC subject (used for authentication) async fn find_by_subject(&self, subject: &str) -> DomainResult>; /// Find a user by their email async fn find_by_email(&self, email: &str) -> DomainResult>; /// 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>; /// Find all tags for a user async fn find_by_user(&self, user_id: Uuid) -> DomainResult>; /// Find a tag by name for a specific user async fn find_by_name(&self, user_id: Uuid, name: &str) -> DomainResult>; /// 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>; } #[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>, } 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> { Ok(self.notes.lock().unwrap().get(&id).cloned()) } async fn find_by_user(&self, user_id: Uuid, filter: NoteFilter) -> DomainResult> { let notes = self.notes.lock().unwrap(); let mut result: Vec = 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> { 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"); } }