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:
163
crates/infrastructure/tests/issue_store.rs
Normal file
163
crates/infrastructure/tests/issue_store.rs
Normal file
@ -0,0 +1,163 @@
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
use domain::{
|
||||
AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter,
|
||||
IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError,
|
||||
MarkdownDoc, ProjectPath,
|
||||
};
|
||||
use infrastructure::{FsIssueNumberAllocator, FsIssueStore};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("idea-issues-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn root(&self) -> ProjectPath {
|
||||
ProjectPath::new(self.0.to_string_lossy().to_string()).unwrap()
|
||||
}
|
||||
|
||||
fn child(&self, rel: &str) -> PathBuf {
|
||||
self.0.join(rel)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn issue(root: &ProjectPath, number: u64, title: &str) -> Issue {
|
||||
let _ = root;
|
||||
Issue::new(
|
||||
IssueId::new_random(),
|
||||
domain::IssueNumber::new(number).unwrap(),
|
||||
title,
|
||||
MarkdownDoc::new("Initial description"),
|
||||
IssueStatus::Open,
|
||||
IssuePriority::High,
|
||||
MarkdownDoc::new("Initial carnet"),
|
||||
Vec::new(),
|
||||
vec![AgentIssueRef {
|
||||
agent_id: domain::AgentId::new_random(),
|
||||
role: AgentIssueRole::Assigned,
|
||||
}],
|
||||
IssueActor::User,
|
||||
1_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_store_writes_markdown_docs_and_index() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let store = FsIssueStore::new();
|
||||
let issue = issue(&root, 1, "Wire issues");
|
||||
|
||||
store.create(&root, &issue).await.unwrap();
|
||||
|
||||
let issue_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/issue.md")).unwrap();
|
||||
let carnet_md = std::fs::read_to_string(tmp.child(".ideai/tickets/1/carnet.md")).unwrap();
|
||||
let index = std::fs::read_to_string(tmp.child(".ideai/tickets/index.json")).unwrap();
|
||||
|
||||
assert!(issue_md.starts_with("---\n"));
|
||||
assert!(issue_md.contains("title: \"Wire issues\""));
|
||||
assert!(issue_md.ends_with("Initial description"));
|
||||
assert!(carnet_md.contains("issueRef: \"#1\""));
|
||||
assert!(carnet_md.ends_with("Initial carnet"));
|
||||
assert!(index.contains("\"issueRef\": \"#1\""));
|
||||
|
||||
let loaded = store
|
||||
.get_by_ref(&root, IssueRef::from_str("#1").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(loaded.title, "Wire issues");
|
||||
assert_eq!(loaded.carnet.as_str(), "Initial carnet");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_store_update_checks_expected_version() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let store = FsIssueStore::new();
|
||||
let original = issue(&root, 2, "Conflict");
|
||||
store.create(&root, &original).await.unwrap();
|
||||
let updated = original
|
||||
.clone()
|
||||
.mutate(IssueActor::System, 2_000, |i| {
|
||||
i.status = IssueStatus::InProgress;
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let err = store
|
||||
.update(&root, &updated, updated.version)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, IssueStoreError::VersionConflict { .. }));
|
||||
|
||||
store
|
||||
.update(&root, &updated, original.version)
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = store.get_by_ref(&root, updated.reference()).await.unwrap();
|
||||
assert_eq!(loaded.status, IssueStatus::InProgress);
|
||||
assert_eq!(loaded.version.get(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_store_lists_by_index_filters() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let store = FsIssueStore::new();
|
||||
store
|
||||
.create(&root, &issue(&root, 1, "Alpha"))
|
||||
.await
|
||||
.unwrap();
|
||||
let closed = issue(&root, 2, "Beta")
|
||||
.mutate(IssueActor::System, 2_000, |i| {
|
||||
i.status = IssueStatus::Closed;
|
||||
i.priority = IssuePriority::Low;
|
||||
})
|
||||
.unwrap();
|
||||
store.create(&root, &closed).await.unwrap();
|
||||
|
||||
let rows = store
|
||||
.list(
|
||||
&root,
|
||||
IssueListFilter {
|
||||
status: Some(IssueStatus::Closed),
|
||||
priority: Some(IssuePriority::Low),
|
||||
..IssueListFilter::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].title, "Beta");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allocator_never_reuses_numbers() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let allocator = FsIssueNumberAllocator::new();
|
||||
|
||||
let first = allocator.allocate_next(&root).await.unwrap();
|
||||
let second = allocator.allocate_next(&root).await.unwrap();
|
||||
let third = FsIssueNumberAllocator::new()
|
||||
.allocate_next(&root)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(first.get(), 1);
|
||||
assert_eq!(second.get(), 2);
|
||||
assert_eq!(third.get(), 3);
|
||||
}
|
||||
Reference in New Issue
Block a user