//! L12 tests for the memory use cases (ARCHITECTURE §14.5.1) with in-memory //! port fakes (no real store/FS): CRUD (`CreateMemory`, `UpdateMemory`, //! `GetMemory`, `ListMemories`, `DeleteMemory`) asserting the `MemorySaved` / //! `MemoryDeleted` events, and the read-only navigation helpers //! (`ReadMemoryIndex`, `ResolveMemoryLinks`) asserting they emit nothing. use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::events::DomainEvent; use domain::ports::{EventBus, EventStream, MemoryError, MemoryQuery, MemoryRecall, MemoryStore}; use domain::{ MarkdownDoc, Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, ProjectPath, }; use application::{ CreateMemory, CreateMemoryInput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput, ListMemories, ListMemoriesInput, ReadMemoryIndex, ReadMemoryIndexInput, RecallMemory, RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory, UpdateMemoryInput, }; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- /// In-memory memory store keyed by slug. Index and links are derived from the /// stored notes (mirroring the real adapter), so navigation reads stay honest. #[derive(Clone, Default)] struct FakeMemories(Arc>>); #[async_trait] impl MemoryStore for FakeMemories { async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { Ok(self.0.lock().unwrap().clone()) } async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result { self.0 .lock() .unwrap() .iter() .find(|m| m.slug() == slug) .cloned() .ok_or(MemoryError::NotFound) } async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> { let mut v = self.0.lock().unwrap(); if let Some(slot) = v.iter_mut().find(|m| m.slug() == memory.slug()) { *slot = memory.clone(); } else { v.push(memory.clone()); } Ok(()) } async fn delete(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError> { let mut v = self.0.lock().unwrap(); let before = v.len(); v.retain(|m| m.slug() != slug); if v.len() == before { return Err(MemoryError::NotFound); } Ok(()) } async fn read_index( &self, _root: &ProjectPath, ) -> Result, MemoryError> { Ok(self .0 .lock() .unwrap() .iter() .map(Memory::index_entry) .collect()) } async fn resolve_links( &self, _root: &ProjectPath, slug: &MemorySlug, ) -> Result, MemoryError> { let v = self.0.lock().unwrap(); let source = v .iter() .find(|m| m.slug() == slug) .ok_or(MemoryError::NotFound)?; // Mirror the adapter: drop links whose target note does not exist. Ok(source .outgoing_links() .into_iter() .filter(|l| v.iter().any(|m| m.slug() == &l.target)) .collect()) } } #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn events(&self) -> Vec { self.0.lock().unwrap().clone() } } impl EventBus for SpyBus { fn publish(&self, event: DomainEvent) { self.0.lock().unwrap().push(event); } fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } // --------------------------------------------------------------------------- // Builders // --------------------------------------------------------------------------- fn root() -> ProjectPath { ProjectPath::new("/home/me/demo").unwrap() } fn slug(s: &str) -> MemorySlug { MemorySlug::new(s).unwrap() } /// Builds and persists a note directly through the store (bypassing use cases). async fn seed(store: &FakeMemories, name: &str, body: &str) { let memory = Memory::new( MemoryFrontmatter { name: slug(name), description: format!("{name} hook"), r#type: MemoryType::Project, }, MarkdownDoc::new(body), ) .unwrap(); store.save(&root(), &memory).await.unwrap(); } fn saved_slugs(events: &[DomainEvent]) -> Vec { events .iter() .filter_map(|e| match e { DomainEvent::MemorySaved { slug } => Some(slug.clone()), _ => None, }) .collect() } fn deleted_slugs(events: &[DomainEvent]) -> Vec { events .iter() .filter_map(|e| match e { DomainEvent::MemoryDeleted { slug } => Some(slug.clone()), _ => None, }) .collect() } // --------------------------------------------------------------------------- // CreateMemory // --------------------------------------------------------------------------- #[tokio::test] async fn create_persists_note_and_emits_memory_saved_with_slug() { let store = FakeMemories::default(); let bus = SpyBus::default(); let out = CreateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) .execute(CreateMemoryInput { project_root: root(), name: "design-decision".to_owned(), description: "why we chose hexagonal".to_owned(), r#type: MemoryType::Project, content: "# body".to_owned(), }) .await .unwrap(); assert_eq!(out.memory.slug(), &slug("design-decision")); // Persisted. let listed = store.list(&root()).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].slug(), &slug("design-decision")); // Emits exactly one MemorySaved with the right slug. assert_eq!(saved_slugs(&bus.events()), vec![slug("design-decision")]); } #[tokio::test] async fn create_rejects_non_kebab_name_as_invalid() { let store = FakeMemories::default(); let bus = SpyBus::default(); let err = CreateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) .execute(CreateMemoryInput { project_root: root(), name: "Not Kebab".to_owned(), description: "x".to_owned(), r#type: MemoryType::User, content: "# body".to_owned(), }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID"); // Nothing persisted, nothing announced. assert!(store.list(&root()).await.unwrap().is_empty()); assert!(bus.events().is_empty()); } #[tokio::test] async fn create_rejects_empty_description() { let store = FakeMemories::default(); let bus = SpyBus::default(); let err = CreateMemory::new(Arc::new(store), Arc::new(bus)) .execute(CreateMemoryInput { project_root: root(), name: "ok-slug".to_owned(), description: String::new(), r#type: MemoryType::User, content: "# body".to_owned(), }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID"); } #[tokio::test] async fn create_rejects_empty_content() { let store = FakeMemories::default(); let bus = SpyBus::default(); let err = CreateMemory::new(Arc::new(store), Arc::new(bus)) .execute(CreateMemoryInput { project_root: root(), name: "ok-slug".to_owned(), description: "a hook".to_owned(), r#type: MemoryType::User, content: String::new(), }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID"); } // --------------------------------------------------------------------------- // UpdateMemory // --------------------------------------------------------------------------- #[tokio::test] async fn update_replaces_content_and_emits_memory_saved() { let store = FakeMemories::default(); seed(&store, "note-a", "v1").await; let bus = SpyBus::default(); let out = UpdateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) .execute(UpdateMemoryInput { project_root: root(), slug: slug("note-a"), description: "updated hook".to_owned(), r#type: MemoryType::Reference, content: "v2".to_owned(), }) .await .unwrap(); assert_eq!(out.memory.body.as_str(), "v2"); assert_eq!(out.memory.frontmatter.description, "updated hook"); assert_eq!(out.memory.frontmatter.r#type, MemoryType::Reference); // Replace semantics: still a single note. let listed = store.list(&root()).await.unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].body.as_str(), "v2"); assert_eq!(saved_slugs(&bus.events()), vec![slug("note-a")]); } #[tokio::test] async fn update_revalidates_invariants_and_rejects_empty_content() { let store = FakeMemories::default(); seed(&store, "note-a", "v1").await; let bus = SpyBus::default(); let err = UpdateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) .execute(UpdateMemoryInput { project_root: root(), slug: slug("note-a"), description: "still here".to_owned(), r#type: MemoryType::Project, content: String::new(), }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID"); // Original note untouched, no event. assert_eq!(store.get(&root(), &slug("note-a")).await.unwrap().body.as_str(), "v1"); assert!(bus.events().is_empty()); } // --------------------------------------------------------------------------- // DeleteMemory // --------------------------------------------------------------------------- #[tokio::test] async fn delete_removes_note_and_emits_memory_deleted() { let store = FakeMemories::default(); seed(&store, "note-a", "body").await; let bus = SpyBus::default(); DeleteMemory::new(Arc::new(store.clone()), Arc::new(bus.clone())) .execute(DeleteMemoryInput { project_root: root(), slug: slug("note-a"), }) .await .unwrap(); assert!(store.list(&root()).await.unwrap().is_empty()); assert_eq!(deleted_slugs(&bus.events()), vec![slug("note-a")]); } #[tokio::test] async fn delete_unknown_slug_is_not_found_and_emits_nothing() { let store = FakeMemories::default(); let bus = SpyBus::default(); let err = DeleteMemory::new(Arc::new(store), Arc::new(bus.clone())) .execute(DeleteMemoryInput { project_root: root(), slug: slug("ghost"), }) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND"); assert!(bus.events().is_empty()); } // --------------------------------------------------------------------------- // GetMemory // --------------------------------------------------------------------------- #[tokio::test] async fn get_returns_the_note() { let store = FakeMemories::default(); seed(&store, "note-a", "hello").await; let out = GetMemory::new(Arc::new(store)) .execute(GetMemoryInput { project_root: root(), slug: slug("note-a"), }) .await .unwrap(); assert_eq!(out.memory.slug(), &slug("note-a")); assert_eq!(out.memory.body.as_str(), "hello"); } #[tokio::test] async fn get_unknown_slug_is_not_found() { let store = FakeMemories::default(); let err = GetMemory::new(Arc::new(store)) .execute(GetMemoryInput { project_root: root(), slug: slug("ghost"), }) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND"); } // --------------------------------------------------------------------------- // ListMemories // --------------------------------------------------------------------------- #[tokio::test] async fn list_returns_all_notes() { let store = FakeMemories::default(); seed(&store, "note-a", "a").await; seed(&store, "note-b", "b").await; let out = ListMemories::new(Arc::new(store)) .execute(ListMemoriesInput { project_root: root(), }) .await .unwrap(); let mut slugs: Vec<_> = out.memories.iter().map(|m| m.slug().clone()).collect(); slugs.sort(); assert_eq!(slugs, vec![slug("note-a"), slug("note-b")]); } #[tokio::test] async fn list_is_empty_when_no_notes() { let store = FakeMemories::default(); let out = ListMemories::new(Arc::new(store)) .execute(ListMemoriesInput { project_root: root(), }) .await .unwrap(); assert!(out.memories.is_empty()); } // --------------------------------------------------------------------------- // ReadMemoryIndex // --------------------------------------------------------------------------- #[tokio::test] async fn read_index_returns_one_row_per_note() { let store = FakeMemories::default(); seed(&store, "note-a", "a").await; seed(&store, "note-b", "b").await; let out = ReadMemoryIndex::new(Arc::new(store)) .execute(ReadMemoryIndexInput { project_root: root(), }) .await .unwrap(); assert_eq!(out.entries.len(), 2); let mut rows: Vec<_> = out .entries .iter() .map(|e| (e.slug.clone(), e.hook.clone())) .collect(); rows.sort(); assert_eq!( rows, vec![ (slug("note-a"), "note-a hook".to_owned()), (slug("note-b"), "note-b hook".to_owned()), ] ); } // --------------------------------------------------------------------------- // ResolveMemoryLinks // --------------------------------------------------------------------------- #[tokio::test] async fn resolve_links_returns_outgoing_links_dropping_broken_ones() { let store = FakeMemories::default(); // note-a links to note-b (exists) and to ghost (does not). seed(&store, "note-a", "see [[note-b]] and [[ghost]]").await; seed(&store, "note-b", "target body").await; let out = ResolveMemoryLinks::new(Arc::new(store)) .execute(ResolveMemoryLinksInput { project_root: root(), slug: slug("note-a"), }) .await .unwrap(); // Only the resolvable link survives; the broken one is dropped. assert_eq!( out.links, vec![MemoryLink { target: slug("note-b") }] ); } #[tokio::test] async fn resolve_links_for_unknown_source_is_not_found() { let store = FakeMemories::default(); let err = ResolveMemoryLinks::new(Arc::new(store)) .execute(ResolveMemoryLinksInput { project_root: root(), slug: slug("ghost"), }) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND"); } // --------------------------------------------------------------------------- // RecallMemory // --------------------------------------------------------------------------- /// In-memory [`MemoryRecall`] fake: records the queries it received and returns a /// canned set of entries. Lets us assert the use case delegates faithfully (same /// `root`, the input `text`/`token_budget` forwarded) and surfaces the result. #[derive(Clone, Default)] struct FakeRecall { entries: Arc>, seen: Arc>>, } impl FakeRecall { fn returning(entries: Vec) -> Self { Self { entries: Arc::new(entries), seen: Arc::default(), } } fn queries(&self) -> Vec { self.seen.lock().unwrap().clone() } } #[async_trait] impl MemoryRecall for FakeRecall { async fn recall( &self, _root: &ProjectPath, query: &MemoryQuery, ) -> Result, MemoryError> { self.seen.lock().unwrap().push(query.clone()); Ok((*self.entries).clone()) } } fn entry(slug_str: &str, hook: &str) -> MemoryIndexEntry { MemoryIndexEntry { slug: slug(slug_str), title: slug_str.to_owned(), hook: hook.to_owned(), r#type: MemoryType::Reference, } } #[tokio::test] async fn recall_delegates_to_port_and_returns_entries() { let recalled = vec![entry("alpha", "first"), entry("beta", "second")]; let recall = FakeRecall::returning(recalled.clone()); let out = RecallMemory::new(Arc::new(recall.clone())) .execute(RecallMemoryInput { project_root: root(), text: "current context".to_owned(), token_budget: 42, }) .await .unwrap(); // Returns exactly what the port produced, in order. assert_eq!(out.entries, recalled); // The query was forwarded verbatim (text + budget). assert_eq!( recall.queries(), vec![MemoryQuery { text: "current context".to_owned(), token_budget: 42, }] ); } #[tokio::test] async fn recall_empty_result_is_empty_not_error() { let recall = FakeRecall::returning(Vec::new()); let out = RecallMemory::new(Arc::new(recall)) .execute(RecallMemoryInput { project_root: root(), text: String::new(), token_budget: 0, }) .await .unwrap(); assert!(out.entries.is_empty()); } #[tokio::test] async fn recall_publishes_no_events() { // RecallMemory takes no EventBus; assert a shared spy stays empty when other // (mutating) use cases on the same bus are NOT exercised — i.e. recall is // read-only by construction. let recall = FakeRecall::returning(vec![entry("alpha", "first")]); let bus = SpyBus::default(); RecallMemory::new(Arc::new(recall)) .execute(RecallMemoryInput { project_root: root(), text: "ctx".to_owned(), token_budget: 10, }) .await .unwrap(); assert!( bus.events().is_empty(), "recall must not publish any DomainEvent" ); } // --------------------------------------------------------------------------- // Read use cases emit no events // --------------------------------------------------------------------------- #[tokio::test] async fn read_use_cases_publish_no_events() { let store = FakeMemories::default(); seed(&store, "note-a", "see [[note-a]]").await; let bus = SpyBus::default(); // None of the read use cases take an EventBus — assert the shared spy stays // empty after exercising every read path on the same store. GetMemory::new(Arc::new(store.clone())) .execute(GetMemoryInput { project_root: root(), slug: slug("note-a"), }) .await .unwrap(); ListMemories::new(Arc::new(store.clone())) .execute(ListMemoriesInput { project_root: root(), }) .await .unwrap(); ReadMemoryIndex::new(Arc::new(store.clone())) .execute(ReadMemoryIndexInput { project_root: root(), }) .await .unwrap(); ResolveMemoryLinks::new(Arc::new(store)) .execute(ResolveMemoryLinksInput { project_root: root(), slug: slug("note-a"), }) .await .unwrap(); assert!( bus.events().is_empty(), "read use cases must not publish any DomainEvent" ); }