//! 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, SecretRef, StoreError}; use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter}; 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}", None, ) .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"); } /// Fixture pair for ticket #97: the local llama.cpp backend (`opencode`) and the /// cloud provider (`opencodeProvider`) — the two mutually exclusive OpenCode /// backends. A profile carrying both is inconsistent. fn opencode_backends() -> (OpenCodeConfig, OpenCodeProviderConfig) { let local = OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder-30b", None, None).unwrap(); let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-cloud")) .unwrap(); (local, cloud) } /// Ticket #97 — read repair (`read_doc`, profile.rs:90-105): a profile corrupted /// on disk (both `opencode` and `opencodeProvider` present) is silently repaired /// on read by dropping the stale `opencode`, converging with the enforced /// builder invariant. The cloud provider — the user's actual intent — is kept. #[tokio::test] async fn read_doc_repairs_stale_opencode_when_both_sections_present() { let tmp = TempDir::new(); let (local, cloud) = opencode_backends(); // The `save` guard would refuse this, so plant the corruption directly on // disk (bypassing the guard), exactly like a stale file from an older build. let mut corrupted = sample(1, "OpenCode Cloud", "opencode") .with_structured_adapter(StructuredAdapter::OpenCode) .with_opencode(local); corrupted.opencode_provider = Some(cloud.clone()); assert!( !corrupted.opencode_backend_is_consistent(), "fixture must violate the invariant" ); let fs = LocalFileSystem::new(); let doc = serde_json::json!({ "version": 1, "profiles": [corrupted] }); fs.write(&tmp.child("profiles.json"), &serde_json::to_vec_pretty(&doc).unwrap()) .await .unwrap(); let store = store(&tmp); let listed = store.list().await.expect("read repairs instead of failing"); assert_eq!(listed.len(), 1, "corrupted profile preserved as an entry"); let repaired = &listed[0]; assert!(repaired.opencode.is_none(), "stale local `opencode` dropped on read"); assert_eq!( repaired.opencode_provider.as_ref().unwrap().provider_id, "anthropic", "cloud `opencodeProvider` preserved" ); assert!( repaired.opencode_backend_is_consistent(), "read repair restored the mutual-exclusion invariant" ); } /// Ticket #97 — write guard (`save`, profile.rs:129-139): persisting a profile /// that violates the OpenCode backend mutual-exclusion invariant is rejected /// with [`StoreError::Invalid`], and nothing is written to disk. #[tokio::test] async fn save_rejects_profile_violating_backend_exclusion() { let tmp = TempDir::new(); let store = store(&tmp); let (local, cloud) = opencode_backends(); // Only reachable via direct mutation: the builders enforce exclusion // (last-wins), so an inconsistent profile cannot be built through the API. let mut inconsistent = sample(1, "OpenCode Both", "opencode") .with_structured_adapter(StructuredAdapter::OpenCode) .with_opencode(local); inconsistent.opencode_provider = Some(cloud); assert!(!inconsistent.opencode_backend_is_consistent()); let err = store .save(&inconsistent) .await .expect_err("save must reject an invariant-violating profile"); assert!( matches!(err, StoreError::Invalid(_)), "defense-in-depth guard maps to Invalid; got {err:?}" ); assert!( !store.is_configured().await.unwrap(), "rejected save must not materialise profiles.json" ); }