feat(tickets): suppression d'un ticket (backend + frontend)
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>
This commit is contained in:
@ -161,6 +161,59 @@ impl ReadIssue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`DeleteIssue::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteIssueInput {
|
||||
/// Project owning the issue.
|
||||
pub project: Project,
|
||||
/// Issue reference.
|
||||
pub issue_ref: IssueRef,
|
||||
}
|
||||
|
||||
/// Output of [`DeleteIssue::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteIssueOutput {
|
||||
/// Deleted issue reference.
|
||||
pub issue_ref: IssueRef,
|
||||
}
|
||||
|
||||
/// Deletes an issue.
|
||||
pub struct DeleteIssue {
|
||||
issues: Arc<dyn IssueStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl DeleteIssue {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(issues: Arc<dyn IssueStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { issues, events }
|
||||
}
|
||||
|
||||
/// Executes deletion.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] on missing issue or store failure.
|
||||
pub async fn execute(&self, input: DeleteIssueInput) -> Result<DeleteIssueOutput, AppError> {
|
||||
let issue = self
|
||||
.issues
|
||||
.get_by_ref(&input.project.root, input.issue_ref)
|
||||
.await?;
|
||||
let freed_sprint = issue.sprint;
|
||||
self.issues
|
||||
.delete(&input.project.root, input.issue_ref)
|
||||
.await?;
|
||||
self.events.publish(DomainEvent::IssueDeleted {
|
||||
project_id: input.project.id,
|
||||
issue_ref: input.issue_ref,
|
||||
freed_sprint,
|
||||
});
|
||||
Ok(DeleteIssueOutput {
|
||||
issue_ref: input.issue_ref,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`ReadIssueCarnet::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadIssueCarnetInput {
|
||||
|
||||
@ -79,11 +79,11 @@ pub use git::{
|
||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
||||
pub use issues::{
|
||||
AssignIssueAgent, AssignIssueAgentInput, AssignIssueAgentOutput, CreateIssue, CreateIssueInput,
|
||||
CreateIssueOutput, LinkIssues, LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput,
|
||||
ListIssuesOutput, ReadIssue, ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput,
|
||||
ReadIssueInput, ReadIssueOutput, UnlinkIssues, UnlinkIssuesInput, UpdateIssue,
|
||||
UpdateIssueCarnet, UpdateIssueCarnetInput, UpdateIssueCarnetOutput, UpdateIssueInput,
|
||||
UpdateIssueOutput,
|
||||
CreateIssueOutput, DeleteIssue, DeleteIssueInput, DeleteIssueOutput, LinkIssues,
|
||||
LinkIssuesInput, LinkIssuesOutput, ListIssues, ListIssuesInput, ListIssuesOutput, ReadIssue,
|
||||
ReadIssueCarnet, ReadIssueCarnetInput, ReadIssueCarnetOutput, ReadIssueInput, ReadIssueOutput,
|
||||
UnlinkIssues, UnlinkIssuesInput, UpdateIssue, UpdateIssueCarnet, UpdateIssueCarnetInput,
|
||||
UpdateIssueCarnetOutput, UpdateIssueInput, UpdateIssueOutput,
|
||||
};
|
||||
pub use layout::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
|
||||
@ -13,7 +13,9 @@ use domain::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{CreateIssue, CreateIssueInput, UpdateIssue, UpdateIssueInput};
|
||||
use application::{
|
||||
CreateIssue, CreateIssueInput, DeleteIssue, DeleteIssueInput, UpdateIssue, UpdateIssueInput,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeIssues {
|
||||
@ -84,6 +86,19 @@ impl IssueStore for FakeIssues {
|
||||
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,
|
||||
@ -400,3 +415,92 @@ async fn update_issue_maps_version_conflict() {
|
||||
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()
|
||||
));
|
||||
}
|
||||
|
||||
@ -159,6 +159,19 @@ impl IssueStore for FakeIssues {
|
||||
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,
|
||||
|
||||
@ -106,6 +106,14 @@ impl IssueStore for FakeIssues {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_issue_ref: IssueRef,
|
||||
) -> Result<(), IssueStoreError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn read_carnet(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
|
||||
Reference in New Issue
Block a user