use serde_json::{json, Value};
use things3_cli::mcp::test_harness::McpTestHarness;
use uuid::Uuid;
fn create_harness() -> McpTestHarness {
McpTestHarness::new()
}
fn parse_tool_result(result: &things3_cli::mcp::CallToolResult) -> Value {
if result.is_error {
return json!({"error": "Tool call failed"});
}
match &result.content[0] {
things3_cli::mcp::Content::Text { text } => {
serde_json::from_str(text).unwrap_or(json!({"text": text}))
}
}
}
async fn create_task_via_mcp(harness: &McpTestHarness) -> String {
let result = harness
.call_tool(
"create_task",
Some(json!({
"title": "Test Task",
"notes": "Test notes"
})),
)
.await;
let response = parse_tool_result(&result);
response["uuid"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn test_complete_task_tool() {
let harness = create_harness();
let uuid = create_task_via_mcp(&harness).await;
let result = harness
.call_tool(
"complete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let response = parse_tool_result(&result);
assert!(
response.get("message").is_some(),
"Response should contain message"
);
assert_eq!(
response["message"], "Task completed successfully",
"Should return success message"
);
assert_eq!(response["uuid"], uuid, "Should return the task UUID");
}
#[tokio::test]
async fn test_complete_task_tool_response_format() {
let harness = create_harness();
let uuid = create_task_via_mcp(&harness).await;
let result = harness
.call_tool(
"complete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let response = parse_tool_result(&result);
assert!(response.is_object(), "Response should be a JSON object");
assert!(response.get("message").is_some());
assert!(response.get("uuid").is_some());
assert!(!response["message"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn test_uncomplete_task_tool() {
let harness = create_harness();
let uuid = create_task_via_mcp(&harness).await;
harness
.call_tool(
"complete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let result = harness
.call_tool(
"uncomplete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let response = parse_tool_result(&result);
assert!(
response.get("message").is_some(),
"Response should contain message"
);
assert_eq!(
response["message"], "Task marked as incomplete successfully",
"Should return success message"
);
}
#[tokio::test]
async fn test_delete_task_tool_error_mode() {
let harness = create_harness();
let parent_result = harness
.call_tool(
"create_task",
Some(json!({
"title": "Parent Task"
})),
)
.await;
let parent_response = parse_tool_result(&parent_result);
let parent_uuid = parent_response["uuid"].as_str().unwrap();
harness
.call_tool(
"create_task",
Some(json!({
"title": "Child Task",
"parent_uuid": parent_uuid
})),
)
.await;
let delete_response = harness
.call_tool_with_fallback(
"delete_task",
Some(json!({
"uuid": parent_uuid,
"child_handling": "error"
})),
)
.await;
assert!(
delete_response.is_error,
"Should fail when parent has children in error mode"
);
}
#[tokio::test]
async fn test_delete_task_tool_cascade_mode() {
let harness = create_harness();
let parent_result = harness
.call_tool(
"create_task",
Some(json!({
"title": "Parent Task"
})),
)
.await;
let parent_response = parse_tool_result(&parent_result);
let parent_uuid = parent_response["uuid"].as_str().unwrap();
let child_result = harness
.call_tool(
"create_task",
Some(json!({
"title": "Child Task",
"parent_uuid": parent_uuid
})),
)
.await;
let child_response = parse_tool_result(&child_result);
let child_uuid = child_response["uuid"].as_str().unwrap();
let delete_result = harness
.call_tool(
"delete_task",
Some(json!({
"uuid": parent_uuid,
"child_handling": "cascade"
})),
)
.await;
let delete_response = parse_tool_result(&delete_result);
assert_eq!(
delete_response["message"], "Task deleted successfully",
"Should successfully delete with cascade"
);
let search_result = harness
.call_tool(
"search_tasks",
Some(json!({
"query": parent_uuid
})),
)
.await;
let search_results = parse_tool_result(&search_result);
assert!(
search_results["tasks"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or(&[])
.is_empty(),
"Parent should be deleted"
);
let child_search_result = harness
.call_tool(
"search_tasks",
Some(json!({
"query": child_uuid
})),
)
.await;
let child_search = parse_tool_result(&child_search_result);
assert!(
child_search["tasks"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or(&[])
.is_empty(),
"Child should be deleted in cascade mode"
);
}
#[tokio::test]
async fn test_delete_task_tool_orphan_mode() {
let harness = create_harness();
let parent_result = harness
.call_tool(
"create_task",
Some(json!({
"title": "Parent Task For Orphan Test"
})),
)
.await;
let parent_response = parse_tool_result(&parent_result);
let parent_uuid = parent_response["uuid"].as_str().unwrap();
harness
.call_tool(
"create_task",
Some(json!({
"title": "Child Task For Orphan Test",
"parent_uuid": parent_uuid
})),
)
.await;
let delete_result = harness
.call_tool(
"delete_task",
Some(json!({
"uuid": parent_uuid,
"child_handling": "orphan"
})),
)
.await;
let delete_response = parse_tool_result(&delete_result);
assert_eq!(
delete_response["message"], "Task deleted successfully",
"Should successfully delete parent with orphan mode even when children exist"
);
}
#[tokio::test]
async fn test_complete_task_invalid_uuid() {
let harness = create_harness();
let response = harness
.call_tool_with_fallback(
"complete_task",
Some(json!({
"uuid": "not-a-valid-uuid"
})),
)
.await;
assert!(response.is_error, "Should return error for invalid UUID");
}
#[tokio::test]
async fn test_delete_task_missing_uuid() {
let harness = create_harness();
let response = harness
.call_tool_with_fallback("delete_task", Some(json!({})))
.await;
assert!(response.is_error, "Should return error for missing UUID");
}
#[tokio::test]
async fn test_delete_task_invalid_child_handling() {
let harness = create_harness();
let uuid = create_task_via_mcp(&harness).await;
let result = harness
.call_tool(
"delete_task",
Some(json!({
"uuid": uuid,
"child_handling": "invalid_mode"
})),
)
.await;
let response = parse_tool_result(&result);
assert_eq!(
response["message"], "Task deleted successfully",
"Should default to error mode for invalid child_handling"
);
}
#[tokio::test]
async fn test_lifecycle_e2e_flow() {
let harness = create_harness();
let create_result = harness
.call_tool(
"create_task",
Some(json!({
"title": "E2E Lifecycle Task",
"notes": "Testing full lifecycle"
})),
)
.await;
let create_response = parse_tool_result(&create_result);
let uuid = create_response["uuid"].as_str().unwrap().to_string();
let update_result = harness
.call_tool(
"update_task",
Some(json!({
"uuid": uuid,
"notes": "Updated notes"
})),
)
.await;
let update_response = parse_tool_result(&update_result);
assert_eq!(update_response["message"], "Task updated successfully");
let complete_result = harness
.call_tool(
"complete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let complete_response = parse_tool_result(&complete_result);
assert_eq!(complete_response["message"], "Task completed successfully");
let uncomplete_result = harness
.call_tool(
"uncomplete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let uncomplete_response = parse_tool_result(&uncomplete_result);
assert_eq!(
uncomplete_response["message"],
"Task marked as incomplete successfully"
);
let delete_result = harness
.call_tool(
"delete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let delete_response = parse_tool_result(&delete_result);
assert_eq!(delete_response["message"], "Task deleted successfully");
let search_result = harness
.call_tool(
"search_tasks",
Some(json!({
"query": uuid
})),
)
.await;
let search_response = parse_tool_result(&search_result);
assert!(search_response["tasks"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or(&[])
.is_empty());
}
#[tokio::test]
async fn test_task_not_in_inbox_after_completion() {
let harness = create_harness();
let uuid = create_task_via_mcp(&harness).await;
let inbox_before_result = harness.call_tool("get_inbox", None).await;
let inbox_before = parse_tool_result(&inbox_before_result);
let tasks_before: Vec<Value> = inbox_before["tasks"]
.as_array()
.unwrap_or(&Vec::new())
.clone();
harness
.call_tool(
"complete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let inbox_after_result = harness.call_tool("get_inbox", None).await;
let inbox_after = parse_tool_result(&inbox_after_result);
let tasks_after: Vec<Value> = inbox_after["tasks"]
.as_array()
.unwrap_or(&Vec::new())
.clone();
assert!(
tasks_after.len() <= tasks_before.len(),
"Inbox should have same or fewer tasks after completion"
);
let found_in_inbox = tasks_after
.iter()
.any(|t| t["uuid"].as_str() == Some(&uuid));
assert!(!found_in_inbox, "Completed task should not appear in inbox");
}
#[tokio::test]
async fn test_task_not_in_queries_after_deletion() {
let harness = create_harness();
let unique_title = format!("Unique Task {}", Uuid::new_v4());
let create_result = harness
.call_tool(
"create_task",
Some(json!({
"title": unique_title
})),
)
.await;
let create_response = parse_tool_result(&create_result);
let uuid = create_response["uuid"].as_str().unwrap().to_string();
let delete_result = harness
.call_tool(
"delete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let delete_response = parse_tool_result(&delete_result);
assert_eq!(
delete_response["message"], "Task deleted successfully",
"Delete operation should succeed"
);
let search_after_result = harness
.call_tool(
"search_tasks",
Some(json!({
"query": unique_title
})),
)
.await;
let search_after = parse_tool_result(&search_after_result);
assert!(
search_after["tasks"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or(&[])
.is_empty(),
"Deleted task should not appear in search results"
);
let inbox_result = harness.call_tool("get_inbox", None).await;
let inbox = parse_tool_result(&inbox_result);
let found_in_inbox = inbox["tasks"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or(&[])
.iter()
.any(|t| t["uuid"].as_str() == Some(&uuid));
assert!(!found_in_inbox, "Deleted task should not appear in inbox");
}
#[tokio::test]
async fn test_mcp_error_propagation() {
let harness = create_harness();
let nonexistent_uuid = Uuid::new_v4().to_string();
let response = harness
.call_tool_with_fallback(
"complete_task",
Some(json!({
"uuid": nonexistent_uuid
})),
)
.await;
assert!(
response.is_error,
"Should propagate error for nonexistent task"
);
}
#[tokio::test]
async fn test_mcp_validation_errors() {
let harness = create_harness();
let response1 = harness
.call_tool_with_fallback("complete_task", Some(json!({})))
.await;
assert!(response1.is_error, "Should return error for missing uuid");
let response2 = harness
.call_tool_with_fallback(
"complete_task",
Some(json!({
"uuid": "not-a-uuid"
})),
)
.await;
assert!(
response2.is_error,
"Should return error for invalid UUID format"
);
}
#[tokio::test]
async fn test_mcp_concurrent_calls() {
let harness = create_harness();
let mut uuids = Vec::new();
for i in 0..5 {
let result = harness
.call_tool(
"create_task",
Some(json!({
"title": format!("Concurrent Task {}", i)
})),
)
.await;
let response = parse_tool_result(&result);
uuids.push(response["uuid"].as_str().unwrap().to_string());
}
for uuid in uuids {
let result = harness
.call_tool(
"complete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let response = parse_tool_result(&result);
assert_eq!(response["message"], "Task completed successfully");
}
}
#[tokio::test]
async fn test_complete_task_appears_in_logbook() {
let harness = create_harness();
let uuid = create_task_via_mcp(&harness).await;
harness
.call_tool(
"complete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let search_result = harness
.call_tool(
"search_tasks",
Some(json!({
"query": uuid
})),
)
.await;
let search_response = parse_tool_result(&search_result);
let tasks = search_response["tasks"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or(&[]);
if !tasks.is_empty() {
assert_eq!(
tasks[0]["status"], "completed",
"Task should have completed status"
);
}
}
#[tokio::test]
async fn test_update_then_complete() {
let harness = create_harness();
let create_result = harness
.call_tool(
"create_task",
Some(json!({
"title": "Task to Update and Complete"
})),
)
.await;
let create_response = parse_tool_result(&create_result);
let uuid = create_response["uuid"].as_str().unwrap();
let update_result = harness
.call_tool(
"update_task",
Some(json!({
"uuid": uuid,
"notes": "Updated before completion",
"tags": ["important", "urgent"]
})),
)
.await;
let update_response = parse_tool_result(&update_result);
assert_eq!(update_response["message"], "Task updated successfully");
let complete_result = harness
.call_tool(
"complete_task",
Some(json!({
"uuid": uuid
})),
)
.await;
let complete_response = parse_tool_result(&complete_result);
assert_eq!(complete_response["message"], "Task completed successfully");
let search_result = harness
.call_tool(
"search_tasks",
Some(json!({
"query": uuid
})),
)
.await;
let search_response = parse_tool_result(&search_result);
let tasks = search_response["tasks"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or(&[]);
if !tasks.is_empty() {
let task = &tasks[0];
assert_eq!(task["status"], "completed");
if let Some(notes) = task["notes"].as_str() {
assert_eq!(notes, "Updated before completion");
}
}
}
#[tokio::test]
async fn test_search_excludes_deleted_tasks() {
let harness = create_harness();
let task_title = "ExcludeDeletedTestTask";
let create_result = harness
.call_tool(
"create_task",
Some(json!({
"title": task_title
})),
)
.await;
let create_response = parse_tool_result(&create_result);
let task_uuid = create_response["uuid"].as_str().unwrap().to_string();
let delete_result = harness
.call_tool(
"delete_task",
Some(json!({
"uuid": task_uuid
})),
)
.await;
let delete_response = parse_tool_result(&delete_result);
assert_eq!(
delete_response["message"], "Task deleted successfully",
"Delete operation should succeed"
);
let search_result = harness
.call_tool(
"search_tasks",
Some(json!({
"query": task_title
})),
)
.await;
let search_response = parse_tool_result(&search_result);
let tasks = search_response["tasks"]
.as_array()
.map(|a| a.as_slice())
.unwrap_or(&[]);
let found_deleted = tasks.iter().any(|t| t["uuid"].as_str() == Some(&task_uuid));
assert!(
!found_deleted,
"Deleted task should not appear in search results"
);
}