//! L7 tests for the template & synchronisation use cases, with in-memory port //! fakes (no real store/FS): `CreateTemplate`, `UpdateTemplate`, //! `CreateAgentFromTemplate`, `DetectAgentDrift`, `SyncAgentWithTemplate`. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::events::DomainEvent; use domain::ids::{AgentId, ProfileId, ProjectId, TemplateId}; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, EventBus, EventStream, IdGenerator, StoreError, TemplateStore, }; use domain::template::{AgentTemplate, TemplateVersion}; use domain::{AgentManifest, ManifestEntry, Project, ProjectPath, RemoteRef}; use uuid::Uuid; use application::{ CreateAgentFromTemplate, CreateAgentFromTemplateInput, CreateTemplate, CreateTemplateInput, DetectAgentDrift, DetectAgentDriftInput, SyncAgentWithTemplate, SyncAgentWithTemplateInput, UpdateTemplate, UpdateTemplateInput, }; // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- #[derive(Clone, Default)] struct FakeTemplates(Arc>>); impl FakeTemplates { fn with(templates: Vec) -> Self { Self(Arc::new(Mutex::new(templates))) } fn get_sync(&self, id: TemplateId) -> Option { self.0.lock().unwrap().iter().find(|t| t.id == id).cloned() } } #[async_trait] impl TemplateStore for FakeTemplates { async fn list(&self) -> Result, StoreError> { Ok(self.0.lock().unwrap().clone()) } async fn get(&self, id: TemplateId) -> Result { self.get_sync(id).ok_or(StoreError::NotFound) } async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> { let mut v = self.0.lock().unwrap(); if let Some(slot) = v.iter_mut().find(|t| t.id == template.id) { *slot = template.clone(); } else { v.push(template.clone()); } Ok(()) } async fn delete(&self, id: TemplateId) -> Result<(), StoreError> { let mut v = self.0.lock().unwrap(); let before = v.len(); v.retain(|t| t.id != id); if v.len() == before { return Err(StoreError::NotFound); } Ok(()) } } #[derive(Clone)] struct FakeContexts(Arc)>>); impl FakeContexts { fn new(entries: Vec) -> Self { Self(Arc::new(Mutex::new(( AgentManifest { version: 1, entries }, HashMap::new(), )))) } fn manifest(&self) -> AgentManifest { self.0.lock().unwrap().0.clone() } fn content(&self, md_path: &str) -> Option { self.0.lock().unwrap().1.get(md_path).cloned() } fn md_path_of(&self, agent: &AgentId) -> Option { self.0 .lock() .unwrap() .0 .entries .iter() .find(|e| &e.agent_id == agent) .map(|e| e.md_path.clone()) } } #[async_trait] impl AgentContextStore for FakeContexts { async fn read_context( &self, _p: &Project, agent: &AgentId, ) -> Result { let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?; self.content(&md).map(MarkdownDoc::new).ok_or(StoreError::NotFound) } async fn write_context( &self, _p: &Project, agent: &AgentId, md: &MarkdownDoc, ) -> Result<(), StoreError> { let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?; self.0.lock().unwrap().1.insert(path, md.as_str().to_owned()); Ok(()) } async fn load_manifest(&self, _p: &Project) -> Result { Ok(self.manifest()) } async fn save_manifest(&self, _p: &Project, m: &AgentManifest) -> Result<(), StoreError> { self.0.lock().unwrap().0 = m.clone(); Ok(()) } } #[derive(Default, Clone)] struct SpyBus(Arc>>); impl SpyBus { fn events(&self) -> Vec { self.0.lock().unwrap().clone() } } impl EventBus for SpyBus { fn publish(&self, event: DomainEvent) { self.0.lock().unwrap().push(event); } fn subscribe(&self) -> EventStream { Box::new(std::iter::empty()) } } struct SeqIds(Mutex); impl SeqIds { fn new() -> Self { Self(Mutex::new(1)) } } impl IdGenerator for SeqIds { fn new_uuid(&self) -> Uuid { let mut n = self.0.lock().unwrap(); let id = Uuid::from_u128(*n); *n += 1; id } } // --------------------------------------------------------------------------- // Builders // --------------------------------------------------------------------------- fn pid(n: u128) -> ProfileId { ProfileId::from_uuid(Uuid::from_u128(n)) } fn tid(n: u128) -> TemplateId { TemplateId::from_uuid(Uuid::from_u128(n)) } fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } fn v(n: u64) -> TemplateVersion { TemplateVersion(n) } fn project() -> Project { Project::new( ProjectId::from_uuid(Uuid::from_u128(1000)), "demo", ProjectPath::new("/home/me/demo").unwrap(), RemoteRef::local(), 1_700_000_000_000, ) .unwrap() } fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTemplate { let mut t = AgentTemplate::new(id, name, MarkdownDoc::new(content), pid(1)).unwrap(); // Bump to the requested version by re-applying content updates. while t.version.get() < version { t = t.with_updated_content(MarkdownDoc::new(content)); } t } /// A synchronized, template-backed manifest entry synced at `synced`. fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry { ManifestEntry::new(agent, "A", md, pid(1), Some(template), true, Some(v(synced))).unwrap() } // --------------------------------------------------------------------------- // CreateTemplate / UpdateTemplate // --------------------------------------------------------------------------- #[tokio::test] async fn create_template_starts_at_initial_version() { let store = FakeTemplates::default(); let out = CreateTemplate::new(Arc::new(store.clone()), Arc::new(SeqIds::new())) .execute(CreateTemplateInput { name: "Backend".to_owned(), content: "# ctx".to_owned(), default_profile_id: pid(7), }) .await .unwrap(); assert_eq!(out.template.version, TemplateVersion::INITIAL); assert_eq!(out.template.default_profile_id, pid(7)); assert_eq!(store.list().await.unwrap().len(), 1); } #[tokio::test] async fn update_template_bumps_version_and_publishes_event() { let store = FakeTemplates::with(vec![template(tid(1), "T", "v1", 1)]); let bus = SpyBus::default(); let out = UpdateTemplate::new(Arc::new(store.clone()), Arc::new(bus.clone())) .execute(UpdateTemplateInput { template_id: tid(1), content: "v2".to_owned(), }) .await .unwrap(); assert_eq!(out.template.version.get(), 2); assert_eq!(store.get_sync(tid(1)).unwrap().content_md.as_str(), "v2"); assert_eq!( bus.events(), vec![DomainEvent::TemplateUpdated { template_id: tid(1), version: v(2), }] ); } #[tokio::test] async fn update_unknown_template_is_not_found() { let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default())) .execute(UpdateTemplateInput { template_id: tid(404), content: "x".to_owned(), }) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); } // --------------------------------------------------------------------------- // CreateAgentFromTemplate // --------------------------------------------------------------------------- #[tokio::test] async fn create_agent_from_template_links_origin_and_seeds_context() { let store = FakeTemplates::with(vec![template(tid(1), "Backend", "# body", 4)]); let contexts = FakeContexts::new(vec![]); let out = CreateAgentFromTemplate::new( Arc::new(store), Arc::new(contexts.clone()), Arc::new(SeqIds::new()), Arc::new(SpyBus::default()), ) .execute(CreateAgentFromTemplateInput { project: project(), template_id: tid(1), name: None, synchronized: true, }) .await .unwrap(); // Name defaults to the template name; profile = template default. assert_eq!(out.agent.name, "Backend"); assert_eq!(out.agent.profile_id, pid(1)); assert!(out.agent.synchronized); assert_eq!( out.agent.origin, domain::AgentOrigin::FromTemplate { template_id: tid(1), synced_template_version: v(4), } ); // Context seeded with the template content under the agent's md path. assert_eq!(contexts.content(&out.agent.context_path).as_deref(), Some("# body")); assert_eq!(contexts.manifest().entries.len(), 1); } // --------------------------------------------------------------------------- // DetectAgentDrift // --------------------------------------------------------------------------- #[tokio::test] async fn detect_drift_flags_only_synchronized_agents_behind() { // Template at v3. let store = FakeTemplates::with(vec![template(tid(1), "T", "v3", 3)]); // a1: synchronized, synced at v1 → drift (1→3). // a2: synchronized, synced at v3 → up to date, no drift. // a3: from template but NOT synchronized → ignored. // a4: scratch (no template) → ignored. let a3 = ManifestEntry::new(aid(3), "A3", "agents/a3.md", pid(1), Some(tid(1)), false, Some(v(1))) .unwrap(); let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap(); let contexts = FakeContexts::new(vec![ synced_entry(aid(1), "agents/a1.md", tid(1), 1), synced_entry(aid(2), "agents/a2.md", tid(1), 3), a3, a4, ]); let bus = SpyBus::default(); let out = DetectAgentDrift::new( Arc::new(store), Arc::new(contexts), Arc::new(bus.clone()), ) .execute(DetectAgentDriftInput { project: project() }) .await .unwrap(); assert_eq!(out.drifts.len(), 1, "only a1 drifts"); assert_eq!(out.drifts[0].agent_id, aid(1)); assert_eq!(out.drifts[0].from, v(1)); assert_eq!(out.drifts[0].to, v(3)); assert_eq!( bus.events(), vec![DomainEvent::AgentDriftDetected { agent_id: aid(1), from: v(1), to: v(3), }] ); } #[tokio::test] async fn detect_drift_ignores_deleted_template() { // No templates in the store, but an agent references tid(1): not an error. let store = FakeTemplates::default(); let contexts = FakeContexts::new(vec![synced_entry(aid(1), "agents/a1.md", tid(1), 1)]); let out = DetectAgentDrift::new( Arc::new(store), Arc::new(contexts), Arc::new(SpyBus::default()), ) .execute(DetectAgentDriftInput { project: project() }) .await .unwrap(); assert!(out.drifts.is_empty()); } // --------------------------------------------------------------------------- // SyncAgentWithTemplate // --------------------------------------------------------------------------- #[tokio::test] async fn sync_applies_to_synchronized_and_updates_version_and_context() { let store = FakeTemplates::with(vec![template(tid(1), "T", "newest body", 3)]); let contexts = FakeContexts::new(vec![synced_entry(aid(1), "agents/a1.md", tid(1), 1)]); // Seed an old context so we can see the replacement. contexts .write_context(&project(), &aid(1), &MarkdownDoc::new("old")) .await .unwrap(); let bus = SpyBus::default(); let out = SyncAgentWithTemplate::new( Arc::new(store), Arc::new(contexts.clone()), Arc::new(bus.clone()), ) .execute(SyncAgentWithTemplateInput { project: project(), agent_id: aid(1), }) .await .unwrap(); assert!(out.synced); assert_eq!(out.version, Some(v(3))); // Context replaced by the template content. assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("newest body")); // Manifest synced version advanced to 3. let entry = &contexts.manifest().entries[0]; assert_eq!(entry.synced_template_version, Some(v(3))); assert_eq!( bus.events(), vec![DomainEvent::AgentSynced { agent_id: aid(1), to: v(3), }] ); } #[tokio::test] async fn sync_ignores_non_synchronized_agent() { let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]); // Non-synchronized agent from a template. let entry = ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1))) .unwrap(); let contexts = FakeContexts::new(vec![entry]); contexts .write_context(&project(), &aid(1), &MarkdownDoc::new("keep me")) .await .unwrap(); let bus = SpyBus::default(); let out = SyncAgentWithTemplate::new( Arc::new(store), Arc::new(contexts.clone()), Arc::new(bus.clone()), ) .execute(SyncAgentWithTemplateInput { project: project(), agent_id: aid(1), }) .await .unwrap(); assert!(!out.synced, "non-synchronized agent is left untouched"); assert_eq!(out.version, None); assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me")); assert!(bus.events().is_empty(), "no sync event for an ignored agent"); }