feat(tickets): checkpoint backend V1 du système de tickets (T1-T5)
Checkpoint de travail — QA formelle encore à venir (après reset Codex). `cargo build` workspace OK, tests ticket/issue verts (domaine, store, use cases, app-tauri 47/51). Le domaine s'appelle `Issue`, exposé « ticket » côté MCP/UI. - T1 domaine : entité Issue (statut, priorité, liens, carnet), events, ids, ports. - T2 infra : store FS Markdown + allocator de références #N. - T3 application : use cases Issue (create/read/list/update/status/ priority/carnet/link/unlink/assign) + erreurs dédiées. - T4 surface MCP : 10 outils publics idea_ticket_* (23 outils au total), mappés vers les use cases Issue. - T5 app-tauri : commandes Tauri ticket_* + DTOs d'events Issue + câblage state.rs. Dette de test PRÉ-EXISTANTE héritée de develop (4 tests app-tauri mcp_e2e_loopback / mcp_serve_peer rouges) hors périmètre tickets, non traitée ici. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
402
crates/application/tests/issue_usecases.rs
Normal file
402
crates/application/tests/issue_usecases.rs
Normal file
@ -0,0 +1,402 @@
|
||||
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, 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 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
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
Reference in New Issue
Block a user