story-kit: merge 129_story_test_coverage_http_mcp_rs

This commit is contained in:
Dave
2026-02-24 00:30:54 +00:00
parent 6c73e11af0
commit 5481f65e8b

View File

@@ -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<AppContext>) -> 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<E: poem::Endpoint>(
cli: &poem::test::TestClient<E>,
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<Value> = 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(&current_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<Value> = 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(&current_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<Value> = 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(&current_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<Value> = 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"));
}
}