//! Tests for [`FsMemoryStore`] against an **in-memory mock** [`FileSystem`]: //! `save` writes the `.md` AND the `MEMORY.md` index line; get/list/delete; //! upsert idempotence (same slug => one index line); malformed frontmatter //! surfaces as [`MemoryError::Frontmatter`]; and `resolve_links` ignores broken //! links. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::markdown::MarkdownDoc; use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType}; use domain::ports::{DirEntry, FileSystem, FsError, MemoryError, MemoryStore, RemotePath}; use domain::project::ProjectPath; use infrastructure::FsMemoryStore; /// A minimal in-memory [`FileSystem`] mock: a path -> bytes map behind a mutex. /// `create_dir_all`/`symlink`/`list` are no-ops or derived; only read/write/exists /// matter for the memory store. #[derive(Default)] struct MemFs { files: Mutex>>, } impl MemFs { fn arc() -> Arc { Arc::new(Self::default()) } fn raw(&self, path: &str) -> Option { self.files .lock() .unwrap() .get(path) .map(|b| String::from_utf8(b.clone()).unwrap()) } /// Directly seeds a file (to inject a malformed note bypassing the store). fn put(&self, path: &str, content: &str) { self.files .lock() .unwrap() .insert(path.to_string(), content.as_bytes().to_vec()); } } #[async_trait] impl FileSystem for MemFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.files .lock() .unwrap() .get(path.as_str()) .cloned() .ok_or_else(|| FsError::NotFound(path.as_str().to_string())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { self.files .lock() .unwrap() .insert(path.as_str().to_string(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { Ok(self.files.lock().unwrap().contains_key(path.as_str())) } async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } fn root() -> ProjectPath { ProjectPath::new("/proj").unwrap() } fn slug(s: &str) -> MemorySlug { MemorySlug::new(s).unwrap() } fn note(slug_str: &str, hook: &str, ty: MemoryType, body: &str) -> Memory { Memory::new( MemoryFrontmatter { name: MemorySlug::new(slug_str).unwrap(), description: hook.to_string(), r#type: ty, }, MarkdownDoc::new(body), ) .unwrap() } #[tokio::test] async fn missing_index_lists_empty() { let store = FsMemoryStore::new(MemFs::arc()); assert!(store.list(&root()).await.unwrap().is_empty()); assert!(store.read_index(&root()).await.unwrap().is_empty()); } #[tokio::test] async fn save_writes_md_and_index_line() { let fs = MemFs::arc(); let store = FsMemoryStore::new(fs.clone()); store .save(&root(), ¬e("alpha", "the hook", MemoryType::Project, "# Body")) .await .unwrap(); // The note `.md` exists with frontmatter + body. let md = fs.raw("/proj/.ideai/memory/alpha.md").expect("note written"); assert!(md.starts_with("---\n")); assert!(md.contains("name: alpha")); assert!(md.contains("description: the hook")); assert!(md.contains("type: project")); assert!(md.contains("# Body")); // The index has the header + the line. let index = fs.raw("/proj/.ideai/memory/MEMORY.md").expect("index written"); assert!(index.starts_with("# Memory Index")); assert!(index.contains("- [alpha](alpha.md) — the hook")); } #[tokio::test] async fn get_roundtrips_the_saved_note() { let store = FsMemoryStore::new(MemFs::arc()); let m = note("topic-x", "hook", MemoryType::Feedback, "content [[other]]"); store.save(&root(), &m).await.unwrap(); let got = store.get(&root(), &slug("topic-x")).await.unwrap(); assert_eq!(got, m); } #[tokio::test] async fn get_missing_is_not_found() { let store = FsMemoryStore::new(MemFs::arc()); assert!(matches!( store.get(&root(), &slug("nope")).await.unwrap_err(), MemoryError::NotFound )); } #[tokio::test] async fn list_returns_all_saved_notes() { let store = FsMemoryStore::new(MemFs::arc()); store .save(&root(), ¬e("a", "ha", MemoryType::User, "A")) .await .unwrap(); store .save(&root(), ¬e("b", "hb", MemoryType::Project, "B")) .await .unwrap(); let mut slugs: Vec<_> = store .list(&root()) .await .unwrap() .into_iter() .map(|m| m.slug().as_str().to_string()) .collect(); slugs.sort(); assert_eq!(slugs, vec!["a".to_string(), "b".to_string()]); } #[tokio::test] async fn save_is_idempotent_upsert_single_index_line() { let fs = MemFs::arc(); let store = FsMemoryStore::new(fs.clone()); store .save(&root(), ¬e("dup", "hook v1", MemoryType::Project, "v1")) .await .unwrap(); store .save(&root(), ¬e("dup", "hook v2", MemoryType::Project, "v2")) .await .unwrap(); // Content was replaced. assert_eq!( store .get(&root(), &slug("dup")) .await .unwrap() .body .as_str(), "v2" ); // Exactly one index entry for the slug. let entries = store.read_index(&root()).await.unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].hook, "hook v2"); // And the raw index has only one matching line. let index = fs.raw("/proj/.ideai/memory/MEMORY.md").unwrap(); assert_eq!(index.matches("- [dup]").count(), 1); } #[tokio::test] async fn delete_removes_index_line_and_is_not_found_twice() { let store = FsMemoryStore::new(MemFs::arc()); store .save(&root(), ¬e("gone", "h", MemoryType::User, "x")) .await .unwrap(); store.delete(&root(), &slug("gone")).await.unwrap(); assert!(store.list(&root()).await.unwrap().is_empty()); assert!(matches!( store.delete(&root(), &slug("gone")).await.unwrap_err(), MemoryError::NotFound )); } #[tokio::test] async fn malformed_frontmatter_surfaces_as_error() { let fs = MemFs::arc(); // Seed the index so the slug is listed, plus a malformed note file. fs.put( "/proj/.ideai/memory/MEMORY.md", "# Memory Index\n\n- [bad](bad.md) — h\n", ); fs.put( "/proj/.ideai/memory/bad.md", "no frontmatter fence here\njust body", ); let store = FsMemoryStore::new(fs); assert!(matches!( store.get(&root(), &slug("bad")).await.unwrap_err(), MemoryError::Frontmatter(_) )); // list propagates the same error. assert!(matches!( store.list(&root()).await.unwrap_err(), MemoryError::Frontmatter(_) )); } #[tokio::test] async fn resolve_links_ignores_broken_links() { let store = FsMemoryStore::new(MemFs::arc()); // `target` exists; `ghost` does not. store .save(&root(), ¬e("target", "h", MemoryType::Project, "T")) .await .unwrap(); store .save( &root(), ¬e( "src", "h", MemoryType::Project, "links to [[target]] and [[ghost]]", ), ) .await .unwrap(); let resolved = store.resolve_links(&root(), &slug("src")).await.unwrap(); let targets: Vec<_> = resolved .into_iter() .map(|l| l.target.as_str().to_string()) .collect(); assert_eq!(targets, vec!["target".to_string()]); } #[tokio::test] async fn resolve_links_missing_source_is_not_found() { let store = FsMemoryStore::new(MemFs::arc()); assert!(matches!( store.resolve_links(&root(), &slug("nope")).await.unwrap_err(), MemoryError::NotFound )); }