Files
IdeA/crates/application/tests/template_usecases.rs
Blomios 307ae71857 feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
2026-06-06 01:27:01 +02:00

422 lines
14 KiB
Rust

//! 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<Mutex<Vec<AgentTemplate>>>);
impl FakeTemplates {
fn with(templates: Vec<AgentTemplate>) -> Self {
Self(Arc::new(Mutex::new(templates)))
}
fn get_sync(&self, id: TemplateId) -> Option<AgentTemplate> {
self.0.lock().unwrap().iter().find(|t| t.id == id).cloned()
}
}
#[async_trait]
impl TemplateStore for FakeTemplates {
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
async fn get(&self, id: TemplateId) -> Result<AgentTemplate, StoreError> {
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<Mutex<(AgentManifest, HashMap<String, String>)>>);
impl FakeContexts {
fn new(entries: Vec<ManifestEntry>) -> 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<String> {
self.0.lock().unwrap().1.get(md_path).cloned()
}
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
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<MarkdownDoc, StoreError> {
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<AgentManifest, StoreError> {
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<Mutex<Vec<DomainEvent>>>);
impl SpyBus {
fn events(&self) -> Vec<DomainEvent> {
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<u128>);
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");
}