Files
IdeaSDK/crates/application/tests/issue_usecases.rs
Blomios 8158057b1d feat(tickets): ajoute les pièces jointes sur tickets (#108)
Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté
backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le
domaine (Issue, ports), l'application (usecases + assistant de ticket), les
adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web-
server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 11:08:45 +02:00

699 lines
19 KiB
Rust

use std::collections::HashMap;
use std::str::FromStr;
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, IssueAttachmentContent,
IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueListFilter, IssueNumber, IssuePriority,
IssueRef, IssueStatus, IssueVersion, LocalPath, ManifestEntry, MarkdownDoc, ProfileId, Project,
ProjectId, ProjectPath, RemoteRef,
};
use uuid::Uuid;
use application::{
BulkDeleteIssues, BulkDeleteIssuesInput, BulkUpdateIssuePriority, BulkUpdateIssuePriorityInput,
BulkUpdateIssueStatus, BulkUpdateIssueStatusInput, CreateIssue, CreateIssueInput, DeleteIssue,
DeleteIssueInput, UpdateIssue, UpdateIssueInput,
};
#[derive(Default)]
struct FakeIssues {
issues: Mutex<HashMap<IssueRef, Issue>>,
conflict: Mutex<bool>,
}
#[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> {
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<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> {
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
}
async fn add_attachment_from_path(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
_source: &LocalPath,
_attachment_id: IssueAttachmentId,
_filename: String,
_mime: String,
_actor: IssueActor,
_now_ms: u64,
_expected_version: IssueVersion,
) -> Result<Issue, IssueStoreError> {
unimplemented!("not needed")
}
async fn read_attachment(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
_attachment_id: &IssueAttachmentId,
) -> Result<IssueAttachmentContent, IssueStoreError> {
unimplemented!("not needed")
}
async fn mark_attachment_summarized(
&self,
_root: &ProjectPath,
_issue_ref: IssueRef,
_attachment_id: &IssueAttachmentId,
_actor: IssueActor,
_now_ms: u64,
_expected_version: IssueVersion,
) -> Result<Issue, IssueStoreError> {
unimplemented!("not needed")
}
}
struct SeqAllocator(Mutex<u64>);
#[async_trait]
impl IssueNumberAllocator for SeqAllocator {
async fn allocate_next(&self, _root: &ProjectPath) -> Result<IssueNumber, IssueStoreError> {
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<MarkdownDoc, StoreError> {
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<AgentManifest, StoreError> {
Ok(self.0.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[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(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()
));
}
#[tokio::test]
async fn bulk_update_status_preserves_order_and_allows_partial_success() {
let project = project();
let issues = Arc::new(FakeIssues::default());
for number in [1, 3] {
let issue = Issue::new(
domain::IssueId::new_random(),
IssueNumber::new(number).unwrap(),
format!("Ticket {number}"),
MarkdownDoc::new("Body"),
IssueStatus::Open,
IssuePriority::Medium,
MarkdownDoc::default(),
Vec::new(),
Vec::new(),
IssueActor::User,
1,
)
.unwrap();
issues.create(&project.root, &issue).await.unwrap();
}
let bus = Arc::new(SpyBus::default());
let uc = BulkUpdateIssueStatus::new(issues.clone(), Arc::new(FixedClock), bus.clone());
let out = uc
.execute(BulkUpdateIssueStatusInput {
project: project.clone(),
issue_refs: vec![
IssueRef::from_str("#1").unwrap(),
IssueRef::from_str("#2").unwrap(),
IssueRef::from_str("#3").unwrap(),
],
status: IssueStatus::Qa,
actor: IssueActor::System,
})
.await;
let refs: Vec<String> = out
.items
.iter()
.map(|item| item.issue_ref.to_string())
.collect();
assert_eq!(refs, ["#1", "#2", "#3"]);
assert_eq!(out.items[0].issue.as_ref().unwrap().status, IssueStatus::Qa);
assert_eq!(out.items[1].error.as_ref().unwrap().code(), "NOT_FOUND");
assert_eq!(out.items[2].issue.as_ref().unwrap().status, IssueStatus::Qa);
assert_eq!(
bus.events()
.iter()
.filter(|event| matches!(event, DomainEvent::IssueStatusChanged { .. }))
.count(),
2
);
}
#[tokio::test]
async fn bulk_update_priority_updates_each_existing_ticket() {
let project = project();
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 = BulkUpdateIssuePriority::new(issues.clone(), Arc::new(FixedClock), bus.clone());
let out = uc
.execute(BulkUpdateIssuePriorityInput {
project,
issue_refs: vec![initial.reference()],
priority: IssuePriority::Critical,
actor: IssueActor::System,
})
.await;
assert_eq!(out.items.len(), 1);
assert!(out.items[0].error.is_none());
assert_eq!(
out.items[0].issue.as_ref().unwrap().priority,
IssuePriority::Critical
);
assert!(bus.events().iter().any(|event| matches!(
event,
DomainEvent::IssuePriorityChanged {
priority: IssuePriority::Critical,
..
}
)));
}
#[tokio::test]
async fn bulk_delete_preserves_order_and_allows_partial_success() {
let project = project();
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 = BulkDeleteIssues::new(issues.clone(), bus.clone());
let out = uc
.execute(BulkDeleteIssuesInput {
project: project.clone(),
issue_refs: vec![IssueRef::from_str("#2").unwrap(), initial.reference()],
})
.await;
let refs: Vec<String> = out
.items
.iter()
.map(|item| item.issue_ref.to_string())
.collect();
assert_eq!(refs, ["#2", "#1"]);
assert_eq!(out.items[0].error.as_ref().unwrap().code(), "NOT_FOUND");
assert!(out.items[1].error.is_none());
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: None,
..
}] if *issue_ref == initial.reference()
));
}