//! L5 tests for the profile/first-run use cases and the reference catalogue. //! //! Ports are faked in-memory so the use cases run without any I/O: //! - [`FakeProfileStore`] — an in-memory [`ProfileStore`] tracking a `configured` //! flag (mirrors `profiles.json` existence), //! - [`StubRuntime`] — an [`AgentRuntime`] whose `detect` is driven by a map from //! command → result (including an error case to prove graceful degradation). use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ids::ProfileId; use domain::ports::{ AgentRuntime, PreparedContext, ProfileStore, RuntimeError, SpawnSpec, StoreError, }; use domain::profile::{AgentProfile, ContextInjection}; use domain::project::ProjectPath; use application::{ reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, }; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- #[derive(Default)] struct FakeStoreInner { profiles: Vec, configured: bool, } #[derive(Default, Clone)] struct FakeProfileStore(Arc>); #[async_trait] impl ProfileStore for FakeProfileStore { async fn list(&self) -> Result, StoreError> { Ok(self.0.lock().unwrap().profiles.clone()) } async fn save(&self, profile: &AgentProfile) -> Result<(), StoreError> { let mut inner = self.0.lock().unwrap(); inner.configured = true; if let Some(slot) = inner.profiles.iter_mut().find(|p| p.id == profile.id) { *slot = profile.clone(); } else { inner.profiles.push(profile.clone()); } Ok(()) } async fn delete(&self, id: ProfileId) -> Result<(), StoreError> { let mut inner = self.0.lock().unwrap(); let before = inner.profiles.len(); inner.profiles.retain(|p| p.id != id); if inner.profiles.len() == before { return Err(StoreError::NotFound); } Ok(()) } async fn is_configured(&self) -> Result { Ok(self.0.lock().unwrap().configured) } async fn mark_configured(&self) -> Result<(), StoreError> { self.0.lock().unwrap().configured = true; Ok(()) } } /// Detection outcomes keyed by command. Missing keys ⇒ `false`. #[derive(Clone)] enum DetectResult { Available, Missing, Error, } struct StubRuntime { by_command: HashMap, } #[async_trait] impl AgentRuntime for StubRuntime { fn detect(&self, profile: &AgentProfile) -> Result { match self.by_command.get(&profile.command) { Some(DetectResult::Available) => Ok(true), Some(DetectResult::Missing) | None => Ok(false), Some(DetectResult::Error) => { Err(RuntimeError::Detection("boom".to_owned())) } } } fn prepare_invocation( &self, _profile: &AgentProfile, _ctx: &PreparedContext, _cwd: &ProjectPath, ) -> Result { unreachable!("not used in these tests") } } fn profile(id: u128, name: &str, command: &str) -> AgentProfile { AgentProfile::new( ProfileId::from_uuid(uuid::Uuid::from_u128(id)), name, command, Vec::new(), ContextInjection::stdin(), Some(format!("{command} --version")), "{projectRoot}", ) .unwrap() } // --------------------------------------------------------------------------- // DetectProfiles // --------------------------------------------------------------------------- #[tokio::test] async fn detect_maps_candidates_to_availability_in_order() { let mut map = HashMap::new(); map.insert("claude".to_owned(), DetectResult::Available); map.insert("codex".to_owned(), DetectResult::Missing); let runtime: Arc = Arc::new(StubRuntime { by_command: map }); let detect = DetectProfiles::new(runtime); let out = detect .execute(DetectProfilesInput { candidates: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")], }) .await .unwrap(); assert_eq!(out.results.len(), 2); assert_eq!(out.results[0].profile.command, "claude"); assert!(out.results[0].available); assert_eq!(out.results[1].profile.command, "codex"); assert!(!out.results[1].available); } #[tokio::test] async fn detect_error_degrades_to_unavailable_not_hard_failure() { let mut map = HashMap::new(); map.insert("aider".to_owned(), DetectResult::Error); let runtime: Arc = Arc::new(StubRuntime { by_command: map }); let detect = DetectProfiles::new(runtime); let out = detect .execute(DetectProfilesInput { candidates: vec![profile(1, "Aider", "aider")], }) .await .expect("detection error must not fail the use case"); assert!(!out.results[0].available, "errored detection ⇒ available:false"); } // --------------------------------------------------------------------------- // ConfigureProfiles // --------------------------------------------------------------------------- #[tokio::test] async fn configure_persists_chosen_profiles_and_closes_first_run() { let store = FakeProfileStore::default(); let configure = ConfigureProfiles::new(Arc::new(store.clone())); let out = configure .execute(ConfigureProfilesInput { profiles: vec![profile(1, "Claude", "claude"), profile(2, "Codex", "codex")], }) .await .unwrap(); assert_eq!(out.profiles.len(), 2); assert!(store.is_configured().await.unwrap()); assert_eq!(store.list().await.unwrap().len(), 2); } #[tokio::test] async fn configure_empty_list_still_marks_configured() { let store = FakeProfileStore::default(); let configure = ConfigureProfiles::new(Arc::new(store.clone())); configure .execute(ConfigureProfilesInput { profiles: vec![] }) .await .unwrap(); assert!( store.is_configured().await.unwrap(), "empty configure closes the first run" ); assert!(store.list().await.unwrap().is_empty()); } // --------------------------------------------------------------------------- // FirstRunState // --------------------------------------------------------------------------- #[tokio::test] async fn first_run_true_when_not_configured_with_reference_catalogue() { let store = FakeProfileStore::default(); let uc = FirstRunState::new(Arc::new(store)); let out = uc.execute().await.unwrap(); assert!(out.is_first_run); assert_eq!(out.reference_profiles.len(), 4, "catalogue seeded"); } #[tokio::test] async fn first_run_false_after_configuration() { let store = FakeProfileStore::default(); store.mark_configured().await.unwrap(); let uc = FirstRunState::new(Arc::new(store)); let out = uc.execute().await.unwrap(); assert!(!out.is_first_run); } // --------------------------------------------------------------------------- // ListProfiles / SaveProfile / DeleteProfile // --------------------------------------------------------------------------- #[tokio::test] async fn save_then_list_then_delete() { let store = FakeProfileStore::default(); let save = SaveProfile::new(Arc::new(store.clone())); let list = ListProfiles::new(Arc::new(store.clone())); let delete = DeleteProfile::new(Arc::new(store.clone())); let p = profile(1, "Claude", "claude"); let saved = save .execute(SaveProfileInput { profile: p.clone() }) .await .unwrap(); assert_eq!(saved.profile, p); assert_eq!(list.execute().await.unwrap().profiles, vec![p.clone()]); delete.execute(DeleteProfileInput { id: p.id }).await.unwrap(); assert!(list.execute().await.unwrap().profiles.is_empty()); } #[tokio::test] async fn delete_unknown_is_not_found_error() { let store = FakeProfileStore::default(); let delete = DeleteProfile::new(Arc::new(store)); let err = delete .execute(DeleteProfileInput { id: ProfileId::from_uuid(uuid::Uuid::from_u128(123)), }) .await .expect_err("deleting unknown id errors"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } // --------------------------------------------------------------------------- // ReferenceProfiles / catalogue // --------------------------------------------------------------------------- #[tokio::test] async fn reference_profiles_use_case_returns_four() { let out = ReferenceProfiles::new().execute().await.unwrap(); assert_eq!(out.profiles.len(), 4); } #[test] fn catalogue_has_expected_commands_and_injection() { let profiles = reference_profiles(); let by_command: HashMap<&str, &AgentProfile> = profiles.iter().map(|p| (p.command.as_str(), p)).collect(); let claude = by_command["claude"]; assert_eq!( claude.context_injection, ContextInjection::ConventionFile { target: "CLAUDE.md".to_owned() } ); assert_eq!( by_command["codex"].context_injection, ContextInjection::ConventionFile { target: "AGENTS.md".to_owned() } ); assert_eq!( by_command["gemini"].context_injection, ContextInjection::ConventionFile { target: "GEMINI.md".to_owned() } ); assert_eq!( by_command["aider"].context_injection, ContextInjection::Flag { flag: "--message-file {path}".to_owned() } ); } #[test] fn catalogue_ids_are_stable_across_calls() { let first = reference_profiles(); let second = reference_profiles(); let ids_a: Vec<_> = first.iter().map(|p| p.id).collect(); let ids_b: Vec<_> = second.iter().map(|p| p.id).collect(); assert_eq!(ids_a, ids_b, "reference ids are deterministic"); // And match the slug-derived id helper. assert_eq!(first[0].id, reference_profile_id("claude")); }