//! L5 integration tests for [`FsProfileStore`] against a real temp directory, //! using a real [`LocalFileSystem`] so the full persistence path (camelCase //! `profiles.json`, upsert, delete, first-run marker) is exercised end-to-end. use std::path::PathBuf; use std::sync::Arc; use domain::ids::ProfileId; use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError}; use domain::profile::{AgentProfile, ContextInjection}; use infrastructure::{FsProfileStore, 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-l5-profile-{}", Uuid::new_v4())); std::fs::create_dir_all(&p).unwrap(); Self(p) } 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) -> FsProfileStore { let fs: Arc = Arc::new(LocalFileSystem::new()); FsProfileStore::new(fs, tmp.app_data_dir()) } fn sample(id: u128, name: &str, command: &str) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(Uuid::from_u128(id)), name, command, Vec::new(), ContextInjection::convention_file("CLAUDE.md").unwrap(), Some(format!("{command} --version")), "{projectRoot}", ) .unwrap() } #[tokio::test] async fn save_then_list_roundtrips() { let tmp = TempDir::new(); let store = store(&tmp); let p = sample(1, "Claude", "claude"); store.save(&p).await.unwrap(); let listed = store.list().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 first = sample(1, "before", "claude"); store.save(&first).await.unwrap(); let updated = sample(1, "after", "claude-renamed"); store.save(&updated).await.unwrap(); let listed = store.list().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 delete_removes_profile() { let tmp = TempDir::new(); let store = store(&tmp); let a = sample(1, "A", "a"); let b = sample(2, "B", "b"); store.save(&a).await.unwrap(); store.save(&b).await.unwrap(); store.delete(a.id).await.unwrap(); let listed = store.list().await.unwrap(); assert_eq!(listed, vec![b]); } #[tokio::test] async fn delete_unknown_is_not_found() { let tmp = TempDir::new(); let store = store(&tmp); store.save(&sample(1, "A", "a")).await.unwrap(); let err = store .delete(ProfileId::from_uuid(Uuid::from_u128(999))) .await .expect_err("deleting unknown id fails"); assert!(matches!(err, StoreError::NotFound), "got {err:?}"); } #[tokio::test] async fn is_configured_false_before_any_write() { let tmp = TempDir::new(); let store = store(&tmp); // First run: no profiles.json yet. assert!(!store.is_configured().await.unwrap()); assert!(store.list().await.unwrap().is_empty()); } #[tokio::test] async fn is_configured_true_after_save() { let tmp = TempDir::new(); let store = store(&tmp); store.save(&sample(1, "A", "a")).await.unwrap(); assert!(store.is_configured().await.unwrap()); } #[tokio::test] async fn mark_configured_creates_file_with_empty_profiles() { let tmp = TempDir::new(); let store = store(&tmp); assert!(!store.is_configured().await.unwrap()); store.mark_configured().await.unwrap(); assert!(store.is_configured().await.unwrap(), "marker materialised"); assert!( store.list().await.unwrap().is_empty(), "empty profile list recorded" ); } #[tokio::test] async fn profiles_file_is_camelcase_versioned() { let tmp = TempDir::new(); let store = store(&tmp); let p = sample(1, "Claude", "claude"); store.save(&p).await.unwrap(); let fs = LocalFileSystem::new(); let bytes = fs.read(&tmp.child("profiles.json")).await.unwrap(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(json["version"], 1); let profiles = json .get("profiles") .and_then(|v| v.as_array()) .expect("top-level `profiles` array"); assert_eq!(profiles.len(), 1); let entry = &profiles[0]; assert_eq!(entry["name"], "Claude"); assert_eq!(entry["command"], "claude"); // camelCase fields, tagged contextInjection. assert!(entry.get("cwdTemplate").is_some(), "camelCase cwdTemplate"); assert!(entry.get("cwd_template").is_none(), "no snake_case leak"); assert_eq!(entry["contextInjection"]["strategy"], "conventionFile"); assert_eq!(entry["contextInjection"]["target"], "CLAUDE.md"); }