use std::path::PathBuf; use std::str::FromStr; use domain::{ AgentIssueRef, AgentIssueRole, Issue, IssueActor, IssueId, IssueListFilter, IssueNumberAllocator, IssuePriority, IssueRef, IssueStatus, IssueStore, IssueStoreError, MarkdownDoc, ProjectPath, SprintId, }; 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_with_empty_or_and_and_semantics() { 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 qa_high = issue(&root, 3, "Gamma") .mutate(IssueActor::System, 3_000, |i| { i.status = IssueStatus::Qa; i.priority = IssuePriority::Critical; }) .unwrap(); store.create(&root, &qa_high).await.unwrap(); let all = store.list(&root, IssueListFilter::default()).await.unwrap(); assert_eq!(all.len(), 3); let by_status_or = store .list( &root, IssueListFilter { statuses: vec![IssueStatus::Closed, IssueStatus::Qa], ..IssueListFilter::default() }, ) .await .unwrap(); assert_eq!(by_status_or.len(), 2); assert_eq!(by_status_or[0].title, "Beta"); assert_eq!(by_status_or[1].title, "Gamma"); let by_priority_or = store .list( &root, IssueListFilter { priorities: vec![IssuePriority::Low, IssuePriority::Critical], ..IssueListFilter::default() }, ) .await .unwrap(); assert_eq!(by_priority_or.len(), 2); assert_eq!(by_priority_or[0].title, "Beta"); assert_eq!(by_priority_or[1].title, "Gamma"); let by_status_and_priority = store .list( &root, IssueListFilter { statuses: vec![IssueStatus::Closed, IssueStatus::Qa], priorities: vec![IssuePriority::Low], ..IssueListFilter::default() }, ) .await .unwrap(); assert_eq!(by_status_and_priority.len(), 1); assert_eq!(by_status_and_priority[0].title, "Beta"); } #[tokio::test] async fn issue_store_persists_and_filters_sprint_membership() { let tmp = TempDir::new(); let root = tmp.root(); let store = FsIssueStore::new(); let sprint_id = SprintId::from_uuid(Uuid::from_u128(42)); let assigned = issue(&root, 1, "Assigned") .mutate(IssueActor::System, 2_000, |i| { i.sprint = Some(sprint_id); }) .unwrap(); store.create(&root, &assigned).await.unwrap(); store .create(&root, &issue(&root, 2, "Backlog")) .await .unwrap(); let loaded = store .get_by_ref(&root, IssueRef::from_str("#1").unwrap()) .await .unwrap(); assert_eq!(loaded.sprint, Some(sprint_id)); let rows = store .list( &root, IssueListFilter { sprint: Some(sprint_id), ..IssueListFilter::default() }, ) .await .unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].title, "Assigned"); assert_eq!(rows[0].sprint, Some(sprint_id)); } #[tokio::test] async fn issue_store_delete_removes_files_and_index_entry() { let tmp = TempDir::new(); let root = tmp.root(); let store = FsIssueStore::new(); store .create(&root, &issue(&root, 1, "Delete me")) .await .unwrap(); std::fs::write(tmp.child(".ideai/tickets/1/attachment.txt"), "payload").unwrap(); store .delete(&root, IssueRef::from_str("#1").unwrap()) .await .unwrap(); assert!(!tmp.child(".ideai/tickets/1/issue.md").exists()); assert!(!tmp.child(".ideai/tickets/1/carnet.md").exists()); assert!(!tmp.child(".ideai/tickets/1/attachment.txt").exists()); assert!(!tmp.child(".ideai/tickets/1").exists()); let rows = store.list(&root, IssueListFilter::default()).await.unwrap(); assert!(rows.is_empty()); let index = std::fs::read_to_string(tmp.child(".ideai/tickets/index.json")).unwrap(); assert!(!index.contains("\"issueRef\": \"#1\"")); } #[tokio::test] async fn issue_store_delete_returns_not_found_when_absent() { let tmp = TempDir::new(); let root = tmp.root(); let store = FsIssueStore::new(); let err = store .delete(&root, IssueRef::from_str("#404").unwrap()) .await .unwrap_err(); assert!(matches!(err, IssueStoreError::NotFound)); } #[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); }