Ajoute la suppression complète d'un ticket (#6). Backend Rust : - port IssueStore::delete (NotFound si absent) + event IssueDeleted{freed_sprint} - FsIssueStore::delete : supprime .ideai/tickets/<N>/ et l'index sous lock - use case DeleteIssue + adaptations des tests sprint/ticket_assistant au port - commande Tauri ticket_delete + câblage events/state/lib Frontend : - gateway delete (ports, adapter ticket + mock, domain) - useTicketDetail : retrait de la liste et fermeture du détail via event issueDeleted - intégration TicketDetail + tests Tests verts : application/issue_usecases (6), infrastructure/issue_store (7), app-tauri --lib (56), frontend vitest (503), npm run build (exit 0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
527 lines
15 KiB
Rust
527 lines
15 KiB
Rust
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<HashMap<SprintId, Sprint>>,
|
|
}
|
|
|
|
#[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<Sprint, SprintStoreError> {
|
|
self.sprints
|
|
.lock()
|
|
.unwrap()
|
|
.get(&sprint_id)
|
|
.cloned()
|
|
.ok_or(SprintStoreError::NotFound)
|
|
}
|
|
|
|
async fn list(&self, _root: &ProjectPath) -> Result<Vec<SprintIndexEntry>, 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<HashMap<IssueRef, Issue>>,
|
|
}
|
|
|
|
#[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<Issue, IssueStoreError> {
|
|
self.issues
|
|
.lock()
|
|
.unwrap()
|
|
.get(&issue_ref)
|
|
.cloned()
|
|
.ok_or(IssueStoreError::NotFound)
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
filter: IssueListFilter,
|
|
) -> Result<Vec<IssueIndexEntry>, IssueStoreError> {
|
|
let mut rows: Vec<IssueIndexEntry> = 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<IssueCarnet, IssueStoreError> {
|
|
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<IssueCarnet, IssueStoreError> {
|
|
unimplemented!("not needed")
|
|
}
|
|
}
|
|
|
|
struct SeqIds(Mutex<u128>);
|
|
|
|
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<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(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<SprintId>) -> 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<_>>(),
|
|
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);
|
|
}
|