From 5481f65e8b39ea6f0d90ec9b646f39c1d93d3091 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 24 Feb 2026 00:30:54 +0000 Subject: [PATCH] story-kit: merge 129_story_test_coverage_http_mcp_rs --- server/src/http/mcp.rs | 622 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 622 insertions(+) diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index ad135cf..4a25e9e 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -2394,4 +2394,626 @@ mod tests { assert!(parsed.get("gate_output").is_some()); assert!(parsed.get("message").is_some()); } + + // ── HTTP handler tests (TestClient) ─────────────────────────── + + fn test_mcp_app(ctx: std::sync::Arc) -> impl poem::Endpoint { + use poem::EndpointExt; + poem::Route::new() + .at("/mcp", poem::post(mcp_post_handler).get(mcp_get_handler)) + .data(ctx) + } + + async fn read_body_json(resp: poem::test::TestResponse) -> Value { + let body = resp.0.into_body().into_string().await.unwrap(); + serde_json::from_str(&body).unwrap() + } + + async fn post_json_mcp( + cli: &poem::test::TestClient, + payload: &str, + ) -> Value { + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .body(payload.to_string()) + .send() + .await; + read_body_json(resp).await + } + + #[tokio::test] + async fn mcp_get_handler_returns_405() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli.get("/mcp").send().await; + assert_eq!(resp.0.status(), poem::http::StatusCode::METHOD_NOT_ALLOWED); + } + + #[tokio::test] + async fn mcp_post_invalid_content_type_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "text/plain") + .body("{}") + .send() + .await; + let body = read_body_json(resp).await; + assert!(body.get("error").is_some(), "expected error field: {body}"); + } + + #[tokio::test] + async fn mcp_post_invalid_json_returns_parse_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .body("not-valid-json") + .send() + .await; + let body = read_body_json(resp).await; + assert!(body.get("error").is_some(), "expected error field: {body}"); + } + + #[tokio::test] + async fn mcp_post_wrong_jsonrpc_version_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{}}"#, + ) + .await; + assert!( + body["error"]["message"].as_str().unwrap_or("").contains("version"), + "expected version error: {body}" + ); + } + + #[tokio::test] + async fn mcp_post_notification_with_null_id_returns_accepted() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn mcp_post_notification_with_explicit_null_id_returns_accepted() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .body( + r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}"#, + ) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn mcp_post_missing_id_non_notification_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"2.0","method":"initialize","params":{}}"#, + ) + .await; + assert!(body.get("error").is_some(), "expected error: {body}"); + } + + #[tokio::test] + async fn mcp_post_unknown_method_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"2.0","id":1,"method":"bogus/method","params":{}}"#, + ) + .await; + assert!( + body["error"]["message"].as_str().unwrap_or("").contains("Unknown method"), + "expected unknown method error: {body}" + ); + } + + #[tokio::test] + async fn mcp_post_initialize_returns_capabilities() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#, + ) + .await; + assert_eq!(body["result"]["protocolVersion"], "2025-03-26"); + assert_eq!(body["result"]["serverInfo"]["name"], "story-kit"); + } + + #[tokio::test] + async fn mcp_post_tools_list_returns_tools() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#, + ) + .await; + assert!(body["result"]["tools"].is_array()); + } + + #[tokio::test] + async fn mcp_post_sse_returns_event_stream_content_type() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#) + .send() + .await; + assert_eq!( + resp.0.headers().get("content-type").unwrap(), + "text/event-stream" + ); + } + + #[tokio::test] + async fn mcp_post_sse_get_agent_output_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{}}}"#) + .send() + .await; + assert_eq!( + resp.0.headers().get("content-type").unwrap(), + "text/event-stream", + "expected SSE content-type" + ); + } + + #[tokio::test] + async fn mcp_post_sse_get_agent_output_missing_agent_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"1_test"}}}"#) + .send() + .await; + assert_eq!( + resp.0.headers().get("content-type").unwrap(), + "text/event-stream", + "expected SSE content-type" + ); + } + + #[tokio::test] + async fn mcp_post_sse_get_agent_output_no_agent_returns_sse_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"99_nope","agent_name":"bot"}}}"#) + .send() + .await; + assert_eq!( + resp.0.headers().get("content-type").unwrap(), + "text/event-stream" + ); + let body = resp.0.into_body().into_string().await.unwrap(); + assert!(body.contains("data:"), "expected SSE data prefix: {body}"); + } + + // ── tool_get_server_logs tests ──────────────────────────────── + + #[test] + fn tool_get_server_logs_no_args_returns_string() { + let result = tool_get_server_logs(&json!({})).unwrap(); + // Returns recent log lines (possibly empty in tests) — just verify no panic + let _ = result; + } + + #[test] + fn tool_get_server_logs_with_filter_returns_matching_lines() { + let result = + tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap(); + assert_eq!(result, "", "filter with no matches should return empty string"); + } + + #[test] + fn tool_get_server_logs_with_line_limit() { + let result = tool_get_server_logs(&json!({"lines": 5})).unwrap(); + assert!(result.lines().count() <= 5); + } + + #[test] + fn tool_get_server_logs_max_cap_is_1000() { + // Lines > 1000 are capped — just verify it returns without error + let result = tool_get_server_logs(&json!({"lines": 9999})).unwrap(); + let _ = result; + } + + // ── tool_list_worktrees tests ───────────────────────────────── + + #[test] + fn tool_list_worktrees_empty_dir() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_list_worktrees(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + assert!(parsed.is_empty()); + } + + // ── tool_accept_story tests ─────────────────────────────────── + + #[test] + fn tool_accept_story_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_accept_story(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[test] + fn tool_accept_story_nonexistent_story_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + let ctx = test_ctx(tmp.path()); + // No story file in current/ — should fail + let result = tool_accept_story(&json!({"story_id": "99_nonexistent"}), &ctx); + assert!(result.is_err()); + } + + // ── tool_check_criterion tests ──────────────────────────────── + + #[test] + fn tool_check_criterion_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_check_criterion(&json!({"criterion_index": 0}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[test] + fn tool_check_criterion_missing_criterion_index() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_check_criterion(&json!({"story_id": "1_test"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("criterion_index")); + } + + #[test] + fn tool_check_criterion_marks_unchecked_item() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + let current_dir = tmp.path().join(".story_kit").join("work").join("2_current"); + fs::create_dir_all(¤t_dir).unwrap(); + fs::write( + current_dir.join("1_test.md"), + "---\nname: Test\n---\n## AC\n- [ ] First criterion\n- [x] Already done\n", + ) + .unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add story"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_check_criterion( + &json!({"story_id": "1_test", "criterion_index": 0}), + &ctx, + ); + assert!(result.is_ok(), "Expected ok: {result:?}"); + assert!(result.unwrap().contains("Criterion 0 checked")); + } + + // ── tool_get_agent_config tests ─────────────────────────────── + + #[test] + fn tool_get_agent_config_no_project_toml_returns_default_agent() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + // No project.toml → default config with one fallback agent + let result = tool_get_agent_config(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + // Default config contains one agent entry with default values + assert_eq!(parsed.len(), 1, "default config should have one fallback agent"); + assert!(parsed[0].get("name").is_some()); + assert!(parsed[0].get("role").is_some()); + } + + // ── tool_get_agent_output_poll tests ───────────────────────── + + #[tokio::test] + async fn tool_get_agent_output_poll_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_agent_output_poll(&json!({"agent_name": "bot"}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[tokio::test] + async fn tool_get_agent_output_poll_missing_agent_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = + tool_get_agent_output_poll(&json!({"story_id": "1_test"}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("agent_name")); + } + + #[tokio::test] + async fn tool_get_agent_output_poll_no_agent_falls_back_to_empty_log() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + // No agent registered, no log file → returns empty response from log fallback + let result = tool_get_agent_output_poll( + &json!({"story_id": "99_nope", "agent_name": "bot"}), + &ctx, + ) + .await + .unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["done"], true); + assert_eq!(parsed["event_count"], 0); + assert!( + parsed["message"].as_str().unwrap_or("").contains("No agent"), + "expected 'No agent' message: {parsed}" + ); + } + + #[tokio::test] + async fn tool_get_agent_output_poll_with_running_agent_returns_empty_events() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + // Inject a running agent — no events broadcast yet + ctx.agents + .inject_test_agent("10_story", "worker", crate::agents::AgentStatus::Running); + let result = tool_get_agent_output_poll( + &json!({"story_id": "10_story", "agent_name": "worker"}), + &ctx, + ) + .await + .unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["done"], false); + assert_eq!(parsed["event_count"], 0); + assert!(parsed["events"].is_array()); + } + + // ── Missing-arg tests for async tools ──────────────────────── + + #[tokio::test] + async fn tool_stop_agent_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_stop_agent(&json!({"agent_name": "bot"}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[tokio::test] + async fn tool_stop_agent_missing_agent_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_stop_agent(&json!({"story_id": "1_test"}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("agent_name")); + } + + #[tokio::test] + async fn tool_start_agent_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_start_agent(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[tokio::test] + async fn tool_create_worktree_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_worktree(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[tokio::test] + async fn tool_remove_worktree_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_remove_worktree(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[tokio::test] + async fn tool_request_qa_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_request_qa(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + // ── parse_test_cases additional coverage ───────────────────── + + #[test] + fn parse_test_cases_null_value_returns_empty() { + let null_val = json!(null); + let result = parse_test_cases(Some(&null_val)).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn parse_test_cases_non_array_returns_error() { + let obj = json!({"invalid": "input"}); + let result = parse_test_cases(Some(&obj)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Expected array")); + } + + #[test] + fn parse_test_cases_missing_name_returns_error() { + let input = json!([{"status": "pass"}]); + let result = parse_test_cases(Some(&input)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); + } + + #[test] + fn parse_test_cases_missing_status_returns_error() { + let input = json!([{"name": "test1"}]); + let result = parse_test_cases(Some(&input)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("status")); + } + + // ── json_rpc_error_response direct test ────────────────────── + + #[test] + fn json_rpc_error_response_builds_json_response() { + let resp = json_rpc_error_response(Some(json!(42)), -32600, "test error".into()); + assert_eq!(resp.status(), poem::http::StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); + } + + // ── request_qa in tools list ────────────────────────────────── + + #[test] + fn request_qa_in_tools_list() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "request_qa"); + assert!(tool.is_some(), "request_qa missing from tools list"); + let t = tool.unwrap(); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"story_id")); + // agent_name is optional + assert!(!req_names.contains(&"agent_name")); + } + + // ── tool_validate_stories with file content ─────────────────── + + #[test] + fn tool_validate_stories_with_valid_story() { + let tmp = tempfile::tempdir().unwrap(); + let current_dir = tmp.path().join(".story_kit").join("work").join("2_current"); + fs::create_dir_all(¤t_dir).unwrap(); + fs::write( + current_dir.join("1_test.md"), + "---\nname: \"Valid Story\"\n---\n## AC\n- [ ] First\n", + ) + .unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_validate_stories(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0]["valid"], true); + } + + #[test] + fn tool_validate_stories_with_invalid_front_matter() { + let tmp = tempfile::tempdir().unwrap(); + let current_dir = tmp.path().join(".story_kit").join("work").join("2_current"); + fs::create_dir_all(¤t_dir).unwrap(); + fs::write( + current_dir.join("1_test.md"), + "## No front matter at all\n", + ) + .unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_validate_stories(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + assert!(!parsed.is_empty()); + assert_eq!(parsed[0]["valid"], false); + } + + // ── tool_record_tests and tool_ensure_acceptance edge cases ── + + #[test] + fn tool_record_tests_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_record_tests( + &json!({"unit": [], "integration": []}), + &ctx, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[test] + fn tool_record_tests_invalid_unit_type_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_record_tests( + &json!({ + "story_id": "1_test", + "unit": "not_an_array", + "integration": [] + }), + &ctx, + ); + assert!(result.is_err()); + } + + #[test] + fn tool_ensure_acceptance_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_ensure_acceptance(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } }