//! L2 integration tests for [`FsProjectStore`] against a real temp directory, //! using a real [`LocalFileSystem`] so the full persistence path (JSON layout, //! tolerant reads, upsert) is exercised end-to-end. use std::path::PathBuf; use std::sync::Arc; use domain::ids::ProjectId; use domain::layout::Workspace; use domain::ports::{FileSystem, ProjectStore, RemotePath}; use domain::project::{Project, ProjectPath}; use domain::remote::RemoteRef; use infrastructure::{FsProjectStore, LocalFileSystem}; use uuid::Uuid; /// A unique scratch directory under the OS temp dir, cleaned up on drop. struct TempDir(PathBuf); impl TempDir { fn new() -> Self { let p = std::env::temp_dir().join(format!("idea-l2-store-{}", Uuid::new_v4())); std::fs::create_dir_all(&p).unwrap(); Self(p) } /// The app-data dir as a plain string, as the composition root would pass it. fn app_data_dir(&self) -> String { self.0.to_string_lossy().into_owned() } fn child(&self, name: &str) -> RemotePath { RemotePath::new(self.0.join(name).to_string_lossy().into_owned()) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn store(tmp: &TempDir) -> FsProjectStore { let fs: Arc = Arc::new(LocalFileSystem::new()); FsProjectStore::new(fs, tmp.app_data_dir()) } fn sample_project(id: ProjectId, name: &str, root: &str) -> Project { Project::new( id, name, ProjectPath::new(root).unwrap(), RemoteRef::local(), 1_700_000_000_000, ) .unwrap() } #[tokio::test] async fn save_then_list_roundtrips() { let tmp = TempDir::new(); let store = store(&tmp); let p = sample_project(ProjectId::new_random(), "alpha", "/home/me/alpha"); store.save_project(&p).await.unwrap(); let listed = store.list_projects().await.unwrap(); assert_eq!(listed, vec![p]); } #[tokio::test] async fn save_upserts_by_id_without_duplicating() { let tmp = TempDir::new(); let store = store(&tmp); let id = ProjectId::new_random(); let first = sample_project(id, "before", "/home/me/proj"); store.save_project(&first).await.unwrap(); // Same id, changed fields: must update in place, not append. let updated = sample_project(id, "after", "/home/me/proj-renamed"); store.save_project(&updated).await.unwrap(); let listed = store.list_projects().await.unwrap(); assert_eq!(listed.len(), 1, "upsert must not duplicate by id"); assert_eq!(listed[0], updated); assert_eq!(listed[0].name, "after"); } #[tokio::test] async fn missing_registry_lists_empty() { let tmp = TempDir::new(); let store = store(&tmp); // No projects.json written yet: tolerant read returns an empty list. let listed = store.list_projects().await.unwrap(); assert!(listed.is_empty()); } #[tokio::test] async fn workspace_save_then_load_roundtrips() { let tmp = TempDir::new(); let store = store(&tmp); // Missing workspace returns the default. let loaded = store.load_workspace().await.unwrap(); assert_eq!(loaded, Workspace::default()); let ws = Workspace::default(); store.save_workspace(&ws).await.unwrap(); let back = store.load_workspace().await.unwrap(); assert_eq!(back, ws); } #[tokio::test] async fn registry_file_is_camelcase_json() { let tmp = TempDir::new(); let store = store(&tmp); let p = sample_project(ProjectId::new_random(), "jsoncheck", "/srv/app"); store.save_project(&p).await.unwrap(); // Read the raw bytes the store wrote and assert the camelCase shape. let fs = LocalFileSystem::new(); let bytes = fs.read(&tmp.child("projects.json")).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert!(json.get("version").is_some(), "top-level `version` present"); let projects = json .get("projects") .and_then(|v| v.as_array()) .expect("top-level `projects` array"); assert_eq!(projects.len(), 1); let entry = &projects[0]; // camelCase serialization of `created_at`. assert!( entry.get("createdAt").is_some(), "project uses camelCase `createdAt`, got {entry}" ); assert!(entry.get("created_at").is_none(), "no snake_case leak"); assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("jsoncheck")); assert_eq!(entry.get("root").and_then(|v| v.as_str()), Some("/srv/app")); }