#![cfg(feature = "neo4j-tests")]
use intent_engine::neo4j::*;
use intent_engine::plan::{PlanRequest, TaskStatus, TaskTree};
use intent_engine::tasks::TaskUpdate;
use intent_engine::workspace::resolve_session_id;
use neo4rs::Graph;
async fn setup() -> (Graph, String) {
let uri = std::env::var("NEO4J_URI").expect("NEO4J_URI must be set for neo4j-tests");
let password =
std::env::var("NEO4J_PASSWORD").expect("NEO4J_PASSWORD must be set for neo4j-tests");
let user = std::env::var("NEO4J_USER").unwrap_or_else(|_| "neo4j".into());
let graph = Graph::new(&uri, &user, &password)
.await
.expect("Failed to connect to Neo4j");
let project_id = format!(
"test-{}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
std::process::id()
);
intent_engine::neo4j::schema::ensure_schema(&graph, &project_id)
.await
.expect("Schema init failed");
(graph, project_id)
}
async fn teardown(graph: &Graph, project_id: &str) {
graph
.run(
neo4rs::query("MATCH (n {project_id: $pid}) DETACH DELETE n")
.param("pid", project_id.to_string()),
)
.await
.expect("Teardown failed");
}
#[tokio::test]
async fn neo4j_task_create_get_update() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let task = tm
.add_task(
"Test Task".to_string(),
Some("A spec".to_string()),
None,
Some("ai".to_string()),
None,
None,
)
.await
.unwrap();
assert_eq!(task.name, "Test Task");
assert_eq!(task.status, "todo");
assert_eq!(task.spec.as_deref(), Some("A spec"));
let fetched = tm.get_task(task.id).await.unwrap();
assert_eq!(fetched.id, task.id);
assert_eq!(fetched.name, "Test Task");
let updated = tm
.update_task(
task.id,
TaskUpdate {
spec: Some("Updated spec"),
priority: Some(1),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(updated.spec.as_deref(), Some("Updated spec"));
assert_eq!(updated.priority, Some(1));
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_task_parent_child_hierarchy() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let parent = tm
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
let child = tm
.add_task("Child".to_string(), None, Some(parent.id), None, None, None)
.await
.unwrap();
let grandchild = tm
.add_task(
"Grandchild".to_string(),
None,
Some(child.id),
None,
None,
None,
)
.await
.unwrap();
let children = tm.get_children(parent.id).await.unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].id, child.id);
let descendants = tm.get_descendants(parent.id).await.unwrap();
assert_eq!(descendants.len(), 2);
let ancestry = tm.get_task_ancestry(grandchild.id).await.unwrap();
assert_eq!(ancestry.len(), 2);
assert_eq!(ancestry[0].id, child.id); assert_eq!(ancestry[1].id, parent.id);
let child2 = tm
.add_task(
"Child2".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let siblings = tm.get_siblings(child.id, Some(parent.id)).await.unwrap();
assert_eq!(siblings.len(), 1);
assert_eq!(siblings[0].id, child2.id);
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_task_start_done_lifecycle() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let task = tm
.add_task(
"Lifecycle Task".to_string(),
Some("do the thing".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
assert_eq!(task.status, "todo");
let started = tm.start_task(task.id, false).await.unwrap();
assert_eq!(started.task.status, "doing");
let done = tm.done_task_by_id(task.id, false).await.unwrap();
assert_eq!(done.completed_task.status, "done");
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_task_done_blocked_by_children() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let parent = tm
.add_task(
"Parent".to_string(),
Some("parent spec".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
let _child = tm
.add_task("Child".to_string(), None, Some(parent.id), None, None, None)
.await
.unwrap();
tm.start_task(parent.id, false).await.unwrap();
let result = tm.done_task_by_id(parent.id, false).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("undone") || err.contains("child") || err.contains("subtask"),
"Expected child-blocking error, got: {}",
err
);
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_task_delete_cascade() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let parent = tm
.add_task("Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
let _child = tm
.add_task(
"Child1".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let _child2 = tm
.add_task(
"Child2".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let deleted = tm.delete_task_cascade(parent.id).await.unwrap();
assert_eq!(deleted, 2);
assert!(tm.get_task(parent.id).await.is_err());
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_plan_create_hierarchy() {
let (graph, pid) = setup().await;
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone());
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let request = PlanRequest {
tasks: vec![TaskTree {
name: Some("Parent Task".to_string()),
status: Some(TaskStatus::Todo),
spec: Some("Parent spec".to_string()),
children: Some(vec![
TaskTree {
name: Some("Child A".to_string()),
status: Some(TaskStatus::Todo),
..Default::default()
},
TaskTree {
name: Some("Child B".to_string()),
status: Some(TaskStatus::Todo),
..Default::default()
},
]),
..Default::default()
}],
};
let result = pe.execute(&request).await.unwrap();
assert!(result.success, "Plan failed: {:?}", result.error);
assert_eq!(result.created_count, 3);
let parent_id = result.task_id_map.get("Parent Task").unwrap();
let children = tm.get_children(*parent_id).await.unwrap();
assert_eq!(children.len(), 2);
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_plan_idempotent_update() {
let (graph, pid) = setup().await;
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone());
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let request = PlanRequest {
tasks: vec![TaskTree {
name: Some("Idempotent Task".to_string()),
status: Some(TaskStatus::Todo),
spec: Some("Original spec".to_string()),
..Default::default()
}],
};
let r1 = pe.execute(&request).await.unwrap();
assert!(r1.success);
assert_eq!(r1.created_count, 1);
let update_request = PlanRequest {
tasks: vec![TaskTree {
name: Some("Idempotent Task".to_string()),
status: Some(TaskStatus::Todo),
spec: Some("Updated spec".to_string()),
..Default::default()
}],
};
let r2 = pe.execute(&update_request).await.unwrap();
assert!(r2.success);
assert_eq!(r2.created_count, 0);
assert_eq!(r2.updated_count, 1);
let task_id = r2.task_id_map.get("Idempotent Task").unwrap();
let task = tm.get_task(*task_id).await.unwrap();
assert_eq!(task.spec.as_deref(), Some("Updated spec"));
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_plan_doing_requires_spec() {
let (graph, pid) = setup().await;
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone());
let request = PlanRequest {
tasks: vec![TaskTree {
name: Some("No Spec Task".to_string()),
status: Some(TaskStatus::Doing),
..Default::default()
}],
};
let result = pe.execute(&request).await.unwrap();
assert!(!result.success);
assert!(result.error.as_deref().unwrap_or("").contains("spec"));
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_plan_duplicate_names_rejected() {
let (graph, pid) = setup().await;
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone());
let request = PlanRequest {
tasks: vec![
TaskTree {
name: Some("Dup".to_string()),
..Default::default()
},
TaskTree {
name: Some("Dup".to_string()),
..Default::default()
},
],
};
let result = pe.execute(&request).await.unwrap();
assert!(!result.success);
assert!(result.error.as_deref().unwrap_or("").contains("Duplicate"));
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_plan_delete_by_id() {
let (graph, pid) = setup().await;
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone());
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let task = tm
.add_task("To Delete".to_string(), None, None, None, None, None)
.await
.unwrap();
let request = PlanRequest {
tasks: vec![TaskTree {
id: Some(task.id),
delete: Some(true),
..Default::default()
}],
};
let result = pe.execute(&request).await.unwrap();
assert!(result.success, "Plan failed: {:?}", result.error);
assert_eq!(result.deleted_count, 1);
assert!(tm.get_task(task.id).await.is_err());
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_plan_dependencies_created() {
let (graph, pid) = setup().await;
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone());
let request = PlanRequest {
tasks: vec![
TaskTree {
name: Some("Foundation".to_string()),
status: Some(TaskStatus::Todo),
..Default::default()
},
TaskTree {
name: Some("Building".to_string()),
status: Some(TaskStatus::Todo),
depends_on: Some(vec!["Foundation".to_string()]),
..Default::default()
},
],
};
let result = pe.execute(&request).await.unwrap();
assert!(result.success, "Plan failed: {:?}", result.error);
assert_eq!(result.dependency_count, 1);
let building_id = result.task_id_map.get("Building").unwrap();
let foundation_id = result.task_id_map.get("Foundation").unwrap();
let mut check = graph
.execute(
neo4rs::query(
"MATCH (blocked:Task {project_id: $pid, id: $bid})\
-[:BLOCKED_BY]->\
(blocking:Task {project_id: $pid, id: $fid}) \
RETURN count(*) AS cnt",
)
.param("pid", pid.clone())
.param("bid", *building_id)
.param("fid", *foundation_id),
)
.await
.unwrap();
let row = check.next().await.unwrap().unwrap();
let cnt: i64 = row.get("cnt").unwrap();
assert_eq!(cnt, 1, "BLOCKED_BY relationship should exist");
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_plan_circular_dependency_rejected() {
let (graph, pid) = setup().await;
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone());
let request = PlanRequest {
tasks: vec![
TaskTree {
name: Some("A".to_string()),
depends_on: Some(vec!["B".to_string()]),
..Default::default()
},
TaskTree {
name: Some("B".to_string()),
depends_on: Some(vec!["A".to_string()]),
..Default::default()
},
],
};
let result = pe.execute(&request).await.unwrap();
assert!(!result.success);
assert!(
result
.error
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains("circular"),
"Expected circular dependency error, got: {:?}",
result.error
);
teardown(&graph, &pid).await;
}
async fn retry_search(
sm: &Neo4jSearchManager,
query: &str,
tasks: bool,
events: bool,
min_count: i64,
max_retries: usize,
) -> intent_engine::db::models::PaginatedSearchResults {
for i in 0..max_retries {
let results = sm
.search(query, tasks, events, Some(20), Some(0))
.await
.unwrap();
let count = if tasks {
results.total_tasks
} else {
results.total_events
};
if count >= min_count {
return results;
}
if i < max_retries - 1 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
sm.search(query, tasks, events, Some(20), Some(0))
.await
.unwrap()
}
#[tokio::test]
async fn neo4j_search_tasks_fulltext() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let sm = Neo4jSearchManager::new(graph.clone(), pid.clone());
tm.add_task(
"Implement authentication".to_string(),
Some("JWT tokens for user login".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
tm.add_task(
"Fix database bug".to_string(),
Some("Connection pool timeout".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
let results = retry_search(&sm, "authentication", true, false, 1, 10).await;
assert!(
results.total_tasks >= 1,
"Expected at least 1 task match, got {}",
results.total_tasks
);
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_search_tasks_contains_cjk() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let sm = Neo4jSearchManager::new(graph.clone(), pid.clone());
tm.add_task(
"实现用户认证".to_string(),
Some("使用JWT令牌".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
let results = sm
.search("认证", true, false, Some(10), Some(0))
.await
.unwrap();
assert!(
results.total_tasks >= 1,
"CJK CONTAINS search should find task, got {}",
results.total_tasks
);
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_search_events() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let em = Neo4jEventManager::new(graph.clone(), pid.clone());
let sm = Neo4jSearchManager::new(graph.clone(), pid.clone());
let task = tm
.add_task("Event Host".to_string(), None, None, None, None, None)
.await
.unwrap();
em.add_event(
task.id,
"decision".to_string(),
"Chose PostgreSQL over MySQL for better JSON support".to_string(),
)
.await
.unwrap();
let results = retry_search(&sm, "PostgreSQL", false, true, 1, 10).await;
assert!(
results.total_events >= 1,
"Expected at least 1 event match, got {}",
results.total_events
);
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_search_empty_query() {
let (graph, pid) = setup().await;
let sm = Neo4jSearchManager::new(graph.clone(), pid.clone());
let results = sm.search("", true, true, Some(10), Some(0)).await.unwrap();
assert_eq!(results.total_tasks, 0);
assert_eq!(results.total_events, 0);
assert!(results.results.is_empty());
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_event_crud() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let em = Neo4jEventManager::new(graph.clone(), pid.clone());
let task = tm
.add_task("Event Target".to_string(), None, None, None, None, None)
.await
.unwrap();
let e1 = em
.add_event(
task.id,
"decision".to_string(),
"Chose approach A".to_string(),
)
.await
.unwrap();
let e2 = em
.add_event(
task.id,
"blocker".to_string(),
"Waiting for API access".to_string(),
)
.await
.unwrap();
assert_eq!(e1.log_type, "decision");
assert_eq!(e2.log_type, "blocker");
let events = em
.list_events(Some(task.id), None, None, None)
.await
.unwrap();
assert_eq!(events.len(), 2);
let decisions = em
.list_events(Some(task.id), None, Some("decision".to_string()), None)
.await
.unwrap();
assert_eq!(decisions.len(), 1);
let err = em
.add_event(99999, "note".to_string(), "ghost".to_string())
.await;
assert!(err.is_err());
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_workspace_focus() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let wm = Neo4jWorkspaceManager::new(graph.clone(), pid.clone());
let task = tm
.add_task(
"Focus Target".to_string(),
Some("focus spec".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
let session_id = "test-session-1".to_string();
wm.set_current_task(task.id, Some(session_id.clone()))
.await
.unwrap();
let response = wm.get_current_task(Some(session_id.clone())).await.unwrap();
assert_eq!(response.current_task_id, Some(task.id));
wm.clear_current_task(Some(session_id.clone()))
.await
.unwrap();
let cleared = wm.get_current_task(Some(session_id)).await.unwrap();
assert_eq!(cleared.current_task_id, None);
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_find_tasks_filter_sort_paginate() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let parent = tm
.add_task("Filter Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
let t1 = tm
.add_task(
"Todo A".to_string(),
None,
Some(parent.id),
None,
Some(2),
None,
)
.await
.unwrap();
let t2 = tm
.add_task(
"Doing B".to_string(),
Some("spec".to_string()),
Some(parent.id),
None,
Some(1),
None,
)
.await
.unwrap();
tm.start_task(t2.id, false).await.unwrap();
let t3 = tm
.add_task(
"Todo C".to_string(),
None,
Some(parent.id),
None,
Some(3),
None,
)
.await
.unwrap();
let _root2 = tm
.add_task("Root Unrelated".to_string(), None, None, None, None, None)
.await
.unwrap();
let doing = tm
.find_tasks(Some("doing".to_string()), None, None, None, None)
.await
.unwrap();
assert!(doing.tasks.iter().all(|t| t.status == "doing"));
assert!(doing.tasks.iter().any(|t| t.id == t2.id));
let children = tm
.find_tasks(None, Some(Some(parent.id)), None, None, None)
.await
.unwrap();
assert_eq!(children.total_count, 3);
assert!(children
.tasks
.iter()
.all(|t| t.parent_id == Some(parent.id)));
let page1 = tm
.find_tasks(None, Some(Some(parent.id)), None, Some(2), Some(0))
.await
.unwrap();
assert_eq!(page1.tasks.len(), 2);
assert!(page1.has_more);
let page2 = tm
.find_tasks(None, Some(Some(parent.id)), None, Some(2), Some(2))
.await
.unwrap();
assert_eq!(page2.tasks.len(), 1);
assert!(!page2.has_more);
use intent_engine::db::models::TaskSortBy;
let sorted = tm
.find_tasks(
Some("todo".to_string()),
Some(Some(parent.id)),
Some(TaskSortBy::Priority),
None,
None,
)
.await
.unwrap();
assert_eq!(sorted.tasks.len(), 2);
assert_eq!(sorted.tasks[0].id, t1.id);
assert_eq!(sorted.tasks[1].id, t3.id);
let wm = Neo4jWorkspaceManager::new(graph.clone(), pid.clone());
let session_id = resolve_session_id(None);
wm.clear_current_task(Some(session_id)).await.unwrap();
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_pick_next_priority_order() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let empty = tm.pick_next().await.unwrap();
assert_eq!(empty.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
assert!(empty.task.is_none());
let parent = tm
.add_task(
"Parent".to_string(),
Some("spec".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
let child_a = tm
.add_task(
"Child A".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let child_b = tm
.add_task(
"Child B".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
tm.start_task(parent.id, false).await.unwrap();
let next = tm.pick_next().await.unwrap();
assert!(next.task.is_some());
let suggested = next.task.unwrap();
assert!(
suggested.id == child_a.id || suggested.id == child_b.id,
"Expected a child task, got #{}",
suggested.id
);
tm.update_task(
child_a.id,
TaskUpdate {
status: Some("done"),
..Default::default()
},
)
.await
.unwrap();
tm.update_task(
child_b.id,
TaskUpdate {
status: Some("done"),
..Default::default()
},
)
.await
.unwrap();
tm.done_task_by_id(parent.id, false).await.unwrap();
let all_done = tm.pick_next().await.unwrap();
assert_eq!(all_done.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
let wm = Neo4jWorkspaceManager::new(graph.clone(), pid.clone());
let session_id = resolve_session_id(None);
wm.clear_current_task(Some(session_id)).await.unwrap();
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_done_task_no_id_uses_focus() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let wm = Neo4jWorkspaceManager::new(graph.clone(), pid.clone());
let err = tm.done_task(false).await;
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("No current task") || msg.contains("no current"),
"Expected focus error, got: {}",
msg
);
let task = tm
.add_task(
"Focus Done".to_string(),
Some("will complete via focus".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
tm.start_task(task.id, false).await.unwrap();
let session_id = resolve_session_id(None);
let focus = wm.get_current_task(Some(session_id)).await.unwrap();
assert_eq!(focus.current_task_id, Some(task.id));
let result = tm.done_task(false).await.unwrap();
assert_eq!(result.completed_task.id, task.id);
assert_eq!(result.completed_task.status, "done");
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_delete_task_no_cascade() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let wm = Neo4jWorkspaceManager::new(graph.clone(), pid.clone());
let leaf = tm
.add_task("Leaf".to_string(), None, None, None, None, None)
.await
.unwrap();
tm.delete_task(leaf.id).await.unwrap();
assert!(tm.get_task(leaf.id).await.is_err());
let focused = tm
.add_task(
"Focused".to_string(),
Some("spec".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
tm.start_task(focused.id, false).await.unwrap();
let err = tm.delete_task(focused.id).await;
assert!(err.is_err());
let msg = err.unwrap_err().to_string();
assert!(
msg.contains("focused") || msg.contains("Unfocus"),
"Expected focus-protection error, got: {}",
msg
);
let session_id = resolve_session_id(None);
wm.clear_current_task(Some(session_id)).await.unwrap();
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_get_task_context_full() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let root = tm
.add_task("Root".to_string(), None, None, None, None, None)
.await
.unwrap();
let parent = tm
.add_task("Parent".to_string(), None, Some(root.id), None, None, None)
.await
.unwrap();
let target = tm
.add_task(
"Target".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let sibling = tm
.add_task(
"Sibling".to_string(),
None,
Some(parent.id),
None,
None,
None,
)
.await
.unwrap();
let child = tm
.add_task(
"Child of Target".to_string(),
None,
Some(target.id),
None,
None,
None,
)
.await
.unwrap();
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone());
let dep_req = PlanRequest {
tasks: vec![
TaskTree {
name: Some("Blocker".to_string()),
..Default::default()
},
TaskTree {
id: Some(target.id),
name: Some("Target".to_string()),
depends_on: Some(vec!["Blocker".to_string()]),
..Default::default()
},
],
};
let dep_result = pe.execute(&dep_req).await.unwrap();
assert!(
dep_result.success,
"Dependency plan failed: {:?}",
dep_result.error
);
let blocker_id = *dep_result.task_id_map.get("Blocker").unwrap();
let ctx = tm.get_task_context(target.id).await.unwrap();
assert_eq!(ctx.ancestors.len(), 2);
assert_eq!(ctx.ancestors[0].id, parent.id);
assert_eq!(ctx.ancestors[1].id, root.id);
assert_eq!(ctx.siblings.len(), 1);
assert_eq!(ctx.siblings[0].id, sibling.id);
assert_eq!(ctx.children.len(), 1);
assert_eq!(ctx.children[0].id, child.id);
assert_eq!(ctx.dependencies.blocking_tasks.len(), 1);
assert_eq!(ctx.dependencies.blocking_tasks[0].id, blocker_id);
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_get_task_with_events() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let em = Neo4jEventManager::new(graph.clone(), pid.clone());
let task = tm
.add_task("Event Rich".to_string(), None, None, None, None, None)
.await
.unwrap();
em.add_event(task.id, "decision".to_string(), "Chose Rust".to_string())
.await
.unwrap();
em.add_event(
task.id,
"blocker".to_string(),
"Waiting for review".to_string(),
)
.await
.unwrap();
em.add_event(task.id, "note".to_string(), "Progress update".to_string())
.await
.unwrap();
let twe = tm.get_task_with_events(task.id).await.unwrap();
assert_eq!(twe.task.id, task.id);
let summary = twe.events_summary.unwrap();
assert_eq!(summary.total_count, 3);
assert_eq!(summary.recent_events.len(), 3);
let types: Vec<&str> = summary
.recent_events
.iter()
.map(|e| e.log_type.as_str())
.collect();
assert!(types.contains(&"decision"));
assert!(types.contains(&"blocker"));
assert!(types.contains(&"note"));
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_blocking_and_blocked_by_tasks() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone());
let req = PlanRequest {
tasks: vec![
TaskTree {
name: Some("Foundation".to_string()),
..Default::default()
},
TaskTree {
name: Some("Building".to_string()),
depends_on: Some(vec!["Foundation".to_string()]),
..Default::default()
},
],
};
let result = pe.execute(&req).await.unwrap();
assert!(result.success);
let building_id = *result.task_id_map.get("Building").unwrap();
let foundation_id = *result.task_id_map.get("Foundation").unwrap();
let blocking = tm.get_blocking_tasks(building_id).await.unwrap();
assert_eq!(blocking.len(), 1);
assert_eq!(blocking[0].id, foundation_id);
let blocked_by = tm.get_blocked_by_tasks(foundation_id).await.unwrap();
assert_eq!(blocked_by.len(), 1);
assert_eq!(blocked_by[0].id, building_id);
let none = tm.get_blocking_tasks(foundation_id).await.unwrap();
assert!(none.is_empty());
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_get_root_tasks_ordering() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let r1 = tm
.add_task("Root Todo".to_string(), None, None, None, None, None)
.await
.unwrap();
let r2 = tm
.add_task(
"Root Doing".to_string(),
Some("spec".to_string()),
None,
None,
None,
None,
)
.await
.unwrap();
tm.start_task(r2.id, false).await.unwrap();
let child = tm
.add_task("Not Root".to_string(), None, Some(r1.id), None, None, None)
.await
.unwrap();
let roots = tm.get_root_tasks().await.unwrap();
assert!(
roots.iter().all(|t| t.parent_id.is_none()),
"All returned tasks should be root (no parent_id)"
);
assert!(roots.iter().any(|t| t.id == r1.id));
assert!(roots.iter().any(|t| t.id == r2.id));
assert!(!roots.iter().any(|t| t.id == child.id));
let doing_pos = roots.iter().position(|t| t.id == r2.id).unwrap();
let todo_pos = roots.iter().position(|t| t.id == r1.id).unwrap();
assert!(
doing_pos < todo_pos,
"doing root should be ordered before todo root"
);
let wm = Neo4jWorkspaceManager::new(graph.clone(), pid.clone());
let session_id = resolve_session_id(None);
wm.clear_current_task(Some(session_id)).await.unwrap();
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_get_status_full_response() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let em = Neo4jEventManager::new(graph.clone(), pid.clone());
let root = tm
.add_task("Status Root".to_string(), None, None, None, None, None)
.await
.unwrap();
let target = tm
.add_task(
"Status Target".to_string(),
Some("spec".to_string()),
Some(root.id),
None,
None,
None,
)
.await
.unwrap();
let _sibling = tm
.add_task(
"Status Sibling".to_string(),
None,
Some(root.id),
None,
None,
None,
)
.await
.unwrap();
let _desc = tm
.add_task(
"Descendant".to_string(),
None,
Some(target.id),
None,
None,
None,
)
.await
.unwrap();
em.add_event(target.id, "note".to_string(), "Testing status".to_string())
.await
.unwrap();
let status = tm.get_status(target.id, true).await.unwrap();
assert_eq!(status.focused_task.id, target.id);
assert_eq!(status.focused_task.name, "Status Target");
assert_eq!(status.ancestors.len(), 1);
assert_eq!(status.ancestors[0].id, root.id);
assert_eq!(status.siblings.len(), 1);
assert_eq!(status.siblings[0].id, _sibling.id);
assert_eq!(status.siblings[0].name, "Status Sibling");
assert_eq!(status.siblings[0].status, "todo");
assert_eq!(status.descendants.len(), 1);
assert_eq!(status.descendants[0].id, _desc.id);
assert_eq!(status.descendants[0].name, "Descendant");
let events = status
.events
.as_ref()
.expect("events should be Some when with_events=true");
assert_eq!(events.len(), 1);
assert_eq!(events[0].log_type, "note");
assert_eq!(events[0].discussion_data, "Testing status");
let status_no_events = tm.get_status(target.id, false).await.unwrap();
assert!(
status_no_events.events.is_none(),
"events should be None when with_events=false"
);
teardown(&graph, &pid).await;
}
#[tokio::test]
async fn neo4j_plan_with_default_parent() {
let (graph, pid) = setup().await;
let tm = Neo4jTaskManager::new(graph.clone(), pid.clone());
let parent = tm
.add_task("Default Parent".to_string(), None, None, None, None, None)
.await
.unwrap();
let pe = Neo4jPlanExecutor::new(graph.clone(), pid.clone()).with_default_parent(parent.id);
let request = PlanRequest {
tasks: vec![
TaskTree {
name: Some("Auto Child A".to_string()),
..Default::default()
},
TaskTree {
name: Some("Auto Child B".to_string()),
..Default::default()
},
],
};
let result = pe.execute(&request).await.unwrap();
assert!(result.success, "Plan failed: {:?}", result.error);
assert_eq!(result.created_count, 2);
let children = tm.get_children(parent.id).await.unwrap();
assert_eq!(children.len(), 2);
let names: Vec<&str> = children.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"Auto Child A"));
assert!(names.contains(&"Auto Child B"));
teardown(&graph, &pid).await;
}