use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ports::{ AgentContextStore, Clock, EventBus, EventStream, IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, StoreError, }; use domain::{ AgentId, AgentManifest, DomainEvent, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectId, ProjectPath, RemoteRef, }; use uuid::Uuid; use application::{ CreateIssue, CreateIssueInput, DeleteIssue, DeleteIssueInput, UpdateIssue, UpdateIssueInput, }; #[derive(Default)] struct FakeIssues { issues: Mutex>, conflict: Mutex, } #[async_trait] impl IssueStore for FakeIssues { async fn create(&self, _root: &ProjectPath, issue: &Issue) -> Result<(), IssueStoreError> { self.issues .lock() .unwrap() .insert(issue.reference(), issue.clone()); Ok(()) } async fn get_by_ref( &self, _root: &ProjectPath, issue_ref: IssueRef, ) -> Result { self.issues .lock() .unwrap() .get(&issue_ref) .cloned() .ok_or(IssueStoreError::NotFound) } async fn list( &self, _root: &ProjectPath, _filter: IssueListFilter, ) -> Result, IssueStoreError> { Ok(self .issues .lock() .unwrap() .values() .map(IssueIndexEntry::from) .collect()) } async fn update( &self, _root: &ProjectPath, issue: &Issue, expected_version: IssueVersion, ) -> Result<(), IssueStoreError> { if *self.conflict.lock().unwrap() { return Err(IssueStoreError::VersionConflict { expected: expected_version, actual: issue.version, }); } let current = self.get_by_ref(_root, issue.reference()).await?; if current.version != expected_version { return Err(IssueStoreError::VersionConflict { expected: expected_version, actual: current.version, }); } self.issues .lock() .unwrap() .insert(issue.reference(), issue.clone()); Ok(()) } async fn delete( &self, _root: &ProjectPath, issue_ref: IssueRef, ) -> Result<(), IssueStoreError> { self.issues .lock() .unwrap() .remove(&issue_ref) .map(|_| ()) .ok_or(IssueStoreError::NotFound) } async fn read_carnet( &self, _root: &ProjectPath, issue_ref: IssueRef, ) -> Result { let issue = self.get_by_ref(_root, issue_ref).await?; Ok(IssueCarnet { issue_ref, carnet: issue.carnet, version: issue.version, updated_by: issue.updated_by, updated_at: issue.updated_at, }) } async fn write_carnet( &self, _root: &ProjectPath, issue_ref: IssueRef, carnet: MarkdownDoc, actor: IssueActor, now_ms: u64, expected_version: IssueVersion, ) -> Result { let issue = self.get_by_ref(_root, issue_ref).await?; if issue.version != expected_version { return Err(IssueStoreError::VersionConflict { expected: expected_version, actual: issue.version, }); } let updated = issue .mutate(actor, now_ms, |i| i.carnet = carnet) .map_err(|err| IssueStoreError::Invalid(err.to_string()))?; self.update(_root, &updated, expected_version).await?; self.read_carnet(_root, issue_ref).await } } struct SeqAllocator(Mutex); #[async_trait] impl IssueNumberAllocator for SeqAllocator { async fn allocate_next(&self, _root: &ProjectPath) -> Result { let mut next = self.0.lock().unwrap(); let number = IssueNumber::new(*next).unwrap(); *next += 1; Ok(number) } } #[derive(Clone)] struct FakeContexts(AgentManifest); #[async_trait] impl AgentContextStore for FakeContexts { async fn read_context( &self, _project: &Project, _agent: &AgentId, ) -> Result { Err(StoreError::NotFound) } async fn write_context( &self, _project: &Project, _agent: &AgentId, _md: &MarkdownDoc, ) -> Result<(), StoreError> { Ok(()) } async fn load_manifest(&self, _project: &Project) -> Result { Ok(self.0.clone()) } async fn save_manifest( &self, _project: &Project, _manifest: &AgentManifest, ) -> Result<(), StoreError> { Ok(()) } } #[derive(Default)] struct SpyBus(Mutex>); 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 FixedClock; impl Clock for FixedClock { fn now_millis(&self) -> i64 { 1_234 } } struct FixedIds; impl IdGenerator for FixedIds { fn new_uuid(&self) -> Uuid { Uuid::from_u128(42) } } fn project() -> Project { Project::new( ProjectId::new_random(), "IdeA", ProjectPath::new("/tmp/idea").unwrap(), RemoteRef::Local, 0, ) .unwrap() } fn manifest(agent_id: AgentId) -> AgentManifest { AgentManifest::new( 1, vec![ManifestEntry::new( agent_id, "Dev", "agents/dev.md", ProfileId::new_random(), None, false, None, ) .unwrap()], ) .unwrap() } #[tokio::test] async fn create_issue_allocates_number_validates_agent_and_emits_event() { let agent_id = AgentId::new_random(); let issues = Arc::new(FakeIssues::default()); let bus = Arc::new(SpyBus::default()); let uc = CreateIssue::new( issues.clone(), Arc::new(SeqAllocator(Mutex::new(1))), Arc::new(FakeContexts(manifest(agent_id))), Arc::new(FixedIds), Arc::new(FixedClock), bus.clone(), ); let out = uc .execute(CreateIssueInput { project: project(), title: "Ship issues".into(), description: "Body".into(), priority: IssuePriority::Critical, status: IssueStatus::Open, links: Vec::new(), assigned_agent_ids: vec![agent_id], actor: IssueActor::User, }) .await .unwrap(); assert_eq!(out.issue.reference().to_string(), "#1"); assert_eq!( out.issue.id, domain::IssueId::from_uuid(Uuid::from_u128(42)) ); assert_eq!(out.issue.created_at, 1_234); assert!(matches!( bus.events().as_slice(), [DomainEvent::IssueCreated { issue_ref, .. }] if issue_ref.to_string() == "#1" )); } #[tokio::test] async fn create_issue_rejects_unknown_assignee() { let known = AgentId::new_random(); let unknown = AgentId::new_random(); let uc = CreateIssue::new( Arc::new(FakeIssues::default()), Arc::new(SeqAllocator(Mutex::new(1))), Arc::new(FakeContexts(manifest(known))), Arc::new(FixedIds), Arc::new(FixedClock), Arc::new(SpyBus::default()), ); let err = uc .execute(CreateIssueInput { project: project(), title: "Bad assignee".into(), description: "Body".into(), priority: IssuePriority::Medium, status: IssueStatus::Open, links: Vec::new(), assigned_agent_ids: vec![unknown], actor: IssueActor::User, }) .await .unwrap_err(); assert_eq!(err.code(), "NOT_FOUND"); } #[tokio::test] async fn update_issue_propagates_expected_version_and_emits_status_event() { let agent_id = AgentId::new_random(); let issues = Arc::new(FakeIssues::default()); let initial = Issue::new( domain::IssueId::new_random(), IssueNumber::new(1).unwrap(), "Initial", MarkdownDoc::new("Body"), IssueStatus::Open, IssuePriority::Medium, MarkdownDoc::default(), Vec::new(), Vec::new(), IssueActor::User, 1, ) .unwrap(); issues.create(&project().root, &initial).await.unwrap(); let bus = Arc::new(SpyBus::default()); let uc = UpdateIssue::new( issues, Arc::new(FakeContexts(manifest(agent_id))), Arc::new(FixedClock), bus.clone(), ); let out = uc .execute(UpdateIssueInput { project: project(), issue_ref: initial.reference(), expected_version: initial.version, title: Some("Updated".into()), description: None, status: Some(IssueStatus::InProgress), priority: None, assigned_agent_ids: None, actor: IssueActor::System, }) .await .unwrap(); assert_eq!(out.issue.version.get(), 2); assert!(bus.events().iter().any(|event| matches!( event, DomainEvent::IssueStatusChanged { status: IssueStatus::InProgress, .. } ))); } #[tokio::test] async fn update_issue_maps_version_conflict() { let agent_id = AgentId::new_random(); let issues = Arc::new(FakeIssues::default()); let initial = Issue::new( domain::IssueId::new_random(), IssueNumber::new(1).unwrap(), "Initial", MarkdownDoc::new("Body"), IssueStatus::Open, IssuePriority::Medium, MarkdownDoc::default(), Vec::new(), Vec::new(), IssueActor::User, 1, ) .unwrap(); issues.create(&project().root, &initial).await.unwrap(); *issues.conflict.lock().unwrap() = true; let uc = UpdateIssue::new( issues, Arc::new(FakeContexts(manifest(agent_id))), Arc::new(FixedClock), Arc::new(SpyBus::default()), ); let err = uc .execute(UpdateIssueInput { project: project(), issue_ref: initial.reference(), expected_version: initial.version, title: Some("Updated".into()), description: None, status: None, priority: None, assigned_agent_ids: None, actor: IssueActor::System, }) .await .unwrap_err(); assert_eq!(err.code(), "INVALID"); assert!(err.to_string().contains("version conflict")); } #[tokio::test] async fn delete_issue_emits_deleted_event_with_freed_sprint() { let issues = Arc::new(FakeIssues::default()); let sprint_id = domain::SprintId::from_uuid(Uuid::from_u128(7)); let initial = Issue::new( domain::IssueId::new_random(), IssueNumber::new(1).unwrap(), "In sprint", MarkdownDoc::new("Body"), IssueStatus::Open, IssuePriority::Medium, MarkdownDoc::default(), Vec::new(), Vec::new(), IssueActor::User, 1, ) .unwrap() .mutate(IssueActor::System, 2, |issue| { issue.sprint = Some(sprint_id); }) .unwrap(); issues.create(&project().root, &initial).await.unwrap(); let bus = Arc::new(SpyBus::default()); let uc = DeleteIssue::new(issues.clone(), bus.clone()); let out = uc .execute(DeleteIssueInput { project: project(), issue_ref: initial.reference(), }) .await .unwrap(); assert_eq!(out.issue_ref, initial.reference()); assert!(matches!( issues .get_by_ref(&project().root, initial.reference()) .await, Err(IssueStoreError::NotFound) )); assert!(matches!( bus.events().as_slice(), [DomainEvent::IssueDeleted { issue_ref, freed_sprint: Some(id), .. }] if *issue_ref == initial.reference() && *id == sprint_id )); } #[tokio::test] async fn delete_issue_emits_deleted_event_without_freed_sprint() { let issues = Arc::new(FakeIssues::default()); let initial = Issue::new( domain::IssueId::new_random(), IssueNumber::new(2).unwrap(), "Backlog", MarkdownDoc::new("Body"), IssueStatus::Open, IssuePriority::Medium, MarkdownDoc::default(), Vec::new(), Vec::new(), IssueActor::User, 1, ) .unwrap(); issues.create(&project().root, &initial).await.unwrap(); let bus = Arc::new(SpyBus::default()); let uc = DeleteIssue::new(issues, bus.clone()); uc.execute(DeleteIssueInput { project: project(), issue_ref: initial.reference(), }) .await .unwrap(); assert!(matches!( bus.events().as_slice(), [DomainEvent::IssueDeleted { issue_ref, freed_sprint: None, .. }] if *issue_ref == initial.reference() )); }