use std::collections::HashMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::ports::{Clock, EventBus, EventStream, IdGenerator, IssueStore, IssueStoreError}; use domain::{ DomainEvent, Issue, IssueActor, IssueCarnet, IssueId, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority, IssueRef, IssueStatus, IssueVersion, MarkdownDoc, Project, ProjectId, ProjectPath, RemoteRef, Sprint, SprintId, SprintIndexEntry, SprintOrder, SprintStatus, SprintStore, SprintStoreError, SprintVersion, }; use uuid::Uuid; use application::{ normalized_reorder, AssignTicketToSprint, AssignTicketToSprintInput, CreateSprint, CreateSprintInput, DeleteSprint, DeleteSprintInput, ListSprints, ListSprintsInput, RenameSprint, RenameSprintInput, ReorderSprints, ReorderSprintsInput, UnassignTicketFromSprint, UnassignTicketFromSprintInput, }; #[derive(Default)] struct FakeSprints { sprints: Mutex>, } #[async_trait] impl SprintStore for FakeSprints { async fn create(&self, _root: &ProjectPath, sprint: &Sprint) -> Result<(), SprintStoreError> { self.sprints .lock() .unwrap() .insert(sprint.id, sprint.clone()); Ok(()) } async fn get( &self, _root: &ProjectPath, sprint_id: SprintId, ) -> Result { self.sprints .lock() .unwrap() .get(&sprint_id) .cloned() .ok_or(SprintStoreError::NotFound) } async fn list(&self, _root: &ProjectPath) -> Result, SprintStoreError> { let mut rows: Vec<_> = self .sprints .lock() .unwrap() .values() .map(SprintIndexEntry::from) .collect(); rows.sort_by_key(|row| row.order.get()); Ok(rows) } async fn update( &self, _root: &ProjectPath, sprint: &Sprint, expected_version: SprintVersion, ) -> Result<(), SprintStoreError> { let mut sprints = self.sprints.lock().unwrap(); let current = sprints.get(&sprint.id).ok_or(SprintStoreError::NotFound)?; if current.version != expected_version { return Err(SprintStoreError::VersionConflict { expected: expected_version, actual: current.version, }); } sprints.insert(sprint.id, sprint.clone()); Ok(()) } async fn delete( &self, _root: &ProjectPath, sprint_id: SprintId, ) -> Result<(), SprintStoreError> { self.sprints .lock() .unwrap() .remove(&sprint_id) .map(|_| ()) .ok_or(SprintStoreError::NotFound) } } #[derive(Default)] struct FakeIssues { issues: 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> { let mut rows: Vec = self .issues .lock() .unwrap() .values() .filter(|issue| { filter .sprint .map_or(true, |sprint| issue.sprint == Some(sprint)) }) .map(IssueIndexEntry::from) .collect(); rows.sort_by_key(|row| row.issue_ref.number().get()); Ok(rows) } async fn update( &self, _root: &ProjectPath, issue: &Issue, expected_version: IssueVersion, ) -> Result<(), IssueStoreError> { let mut issues = self.issues.lock().unwrap(); let current = issues .get(&issue.reference()) .ok_or(IssueStoreError::NotFound)?; if current.version != expected_version { return Err(IssueStoreError::VersionConflict { expected: expected_version, actual: current.version, }); } issues.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 { unimplemented!("not needed") } } struct SeqIds(Mutex); impl IdGenerator for SeqIds { fn new_uuid(&self) -> Uuid { let mut next = self.0.lock().unwrap(); let id = Uuid::from_u128(*next); *next += 1; id } } struct FixedClock(i64); impl Clock for FixedClock { fn now_millis(&self) -> i64 { self.0 } } #[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(Vec::new().into_iter()) } } fn project() -> Project { Project { id: ProjectId::from_uuid(Uuid::from_u128(900)), name: "P".to_owned(), root: ProjectPath::new("/tmp/idea-sprint-usecases").unwrap(), remote: RemoteRef::Local, created_at: 1_000, } } fn sid(n: u128) -> SprintId { SprintId::from_uuid(Uuid::from_u128(n)) } fn sprint(id: SprintId, order: u32, name: &str) -> Sprint { Sprint::new( id, SprintOrder::new(order).unwrap(), name, None, IssueActor::User, 1_000, ) .unwrap() } fn issue(number: u64, title: &str, sprint_id: Option) -> Issue { Issue::new( IssueId::from_uuid(Uuid::from_u128(number as u128)), IssueNumber::new(number).unwrap(), title, MarkdownDoc::default(), IssueStatus::Open, IssuePriority::Medium, MarkdownDoc::default(), Vec::new(), Vec::new(), IssueActor::User, 1_000, ) .unwrap() .mutate(IssueActor::System, 1_000, |issue| issue.sprint = sprint_id) .unwrap() } #[tokio::test] async fn create_sprint_uses_max_order_plus_one_and_publishes_event() { let sprints = Arc::new(FakeSprints::default()); sprints .create(&project().root, &sprint(sid(1), 2, "Existing")) .await .unwrap(); let bus = Arc::new(SpyBus::default()); let usecase = CreateSprint::new( sprints, Arc::new(SeqIds(Mutex::new(10))), Arc::new(FixedClock(2_000)), bus.clone(), ); let out = usecase .execute(CreateSprintInput { project: project(), name: "Next".to_owned(), status: Some(SprintStatus::Active), actor: IssueActor::User, }) .await .unwrap(); assert_eq!(out.sprint.id, sid(10)); assert_eq!(out.sprint.order.get(), 3); assert_eq!(out.sprint.status, SprintStatus::Active); assert!(bus.events().contains(&DomainEvent::SprintCreated { sprint_id: sid(10), order: SprintOrder::new(3).unwrap(), })); } #[tokio::test] async fn rename_and_reorder_publish_sprint_events() { let sprints = Arc::new(FakeSprints::default()); let first = sprint(sid(1), 1, "One"); let second = sprint(sid(2), 2, "Two"); sprints.create(&project().root, &first).await.unwrap(); sprints.create(&project().root, &second).await.unwrap(); let bus = Arc::new(SpyBus::default()); let renamed = RenameSprint::new(sprints.clone(), Arc::new(FixedClock(2_000)), bus.clone()) .execute(RenameSprintInput { project: project(), sprint_id: sid(1), expected_version: first.version, name: "Renamed".to_owned(), actor: IssueActor::User, }) .await .unwrap() .sprint; assert_eq!(renamed.name, "Renamed"); let reordered = ReorderSprints::new(sprints.clone(), Arc::new(FixedClock(3_000)), bus.clone()) .execute(ReorderSprintsInput { project: project(), ordered_ids: vec![sid(2), sid(1)], actor: IssueActor::System, }) .await .unwrap(); assert_eq!( reordered .sprints .iter() .map(|sprint| (sprint.id, sprint.order.get())) .collect::>(), vec![(sid(2), 1), (sid(1), 2)] ); assert!(bus.events().iter().any(|event| { matches!( event, DomainEvent::SprintRenamed { sprint_id, name, .. } if *sprint_id == sid(1) && name == "Renamed" ) })); assert!(bus.events().iter().any(|event| { matches!( event, DomainEvent::SprintReordered { sprint_id, order, .. } if *sprint_id == sid(2) && order.get() == 1 ) })); } #[test] fn normalized_reorder_rejects_incomplete_or_duplicate_ids() { let sprints = vec![sprint(sid(1), 1, "One"), sprint(sid(2), 2, "Two")]; assert!(normalized_reorder(&sprints, &[sid(1)], IssueActor::User, 2_000).is_err()); assert!(normalized_reorder(&sprints, &[sid(1), sid(1)], IssueActor::User, 2_000).is_err()); } #[tokio::test] async fn assign_and_unassign_ticket_to_sprint_use_issue_expected_version() { let sprints = Arc::new(FakeSprints::default()); sprints .create(&project().root, &sprint(sid(1), 1, "One")) .await .unwrap(); let issues = Arc::new(FakeIssues::default()); let original = issue(1, "Ticket", None); issues.create(&project().root, &original).await.unwrap(); let bus = Arc::new(SpyBus::default()); let assign = AssignTicketToSprint::new( sprints, issues.clone(), Arc::new(FixedClock(2_000)), bus.clone(), ); let conflict = assign .execute(AssignTicketToSprintInput { project: project(), issue_ref: original.reference(), sprint_id: sid(1), expected_version: original.version.next(), actor: IssueActor::User, }) .await .unwrap_err(); assert_eq!(conflict.code(), "INVALID"); let assigned = assign .execute(AssignTicketToSprintInput { project: project(), issue_ref: original.reference(), sprint_id: sid(1), expected_version: original.version, actor: IssueActor::User, }) .await .unwrap() .issue; assert_eq!(assigned.sprint, Some(sid(1))); let unassigned = UnassignTicketFromSprint::new(issues, Arc::new(FixedClock(3_000)), bus.clone()) .execute(UnassignTicketFromSprintInput { project: project(), issue_ref: original.reference(), expected_version: assigned.version, actor: IssueActor::User, }) .await .unwrap() .issue; assert_eq!(unassigned.sprint, None); assert!(bus.events().iter().any(|event| { matches!( event, DomainEvent::IssueSprintChanged { issue_ref, from: None, to: Some(target), .. } if *issue_ref == original.reference() && *target == sid(1) ) })); } #[tokio::test] async fn delete_sprint_unassigns_tickets_without_deleting_them() { let sprints = Arc::new(FakeSprints::default()); sprints .create(&project().root, &sprint(sid(1), 1, "One")) .await .unwrap(); let issues = Arc::new(FakeIssues::default()); let assigned = issue(1, "Assigned", Some(sid(1))); let backlog = issue(2, "Backlog", None); issues.create(&project().root, &assigned).await.unwrap(); issues.create(&project().root, &backlog).await.unwrap(); let bus = Arc::new(SpyBus::default()); DeleteSprint::new( sprints.clone(), issues.clone(), Arc::new(FixedClock(2_000)), bus.clone(), ) .execute(DeleteSprintInput { project: project(), sprint_id: sid(1), actor: IssueActor::System, }) .await .unwrap(); assert!(matches!( sprints.get(&project().root, sid(1)).await.unwrap_err(), SprintStoreError::NotFound )); assert_eq!( issues .get_by_ref(&project().root, assigned.reference()) .await .unwrap() .sprint, None ); assert!(issues .get_by_ref(&project().root, backlog.reference()) .await .is_ok()); assert!(bus .events() .contains(&DomainEvent::SprintDeleted { sprint_id: sid(1) })); } #[tokio::test] async fn list_sprints_returns_ticket_counts() { let sprints = Arc::new(FakeSprints::default()); sprints .create(&project().root, &sprint(sid(1), 1, "One")) .await .unwrap(); let issues = Arc::new(FakeIssues::default()); issues .create(&project().root, &issue(1, "A", Some(sid(1)))) .await .unwrap(); issues .create(&project().root, &issue(2, "B", Some(sid(1)))) .await .unwrap(); let out = ListSprints::new(sprints, issues) .execute(ListSprintsInput { project: project() }) .await .unwrap(); assert_eq!(out.sprints.len(), 1); assert_eq!(out.sprints[0].ticket_count, 2); }