#[cfg(feature = "http")]
#[path = "http_common/mod.rs"]
mod common;
#[cfg(feature = "http")]
mod http_tests {
use axum::body::Body;
use http_body_util::BodyExt;
use recursive::http::{
build_router, build_router_with_auth, build_router_with_auth_and_rate_limit,
map_agent_event, AppState, AuthConfig, JwtConfig, Metrics, RateLimiter, SessionState,
SseEvent, ToolInfo,
};
use recursive::llm::{Completion, MockProvider};
use recursive::runtime::AgentRuntimeBuilder;
use recursive::tools::ToolRegistry;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{broadcast, RwLock};
use tower::ServiceExt;
use crate::common::{mock_config, sample_state, sample_state_with_provider};
#[tokio::test]
async fn health_returns_ok() {
let app = build_router(sample_state());
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"ok");
}
#[tokio::test]
async fn tools_returns_json_array() {
let app = build_router(sample_state());
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
let tools: Vec<ToolInfo> = serde_json::from_slice(&body).unwrap();
assert_eq!(tools.len(), 2);
}
#[tokio::test]
async fn tools_contains_expected_names() {
let app = build_router(sample_state());
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let tools: Vec<ToolInfo> = serde_json::from_slice(&body).unwrap();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"Read"));
assert!(names.contains(&"Write"));
}
#[tokio::test]
async fn tools_empty_state_returns_empty_array() {
let provider = Arc::new(MockProvider::new(vec![]));
let app = build_router(AppState {
tools: vec![],
config: mock_config(),
tool_registry: ToolRegistry::local(),
provider,
sessions: Arc::new(RwLock::new(HashMap::new())),
event_channels: Arc::new(RwLock::new(HashMap::new())),
metrics: Arc::new(Metrics::default()),
slash_commands: Arc::new(Vec::new()),
session_ttl_secs: 0,
run_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(8)),
rate_limiter: RateLimiter::new(10, 1.0),
skills: vec![],
});
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
let tools: Vec<ToolInfo> = serde_json::from_slice(&body).unwrap();
assert!(tools.is_empty());
}
#[tokio::test]
async fn run_with_mock_provider_returns_200() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "I completed the task.".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: Some(recursive::llm::TokenUsage {
reasoning_tokens: 0,
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
}),
reasoning_content: None,
}]));
let state = AppState {
tools: vec![],
config: mock_config(),
tool_registry: ToolRegistry::local(),
provider,
sessions: Arc::new(RwLock::new(HashMap::new())),
event_channels: Arc::new(RwLock::new(HashMap::new())),
metrics: Arc::new(Metrics::default()),
slash_commands: Arc::new(Vec::new()),
session_ttl_secs: 0,
run_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(8)),
rate_limiter: RateLimiter::new(10, 1.0),
skills: vec![],
};
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"goal": "Say hello"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(resp["status"], "success");
assert!(resp["finish_reason"]
.as_str()
.unwrap()
.contains("no_more_tool_calls"));
assert!(resp["messages"].is_array());
assert!(!resp["messages"].as_array().unwrap().is_empty());
assert_eq!(resp["usage"]["total_steps"], 1);
assert_eq!(resp["usage"]["total_tokens"], 15);
}
#[tokio::test]
async fn run_with_missing_goal_returns_400() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"goal": ""
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 400);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
resp.get("status").is_none(),
"ApiError envelope must NOT include a top-level `status` field, got: {resp}"
);
assert!(
resp.get("error").is_some(),
"ApiError envelope must include an `error` field, got: {resp}"
);
assert!(resp["error"].as_str().unwrap().contains("goal"));
}
#[tokio::test]
async fn run_returns_403_on_permission_denied() {
use recursive::error::Error;
use recursive::permissions::{DecisionReason, PermissionMode};
let provider =
Arc::new(
MockProvider::new(vec![]).with_errors(vec![Error::PermissionDenied {
name: "Bash".into(),
reason: DecisionReason::Mode(PermissionMode::DontAsk),
}]),
);
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({ "goal": "test" })).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 403);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
resp["error"]
.as_str()
.unwrap()
.contains("permission denied"),
"403 body should explain the denial, got: {resp}"
);
}
#[tokio::test]
async fn run_returns_429_with_retry_after_on_rate_limited() {
use recursive::error::Error;
let provider = Arc::new(
MockProvider::new(vec![]).with_errors(vec![Error::RateLimited {
provider: "mock".into(),
retry_after_ms: 1234,
}]),
);
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({ "goal": "test" })).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 429);
let retry_after = response
.headers()
.get(axum::http::header::RETRY_AFTER)
.expect("429 must carry a Retry-After header");
assert_eq!(retry_after, "1", "1234ms must floor to Retry-After: 1");
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
resp["error"].as_str().unwrap().contains("rate limited"),
"429 body should explain the rate limit, got: {resp}"
);
}
#[tokio::test]
async fn run_returns_400_on_bad_tool_args() {
use recursive::error::Error;
let provider = Arc::new(
MockProvider::new(vec![]).with_errors(vec![Error::BadToolArgs {
name: "Read".into(),
message: "missing path".into(),
}]),
);
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({ "goal": "test" })).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 400);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
resp["error"]
.as_str()
.unwrap()
.contains("bad tool arguments"),
"400 body should explain the bad arguments, got: {resp}"
);
}
#[tokio::test]
async fn run_returns_500_on_internal_llm_error() {
use recursive::error::Error;
let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![Error::Llm {
provider: "mock".into(),
message: "upstream 5xx".into(),
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({ "goal": "test" })).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 500);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
resp["error"].as_str().unwrap().contains("LLM error"),
"500 body should expose the LLM failure, got: {resp}"
);
}
#[tokio::test]
async fn run_response_has_expected_fields() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "Done.".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: Some(recursive::llm::TokenUsage {
reasoning_tokens: 0,
prompt_tokens: 20,
completion_tokens: 10,
total_tokens: 30,
cache_hit_tokens: 0,
cache_miss_tokens: 0,
}),
reasoning_content: None,
}]));
let state = AppState {
tools: vec![],
config: mock_config(),
tool_registry: ToolRegistry::local(),
provider,
sessions: Arc::new(RwLock::new(HashMap::new())),
event_channels: Arc::new(RwLock::new(HashMap::new())),
metrics: Arc::new(Metrics::default()),
slash_commands: Arc::new(Vec::new()),
session_ttl_secs: 0,
run_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(8)),
rate_limiter: RateLimiter::new(10, 1.0),
skills: vec![],
};
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"goal": "Test goal",
"max_steps": 5,
"system_prompt": "You are a terse assistant."
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(resp.get("status").is_some(), "missing 'status' field");
assert!(
resp.get("finish_reason").is_some(),
"missing 'finish_reason' field"
);
assert!(resp.get("messages").is_some(), "missing 'messages' field");
assert!(resp.get("usage").is_some(), "missing 'usage' field");
let usage = &resp["usage"];
assert!(
usage.get("total_steps").is_some(),
"missing 'usage.total_steps'"
);
assert!(
usage.get("total_tokens").is_some(),
"missing 'usage.total_tokens'"
);
assert_eq!(resp["status"], "success");
assert_eq!(usage["total_steps"], 1);
assert_eq!(usage["total_tokens"], 30);
}
#[tokio::test]
async fn run_with_no_goal_field_returns_422() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = AppState {
tools: vec![],
config: mock_config(),
tool_registry: ToolRegistry::local(),
provider,
sessions: Arc::new(RwLock::new(HashMap::new())),
event_channels: Arc::new(RwLock::new(HashMap::new())),
metrics: Arc::new(Metrics::default()),
slash_commands: Arc::new(Vec::new()),
session_ttl_secs: 0,
run_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(8)),
rate_limiter: RateLimiter::new(10, 1.0),
skills: vec![],
};
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"max_steps": 5
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 422);
}
#[tokio::test]
async fn run_with_custom_max_steps_respected() {
use recursive::llm::ToolCall;
let provider = Arc::new(MockProvider::new(vec![
Completion {
content: "step 1".into(),
tool_calls: vec![ToolCall {
id: "c1".into(),
name: "unknown".into(),
arguments: serde_json::json!({}),
}],
finish_reason: Some("tool_calls".into()),
usage: None,
reasoning_content: None,
},
Completion {
content: "step 2".into(),
tool_calls: vec![ToolCall {
id: "c2".into(),
name: "unknown".into(),
arguments: serde_json::json!({}),
}],
finish_reason: Some("tool_calls".into()),
usage: None,
reasoning_content: None,
},
Completion {
content: "step 3".into(),
tool_calls: vec![ToolCall {
id: "c3".into(),
name: "unknown".into(),
arguments: serde_json::json!({}),
}],
finish_reason: Some("tool_calls".into()),
usage: None,
reasoning_content: None,
},
]));
let state = AppState {
tools: vec![],
config: mock_config(),
tool_registry: ToolRegistry::local(),
provider,
sessions: Arc::new(RwLock::new(HashMap::new())),
event_channels: Arc::new(RwLock::new(HashMap::new())),
metrics: Arc::new(Metrics::default()),
slash_commands: Arc::new(Vec::new()),
session_ttl_secs: 0,
run_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(8)),
rate_limiter: RateLimiter::new(10, 1.0),
skills: vec![],
};
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"goal": "loop forever",
"max_steps": 2
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(resp["status"], "success");
assert!(resp["finish_reason"]
.as_str()
.unwrap()
.contains("budget_exceeded"));
assert_eq!(resp["usage"]["total_steps"], 2);
}
#[tokio::test]
async fn post_sessions_creates_session() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 201);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(resp["id"].is_string());
assert!(!resp["id"].as_str().unwrap().is_empty());
assert!(resp["created_at"].is_string());
assert!(resp["created_at"].as_str().unwrap().contains('T'));
}
#[tokio::test]
async fn get_sessions_lists_sessions() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 201);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(resp["total"], 1);
let sessions = resp["sessions"]
.as_array()
.expect("sessions should be an array");
assert_eq!(sessions.len(), 1);
assert!(sessions[0]["id"].is_string());
assert_eq!(sessions[0]["message_count"], 0);
}
#[tokio::test]
async fn post_session_messages_returns_assistant_response() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "Hello! How can I help?".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let create_resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = create_resp["id"].as_str().unwrap();
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{}/messages", session_id))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"content": "Hi there"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(resp["role"], "assistant");
assert_eq!(resp["content"], "Hello! How can I help?");
}
#[tokio::test]
async fn get_session_returns_session_with_messages() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "I'm here to help.".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"system_prompt": "Be helpful."
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let create_resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = create_resp["id"].as_str().unwrap();
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{}/messages", session_id))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"content": "Hello"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{}", session_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(resp["id"], session_id);
assert!(resp["created_at"].is_string());
assert!(resp["messages"].is_array());
assert!(resp["messages"].as_array().unwrap().len() >= 3);
}
#[tokio::test]
async fn delete_session_removes_it() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let create_resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = create_resp["id"].as_str().unwrap();
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("DELETE")
.uri(format!("/sessions/{}", session_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 204);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{}", session_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404);
}
#[tokio::test]
async fn delete_session_cleans_up_event_channels() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "Hello!".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let create_resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = create_resp["id"].as_str().unwrap().to_string();
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{}/messages", session_id))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"content": "Hi"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
assert!(
state.event_channels.read().await.contains_key(&session_id),
"event channel entry must exist after sending a message"
);
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("DELETE")
.uri(format!("/sessions/{}", session_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 204);
assert!(
!state.event_channels.read().await.contains_key(&session_id),
"event channel entry must be removed after session deletion"
);
}
#[tokio::test]
async fn post_message_to_nonexistent_session_returns_404() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/nonexistent-id/messages")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"content": "Hello?"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404);
}
#[tokio::test]
async fn patch_session_returns_actual_message_count() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "hi".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 201);
let body = response.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{session_id}/messages"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"content": "hi"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200, "send_message must succeed");
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("PATCH")
.uri(format!("/sessions/{session_id}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"rename me"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200, "PATCH must succeed");
let body = response.into_body().collect().await.unwrap().to_bytes();
let patched: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
patched["message_count"], 2,
"patch_session must return the real non-system message count, got: {patched}"
);
assert_eq!(
patched["title"], "rename me",
"title must reflect the patched value, got: {patched}"
);
assert_eq!(
patched["id"], session_id,
"id must round-trip, got: {patched}"
);
}
#[tokio::test]
async fn session_events_returns_sse_content_type() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let create_resp: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = create_resp["id"].as_str().unwrap();
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{}/events", session_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let content_type = response
.headers()
.get("content-type")
.expect("content-type header missing")
.to_str()
.unwrap();
assert!(
content_type.contains("text/event-stream"),
"Expected text/event-stream, got: {}",
content_type
);
}
#[tokio::test]
async fn session_events_nonexistent_returns_404() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions/nonexistent-id/events")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404);
}
#[tokio::test]
async fn api_error_sites_return_json_error_body() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/sessions/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404, "GET missing session must 404");
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("error").is_some(),
"404 body must have an \"error\" key, got: {body}"
);
assert!(
body["error"].as_str().is_some(),
"404 body \"error\" must be a string, got: {body}"
);
let error_msg = body["error"].as_str().unwrap();
assert!(
error_msg.contains("session") && error_msg.contains("not found"),
"404 error message should mention session not found, got: {error_msg}"
);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("DELETE")
.uri("/sessions/nonexistent-delete")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404, "DELETE missing session must 404");
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("error").is_some(),
"DELETE 404 body must have \"error\" key, got: {body}"
);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("PATCH")
.uri("/sessions/nonexistent-patch")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"x"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404, "PATCH missing session must 404");
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("error").is_some(),
"PATCH 404 body must have \"error\" key, got: {body}"
);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/nonexistent-fork/fork")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404, "FORK missing session must 404");
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("error").is_some(),
"FORK 404 body must have \"error\" key, got: {body}"
);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/sessions/nonexistent-events/events")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404, "EVENTS missing session must 404");
let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("error").is_some(),
"EVENTS 404 body must have \"error\" key, got: {body}"
);
}
#[tokio::test]
async fn goal_304_run_sessions_sessions_messages_emit_api_error_envelope() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "ok".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/run")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"goal": ""})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 400, "POST /run with empty goal must 400");
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("400 body must be valid JSON");
assert!(
body.get("status").is_none(),
"POST /run ApiError envelope must NOT include a `status` field, got: {body}"
);
assert!(
body.get("error").is_some(),
"POST /run ApiError envelope must include an `error` field, got: {body}"
);
assert!(
body["error"].as_str().unwrap().contains("goal"),
"POST /run error message should mention `goal`, got: {body}"
);
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 201, "POST /sessions must 201 on success");
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("201 body must be valid JSON");
assert!(
body.get("error").is_none(),
"POST /sessions success body must NOT include an `error` field, got: {body}"
);
assert!(
body.get("id").is_some(),
"POST /sessions success body must include `id`, got: {body}"
);
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/no-such-session-id/messages")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"content": "hi"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
404,
"POST /sessions/:id/messages with missing session must 404"
);
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("status").is_none(),
"POST /sessions/:id/messages ApiError envelope must NOT include a `status` field, got: {body}"
);
assert!(
body.get("error").is_some(),
"POST /sessions/:id/messages ApiError envelope must include an `error` field, got: {body}"
);
let err = body["error"].as_str().unwrap();
assert!(
err.contains("session") && err.contains("not found"),
"404 error message should mention session not found, got: {err}"
);
}
#[tokio::test]
async fn plan_confirm_returns_api_error_envelope_on_404() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/nonexistent-plan-confirm/plan/confirm")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404, "missing session must 404");
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("status").is_none(),
"ApiError envelope must NOT include a `status` field, got: {body}"
);
let err = body["error"]
.as_str()
.expect("ApiError envelope must have a string `error` field");
assert!(
!err.is_empty(),
"ApiError envelope `error` must be non-empty, got: {body}"
);
assert!(
err.contains("session") && err.contains("not found"),
"404 error should mention session not found, got: {err}"
);
}
#[tokio::test]
async fn plan_reject_returns_api_error_envelope_on_404() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/nonexistent-plan-reject/plan/reject")
.header("content-type", "application/json")
.body(Body::from(r#"{"reason":"missing"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404, "missing session must 404");
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("status").is_none(),
"ApiError envelope must NOT include a `status` field, got: {body}"
);
let err = body["error"]
.as_str()
.expect("ApiError envelope must have a string `error` field");
assert!(
!err.is_empty(),
"ApiError envelope `error` must be non-empty, got: {body}"
);
assert!(
err.contains("session") && err.contains("not found"),
"404 error should mention session not found, got: {err}"
);
}
#[tokio::test]
async fn session_set_goal_returns_api_error_envelope_on_404() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/nonexistent-set-goal/goal")
.header("content-type", "application/json")
.body(Body::from(r#"{"condition":"something"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404, "missing session must 404");
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("status").is_none(),
"ApiError envelope must NOT include a `status` field, got: {body}"
);
let err = body["error"]
.as_str()
.expect("ApiError envelope must have a string `error` field");
assert!(
!err.is_empty(),
"ApiError envelope `error` must be non-empty, got: {body}"
);
assert!(
err.contains("session") && err.contains("not found"),
"404 error should mention session not found, got: {err}"
);
}
#[tokio::test]
async fn session_interrupt_returns_api_error_envelope_on_404() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/nonexistent-interrupt/interrupt")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404, "missing session must 404");
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("404 body must be valid JSON");
assert!(
body.get("status").is_none(),
"ApiError envelope must NOT include a `status` field, got: {body}"
);
let err = body["error"]
.as_str()
.expect("ApiError envelope must have a string `error` field");
assert!(
!err.is_empty(),
"ApiError envelope `error` must be non-empty, got: {body}"
);
assert!(
err.contains("session") && err.contains("not found"),
"404 error should mention session not found, got: {err}"
);
}
#[tokio::test]
async fn goal_313_plan_and_set_goal_conflicts_use_api_error_envelope() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let create_resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(create_resp.status(), 201);
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app = build_router(state.clone());
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{session_id}/plan/confirm"))
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
409,
"plan_confirm with no pending plan must 409"
);
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("409 body must be valid JSON");
assert!(
body.get("status").is_none(),
"409 ApiError envelope must NOT include a `status` field, got: {body}"
);
let err = body["error"]
.as_str()
.expect("409 envelope must have a string `error` field");
assert!(
err.contains("plan approval"),
"409 error should mention plan approval, got: {err}"
);
let runtime_arc = {
let sessions = state.sessions.read().await;
sessions.get(&session_id).unwrap().runtime.clone()
};
let _guard = runtime_arc.lock().await;
let app = build_router(state.clone());
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{session_id}/goal"))
.header("content-type", "application/json")
.body(Body::from(r#"{"condition":"something"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 409, "set_goal with busy runtime must 409");
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("409 body must be valid JSON");
assert!(
body.get("status").is_none(),
"set_goal 409 ApiError envelope must NOT include a `status` field, got: {body}"
);
let err = body["error"]
.as_str()
.expect("set_goal 409 envelope must have a string `error` field");
assert!(
!err.is_empty(),
"set_goal 409 envelope `error` must be non-empty, got: {body}"
);
assert!(
err.contains("busy"),
"set_goal 409 error should mention busy, got: {err}"
);
}
#[tokio::test]
async fn goal_313_clear_goal_409_envelope_preserves_retry_after_hint() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let create_resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(create_resp.status(), 201);
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let runtime_arc = {
let sessions = state.sessions.read().await;
sessions.get(&session_id).unwrap().runtime.clone()
};
let _guard = runtime_arc.lock().await;
let app = build_router(state.clone());
let resp = app
.oneshot(
axum::http::Request::builder()
.method("DELETE")
.uri(format!("/sessions/{session_id}/goal"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 409, "clear_goal with busy runtime must 409");
let retry_after = resp
.headers()
.get(axum::http::header::RETRY_AFTER)
.expect("Retry-After header missing on clear_goal 409")
.to_str()
.unwrap();
assert_eq!(
retry_after, "5",
"Retry-After must stay at 5s for clear_goal 409"
);
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value =
serde_json::from_slice(&body_bytes).expect("409 body must be valid JSON");
assert!(
body.get("status").is_none(),
"clear_goal 409 ApiError envelope must NOT include a `status` field, got: {body}"
);
let err = body["error"]
.as_str()
.expect("clear_goal 409 envelope must have a string `error` field");
assert!(
err.contains("retry after"),
"clear_goal 409 error must preserve the original hint text, got: {err}"
);
}
#[tokio::test]
async fn sse_event_serialization() {
let tool_call = SseEvent::ToolCall {
name: "Read".into(),
step: 1,
};
let json = serde_json::to_value(&tool_call).unwrap();
assert_eq!(json["type"], "tool_call");
assert_eq!(json["name"], "Read");
assert_eq!(json["step"], 1);
let tool_result = SseEvent::ToolResult {
name: "Read".into(),
success: true,
};
let json = serde_json::to_value(&tool_result).unwrap();
assert_eq!(json["type"], "tool_result");
assert_eq!(json["name"], "Read");
assert_eq!(json["success"], true);
let done = SseEvent::Done {
finish_reason: "NoMoreToolCalls".into(),
total_steps: 3,
};
let json = serde_json::to_value(&done).unwrap();
assert_eq!(json["type"], "done");
assert_eq!(json["finish_reason"], "NoMoreToolCalls");
assert_eq!(json["total_steps"], 3);
let error = SseEvent::Error {
message: "something went wrong".into(),
};
let json = serde_json::to_value(&error).unwrap();
assert_eq!(json["type"], "error");
assert_eq!(json["message"], "something went wrong");
}
#[tokio::test]
async fn map_agent_event_tool_call() {
use recursive::AgentEvent;
let event = AgentEvent::ToolCall {
name: "Write".into(),
id: "call_1".into(),
arguments: r#"{"path": "/tmp/test"}"#.into(),
step: 2,
};
let sse = map_agent_event(&event).unwrap();
assert_eq!(
sse,
SseEvent::ToolCall {
name: "Write".into(),
step: 2,
}
);
}
#[tokio::test]
async fn map_agent_event_tool_result_success() {
use recursive::AgentEvent;
let event = AgentEvent::ToolResult {
id: "call_1".into(),
name: "Read".into(),
output: "file contents here".into(),
step: 1,
is_error: false,
};
let sse = map_agent_event(&event).unwrap();
assert_eq!(
sse,
SseEvent::ToolResult {
name: "Read".into(),
success: true,
}
);
}
#[tokio::test]
async fn map_agent_event_tool_result_error() {
use recursive::AgentEvent;
let event = AgentEvent::ToolResult {
id: "call_2".into(),
name: "Write".into(),
output: "ERROR: permission denied".into(),
step: 3,
is_error: true,
};
let sse = map_agent_event(&event).unwrap();
assert_eq!(
sse,
SseEvent::ToolResult {
name: "Write".into(),
success: false,
}
);
}
#[tokio::test]
async fn map_agent_event_turn_finished() {
use recursive::AgentEvent;
let event = AgentEvent::TurnFinished {
reason: "NoMoreToolCalls".into(),
steps: 5,
};
let sse = map_agent_event(&event).unwrap();
assert_eq!(
sse,
SseEvent::Done {
finish_reason: "NoMoreToolCalls".into(),
total_steps: 5,
}
);
}
#[tokio::test]
async fn map_agent_event_returns_none_for_unrelated() {
use recursive::AgentEvent;
let event = AgentEvent::Latency {
step: 1,
llm_ms: 500,
};
assert!(map_agent_event(&event).is_none());
let event = AgentEvent::AssistantText {
text: "hello".into(),
step: 1,
};
assert!(map_agent_event(&event).is_none());
}
#[tokio::test]
async fn map_message_appended_assistant_text_only() {
use recursive::http::SseContentBlock;
use recursive::message::{Message, Role};
use recursive::AgentEvent;
let event = AgentEvent::MessageAppended {
message: Message {
role: Role::Assistant,
content: "Hi there".into(),
tool_calls: vec![],
tool_call_id: None,
reasoning_content: None,
is_compaction_summary: false,
},
usage: None,
};
let sse = map_agent_event(&event).unwrap();
assert_eq!(
sse,
SseEvent::Message {
role: "assistant".into(),
content: vec![SseContentBlock::Text {
text: "Hi there".into(),
}],
}
);
}
#[tokio::test]
async fn map_message_appended_assistant_with_tool_calls() {
use recursive::http::SseContentBlock;
use recursive::llm::ToolCall;
use recursive::message::{Message, Role};
use recursive::AgentEvent;
let event = AgentEvent::MessageAppended {
message: Message {
role: Role::Assistant,
content: "calling".into(),
tool_calls: vec![ToolCall {
id: "tc1".into(),
name: "Read".into(),
arguments: serde_json::json!({"path": "x"}),
}],
tool_call_id: None,
reasoning_content: None,
is_compaction_summary: false,
},
usage: None,
};
let sse = map_agent_event(&event).unwrap();
let SseEvent::Message { role, content } = sse else {
panic!("expected Message variant");
};
assert_eq!(role, "assistant");
assert_eq!(content.len(), 2);
assert!(matches!(&content[0], SseContentBlock::Text { text } if text == "calling"));
assert!(matches!(
&content[1],
SseContentBlock::ToolUse { id, name, .. } if id == "tc1" && name == "Read"
));
}
#[tokio::test]
async fn map_message_appended_skips_system_and_tool_roles() {
use recursive::message::{Message, Role};
use recursive::AgentEvent;
for role in [Role::System, Role::Tool] {
let event = AgentEvent::MessageAppended {
message: Message {
role,
content: "x".into(),
tool_calls: vec![],
tool_call_id: Some("tc".into()),
reasoning_content: None,
is_compaction_summary: false,
},
usage: None,
};
assert!(
map_agent_event(&event).is_none(),
"role {role:?} should not produce a Message event"
);
}
}
#[tokio::test]
async fn map_partial_token_emits_partial_message() {
use recursive::AgentEvent;
let event = AgentEvent::PartialToken {
text: "hel".into(),
step: 3,
};
let sse = map_agent_event(&event).unwrap();
assert_eq!(
sse,
SseEvent::PartialMessage {
text: "hel".into(),
step: 3,
}
);
}
#[tokio::test]
async fn broadcast_channel_delivers_events() {
let (tx, _) = broadcast::channel::<SseEvent>(64);
let mut rx = tx.subscribe();
let event = SseEvent::ToolCall {
name: "test".into(),
step: 1,
};
tx.send(event.clone()).unwrap();
let received = rx.recv().await.unwrap();
assert_eq!(received, event);
}
#[tokio::test]
async fn openapi_spec_returns_200() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/openapi.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn openapi_spec_has_correct_version() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/openapi.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(spec["openapi"], "3.0.3");
assert_eq!(spec["info"]["title"], "Recursive Agent API");
assert_eq!(spec["info"]["version"], "0.4.0");
}
#[tokio::test]
async fn openapi_spec_has_all_paths() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/openapi.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
let paths = spec["paths"]
.as_object()
.expect("paths should be an object");
assert!(paths.contains_key("/health"), "missing /health");
assert!(paths.contains_key("/tools"), "missing /tools");
assert!(paths.contains_key("/run"), "missing /run");
assert!(paths.contains_key("/sessions"), "missing /sessions");
assert!(
paths.contains_key("/sessions/{id}"),
"missing /sessions/{{id}}"
);
assert!(
paths.contains_key("/sessions/{id}/messages"),
"missing /sessions/{{id}}/messages"
);
assert!(
paths.contains_key("/sessions/{id}/events"),
"missing /sessions/{{id}}/events"
);
assert!(paths.contains_key("/openapi.json"), "missing /openapi.json");
}
#[tokio::test]
async fn metrics_returns_prometheus_format() {
let app = build_router(sample_state());
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let body = response.into_body().collect().await.unwrap().to_bytes();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("# HELP recursive_requests_total"));
assert!(text.contains("# TYPE recursive_requests_total counter"));
assert!(text.contains("# TYPE recursive_requests_active gauge"));
for name in [
"recursive_requests_total",
"recursive_requests_active",
"recursive_agent_runs_total",
"recursive_agent_runs_success",
"recursive_agent_runs_failed",
"recursive_tokens_prompt_total",
"recursive_tokens_completion_total",
"recursive_agent_steps_total",
] {
assert!(text.contains(name), "missing metric: {name}");
}
}
#[tokio::test]
async fn metrics_middleware_increments_requests_total() {
let state = sample_state();
let metrics = state.metrics.clone();
let app = build_router(state);
for uri in ["/health", "/tools"] {
let _ = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
}
let n = metrics
.requests_total
.load(std::sync::atomic::Ordering::Relaxed);
assert!(n >= 2, "expected requests_total >= 2, got {n}");
}
#[tokio::test]
async fn metrics_counter_values_render() {
let state = sample_state();
state
.metrics
.agent_runs_total
.store(7, std::sync::atomic::Ordering::Relaxed);
state
.metrics
.tokens_prompt_total
.store(12345, std::sync::atomic::Ordering::Relaxed);
let app = build_router(state);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/metrics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("recursive_agent_runs_total 7"));
assert!(text.contains("recursive_tokens_prompt_total 12345"));
}
#[tokio::test]
async fn auth_disabled_passes_through() {
let app = build_router_with_auth(sample_state(), AuthConfig::default());
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn auth_enabled_rejects_missing_header() {
let app = build_router_with_auth(sample_state(), AuthConfig::new(vec!["secret".into()]));
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 401);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"unauthorized");
}
#[tokio::test]
async fn auth_enabled_accepts_valid_key() {
let app = build_router_with_auth(sample_state(), AuthConfig::new(vec!["secret".into()]));
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("X-API-Key", "secret")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn auth_enabled_rejects_wrong_key() {
let app = build_router_with_auth(sample_state(), AuthConfig::new(vec!["secret".into()]));
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("X-API-Key", "bogus")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn auth_health_and_metrics_are_exempt() {
let auth = AuthConfig::new(vec!["secret".into()]);
let app = build_router_with_auth(sample_state(), auth);
for uri in ["/health", "/metrics"] {
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
200,
"expected {uri} to be exempt from auth"
);
}
}
#[tokio::test]
async fn auth_config_is_valid_unit() {
let empty = AuthConfig::default();
assert!(!empty.is_valid(""));
assert!(!empty.is_valid("anything"));
assert!(!empty.is_enabled());
let cfg = AuthConfig::new(vec!["alpha".into(), "beta".into()]);
assert!(cfg.is_enabled());
assert!(cfg.is_valid("alpha"));
assert!(cfg.is_valid("beta"));
assert!(!cfg.is_valid("alphA")); assert!(!cfg.is_valid("alph")); assert!(!cfg.is_valid("alphax")); assert!(!cfg.is_valid("")); assert!(!cfg.is_valid("gamma")); }
fn router_with_limiter(limiter: RateLimiter) -> axum::Router {
build_router_with_auth_and_rate_limit(sample_state(), AuthConfig::default(), limiter)
}
#[tokio::test]
async fn rate_limit_first_request_succeeds() {
let app = router_with_limiter(RateLimiter::new(2, 0.001));
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn rate_limit_burst_allowed_then_429() {
let app = router_with_limiter(RateLimiter::new(2, 0.001));
for i in 0..2 {
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200, "request #{} should succeed", i + 1);
}
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 429, "third request should be rate-limited");
let body = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"rate limit exceeded");
}
#[tokio::test]
async fn rate_limit_different_clients_have_independent_buckets() {
let app = router_with_limiter(RateLimiter::new(1, 0.001));
let req = |key: &'static str| {
let app = app.clone();
async move {
app.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("X-API-Key", key)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
.status()
}
};
assert_eq!(req("alpha").await, 200, "alpha first hit");
assert_eq!(req("beta").await, 200, "beta first hit");
assert_eq!(req("alpha").await, 429, "alpha second hit (exhausted)");
}
#[tokio::test]
async fn rate_limit_does_not_block_below_threshold() {
let app = router_with_limiter(RateLimiter::new(100, 0.001));
for i in 0..5 {
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200, "request #{} should pass", i + 1);
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn mint_token(secret: &str, exp_offset_secs: i64, audience: Option<&str>) -> String {
use jsonwebtoken::{encode, EncodingKey, Header};
let exp = (now_secs() as i64) + exp_offset_secs;
let mut claims = serde_json::json!({ "exp": exp, "sub": "test-user" });
if let Some(aud) = audience {
claims["aud"] = serde_json::Value::String(aud.into());
}
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.expect("mint jwt")
}
fn router_with_jwt_only(secret: &str, audience: Option<&str>) -> axum::Router {
let jwt = JwtConfig::hs256(secret, audience.map(|s| s.to_string())).unwrap();
let auth = AuthConfig::new(Vec::new()).with_jwt(jwt);
build_router_with_auth(sample_state(), auth)
}
#[tokio::test]
async fn jwt_disabled_legacy_keys_only() {
let auth = AuthConfig::new(vec!["legacy-key".into()]);
let app = build_router_with_auth(sample_state(), auth);
let token = mint_token("any-secret", 60, None);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 401);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("X-API-Key", "legacy-key")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn jwt_valid_token_accepted() {
let secret = "test-secret-12345";
let app = router_with_jwt_only(secret, None);
let token = mint_token(secret, 60, None);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn jwt_expired_token_rejected() {
let secret = "test-secret-12345";
let app = router_with_jwt_only(secret, None);
let token = mint_token(secret, -300, None);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn jwt_wrong_signature_rejected() {
let app = router_with_jwt_only("server-secret", None);
let token = mint_token("attacker-secret", 60, None);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn jwt_audience_mismatch_rejected() {
let secret = "test-secret-12345";
let app = router_with_jwt_only(secret, Some("expected-aud"));
let token = mint_token(secret, 60, Some("other-aud"));
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 401);
}
#[tokio::test]
async fn jwt_audience_match_accepted() {
let secret = "test-secret-12345";
let app = router_with_jwt_only(secret, Some("expected-aud"));
let token = mint_token(secret, 60, Some("expected-aud"));
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn jwt_or_api_key_either_works() {
let secret = "test-secret-12345";
let jwt = JwtConfig::hs256(secret, None).unwrap();
let auth = AuthConfig::new(vec!["legacy-key".into()]).with_jwt(jwt);
let app = build_router_with_auth(sample_state(), auth);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 401);
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("X-API-Key", "legacy-key")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let token = mint_token(secret, 60, None);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/tools")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
}
#[tokio::test]
async fn jwt_health_metrics_remain_exempt() {
let app = router_with_jwt_only("test-secret-12345", None);
for uri in ["/health", "/metrics"] {
let response = app
.clone()
.oneshot(
axum::http::Request::builder()
.uri(uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
200,
"{uri} should be exempt from JWT auth"
);
}
}
async fn collect_agui_events(
response: axum::http::Response<Body>,
) -> Vec<agui_protocol::Event> {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let mut parser = agui_protocol::SseParser::new();
parser.feed(&bytes)
}
fn agui_request_body(messages: serde_json::Value, context: serde_json::Value) -> String {
serde_json::to_string(&serde_json::json!({
"threadId": "t-test",
"runId": "r-test",
"messages": messages,
"tools": [],
"context": context,
}))
.unwrap()
}
#[tokio::test]
async fn agui_endpoint_streams_run_started_and_finished() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "hello from mock".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let body = agui_request_body(
serde_json::json!([
{"id": "u1", "role": "user", "content": "say hello"}
]),
serde_json::json!([]),
);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/agui")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let content_type = response
.headers()
.get("content-type")
.expect("content-type header missing")
.to_str()
.unwrap()
.to_string();
assert!(
content_type.contains("text/event-stream"),
"Expected text/event-stream, got: {content_type}",
);
let events = collect_agui_events(response).await;
assert!(!events.is_empty(), "expected at least one AG-UI event");
match &events[0] {
agui_protocol::Event::RunStarted(rs) => {
assert_eq!(rs.thread_id, "t-test");
assert_eq!(rs.run_id, "r-test");
}
other => panic!("expected RunStarted first, got {other:?}"),
}
match events.last().unwrap() {
agui_protocol::Event::RunFinished(rf) => {
assert_eq!(rf.thread_id, "t-test");
assert_eq!(rf.run_id, "r-test");
}
other => panic!("expected RunFinished last, got {other:?}"),
}
let positions: Vec<usize> = events
.iter()
.enumerate()
.filter_map(|(i, e)| match e {
agui_protocol::Event::TextMessageStart(_)
| agui_protocol::Event::TextMessageContent(_)
| agui_protocol::Event::TextMessageEnd(_) => Some(i),
_ => None,
})
.collect();
assert!(
positions.len() >= 3,
"expected Start/Content/End, got events: {events:?}"
);
match &events[positions[0]] {
agui_protocol::Event::TextMessageStart(_) => {}
other => panic!("expected TextMessageStart first, got {other:?}"),
}
let mut saw_content = false;
for &i in &positions[1..positions.len() - 1] {
if let agui_protocol::Event::TextMessageContent(c) = &events[i] {
if c.delta.contains("hello from mock") {
saw_content = true;
}
}
}
assert!(
saw_content,
"expected a TextMessageContent carrying the assistant text"
);
match &events[*positions.last().unwrap()] {
agui_protocol::Event::TextMessageEnd(_) => {}
other => panic!("expected TextMessageEnd last, got {other:?}"),
}
}
#[tokio::test]
async fn agui_endpoint_rejects_empty_messages_and_context() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let body = agui_request_body(serde_json::json!([]), serde_json::json!([]));
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/agui")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 400);
}
#[tokio::test]
async fn agui_endpoint_emits_tool_call_events() {
use recursive::llm::ToolCall;
let provider = Arc::new(MockProvider::new(vec![
Completion {
content: "calling a tool".into(),
tool_calls: vec![ToolCall {
id: "call-1".into(),
name: "unknown_tool".into(),
arguments: serde_json::json!({"foo": "bar"}),
}],
finish_reason: Some("tool_calls".into()),
usage: None,
reasoning_content: None,
},
Completion {
content: "all done".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
},
]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let body = agui_request_body(
serde_json::json!([
{"id": "u1", "role": "user", "content": "go"}
]),
serde_json::json!([]),
);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/agui")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let events = collect_agui_events(response).await;
let mut idx_start = None;
let mut idx_args = None;
let mut idx_end = None;
let mut idx_result = None;
let mut idx_finished = None;
for (i, ev) in events.iter().enumerate() {
match ev {
agui_protocol::Event::ToolCallStart(s) if s.tool_call_id == "call-1" => {
idx_start.get_or_insert(i);
}
agui_protocol::Event::ToolCallArgs(a) if a.tool_call_id == "call-1" => {
idx_args.get_or_insert(i);
}
agui_protocol::Event::ToolCallEnd(e) if e.tool_call_id == "call-1" => {
idx_end.get_or_insert(i);
}
agui_protocol::Event::ToolCallResult(r) if r.tool_call_id == "call-1" => {
idx_result.get_or_insert(i);
}
agui_protocol::Event::RunFinished(_) => {
idx_finished.get_or_insert(i);
}
_ => {}
}
}
let s = idx_start.expect("missing ToolCallStart");
let a = idx_args.expect("missing ToolCallArgs");
let e = idx_end.expect("missing ToolCallEnd");
let r = idx_result.expect("missing ToolCallResult");
let f = idx_finished.expect("missing RunFinished");
assert!(
s < a && a < e && e < r && r < f,
"tool events out of order: start={s} args={a} end={e} result={r} finished={f}; events={events:?}"
);
if let agui_protocol::Event::ToolCallArgs(args) = &events[a] {
assert!(
args.delta.contains("foo") && args.delta.contains("bar"),
"args delta missing arguments: {}",
args.delta
);
}
}
#[tokio::test]
async fn agui_runfinished_reports_error_when_run_fails() {
use recursive::error::Error;
let provider = Arc::new(MockProvider::new(vec![]).with_errors(vec![Error::Llm {
provider: "mock".into(),
message: "injected provider failure".into(),
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let body = agui_request_body(
serde_json::json!([
{"id": "u1", "role": "user", "content": "say hello"}
]),
serde_json::json!([]),
);
let response = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/agui")
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 200);
let events = collect_agui_events(response).await;
assert!(!events.is_empty(), "expected at least one AG-UI event");
match &events[0] {
agui_protocol::Event::RunStarted(_) => {}
other => panic!("expected RunStarted first, got {other:?}"),
}
match events.last().unwrap() {
agui_protocol::Event::RunFinished(rf) => {
let outcome = rf
.outcome
.as_ref()
.expect("RunFinished must carry an outcome");
match outcome {
agui_protocol::RunFinishedOutcome::Error { message, code } => {
assert!(
message.contains("injected provider failure"),
"message should surface the underlying error, got: {message}"
);
assert!(code.is_none(), "code is reserved for future mapping");
}
other => panic!("expected Error outcome, got {other:?}"),
}
}
other => panic!("expected RunFinished last, got {other:?}"),
}
}
async fn state_with_pending_plan_session(plan_text: &str) -> (AppState, String) {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let runtime = AgentRuntimeBuilder::new()
.llm(Arc::new(MockProvider::new(vec![])))
.build()
.expect("runtime build failed");
let gate = runtime.plan_approval_gate();
gate.pending_plan
.write()
.expect("write lock")
.replace(plan_text.to_string());
let session_id = "test-session-plan".to_string();
let session = SessionState {
id: session_id.clone(),
created_at: "2026-01-01T00:00:00Z".to_string(),
title: None,
runtime: Arc::new(tokio::sync::Mutex::new(runtime)),
plan_approval_gate: gate,
interrupt_token: Arc::new(tokio::sync::Mutex::new(None)),
non_system_message_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
last_active_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)),
prompt_tokens: Arc::new(std::sync::atomic::AtomicU64::new(0)),
completion_tokens: Arc::new(std::sync::atomic::AtomicU64::new(0)),
};
state
.sessions
.write()
.await
.insert(session_id.clone(), session);
(state, session_id)
}
#[tokio::test]
async fn plan_confirm_returns_404_for_unknown_session() {
let app = build_router(sample_state());
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/nonexistent/plan/confirm")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn plan_reject_returns_404_for_unknown_session() {
let app = build_router(sample_state());
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/nonexistent/plan/reject")
.header("content-type", "application/json")
.body(Body::from(r#"{"reason":"no such session"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn plan_confirm_returns_409_when_no_plan_pending() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(create_resp.status(), 201);
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let confirm_resp = app2
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{session_id}/plan/confirm"))
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(confirm_resp.status(), 409);
}
#[tokio::test]
async fn plan_reject_returns_409_when_no_plan_pending() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(create_resp.status(), 201);
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let reject_resp = app2
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{session_id}/plan/reject"))
.header("content-type", "application/json")
.body(Body::from(r#"{"reason":"test"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(reject_resp.status(), 409);
}
#[tokio::test]
async fn get_session_returns_plan_pending_approval_status() {
let (state, session_id) =
state_with_pending_plan_session("Step 1: read files\nStep 2: write summary").await;
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{session_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let detail: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(detail["status"], "plan_pending_approval");
assert_eq!(
detail["pending_plan"],
"Step 1: read files\nStep 2: write summary"
);
}
#[tokio::test]
async fn plan_confirm_approves_pending_plan_and_returns_200() {
let (state, session_id) = state_with_pending_plan_session("Do the thing").await;
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{session_id}/plan/confirm"))
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["status"], "approved");
assert_eq!(result["session_id"], session_id.as_str());
}
#[tokio::test]
async fn plan_confirm_with_edits_updates_plan_text() {
let (state, session_id) = state_with_pending_plan_session("Original plan").await;
let gate = {
state
.sessions
.read()
.await
.get(&session_id)
.unwrap()
.plan_approval_gate
.clone()
};
let app = build_router(state);
let body = serde_json::json!({"edits": "Revised plan"}).to_string();
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{session_id}/plan/confirm"))
.header("content-type", "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let after = gate.pending_plan.read().unwrap().clone();
assert!(
after.is_none(),
"pending_plan must be cleared after approve() to prevent stale re-injection"
);
}
#[tokio::test]
async fn plan_reject_rejects_pending_plan_and_returns_200() {
let (state, session_id) = state_with_pending_plan_session("Plan to reject").await;
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{session_id}/plan/reject"))
.header("content-type", "application/json")
.body(Body::from(r#"{"reason":"not detailed enough"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["status"], "rejected");
assert_eq!(result["session_id"], session_id.as_str());
}
#[tokio::test]
async fn map_agent_event_plan_proposed_maps_to_sse() {
use recursive::event::AgentEvent;
let event = AgentEvent::PlanProposed {
plan_text: "my plan".to_string(),
tool_calls: vec![],
};
let sse = map_agent_event(&event);
assert_eq!(
sse,
Some(SseEvent::PlanProposed {
plan: "my plan".to_string()
})
);
}
#[tokio::test]
async fn get_session_returns_idle_status_when_no_plan_pending() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let detail_resp = app2
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{session_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(detail_resp.status(), 200);
let body = detail_resp.into_body().collect().await.unwrap().to_bytes();
let detail: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(detail["status"], "idle");
assert!(detail["pending_plan"].is_null());
}
#[tokio::test]
async fn set_goal_returns_200_for_valid_session() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "YES\nCondition met.".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{"system_prompt": null}"#))
.unwrap(),
)
.await
.unwrap();
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let resp = app2
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{session_id}/goal"))
.method("POST")
.header("content-type", "application/json")
.body(Body::from(
r#"{"condition": "Write a greeting", "max_turns": 3}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["status"], "pursuing");
assert_eq!(val["session_id"], session_id);
}
#[tokio::test]
async fn set_goal_returns_404_for_missing_session() {
let app = build_router(sample_state());
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions/no-such-session/goal")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{"condition": "anything"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn clear_goal_returns_200_for_valid_session() {
let state = sample_state();
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let resp = app2
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{session_id}/goal"))
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["status"], "cleared");
}
#[tokio::test]
async fn clear_goal_returns_404_for_missing_session() {
let app = build_router(sample_state());
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions/ghost/goal")
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn session_detail_includes_goal_field_when_null() {
let state = sample_state();
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let resp = app2
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{session_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let detail: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(detail.get("goal").is_some());
assert!(detail["goal"].is_null());
}
#[tokio::test]
async fn set_goal_response_includes_session_id_field() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "NO\nNot done yet.".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let resp = app2
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{session_id}/goal"))
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{"condition": "tests pass"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(val.get("session_id").is_some());
assert_eq!(val["session_id"], session_id);
assert_eq!(val["status"], "pursuing");
}
#[tokio::test]
async fn clear_goal_response_includes_session_id_field() {
let state = sample_state();
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let resp = app2
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{session_id}/goal"))
.method("DELETE")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(val.get("session_id").is_some());
assert_eq!(val["session_id"], session_id);
assert_eq!(val["status"], "cleared");
}
#[tokio::test]
async fn set_goal_uses_default_max_turns_when_omitted() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "NO\nNot yet.".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let resp = app2
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{session_id}/goal"))
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{"condition": "all tests pass"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["status"], "pursuing");
}
#[tokio::test]
async fn interrupt_returns_200_for_valid_session() {
let state = sample_state();
let app = build_router(state.clone());
let create_resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
let body = create_resp.into_body().collect().await.unwrap().to_bytes();
let created: serde_json::Value = serde_json::from_slice(&body).unwrap();
let session_id = created["id"].as_str().unwrap().to_string();
let app2 = build_router(state);
let resp = app2
.oneshot(
axum::http::Request::builder()
.uri(format!("/sessions/{session_id}/interrupt"))
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["status"], "interrupted");
assert_eq!(val["session_id"], session_id);
}
#[tokio::test]
async fn interrupt_returns_404_for_missing_session() {
let app = build_router(sample_state());
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/sessions/ghost/interrupt")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn slash_commands_returns_empty_list_when_none_configured() {
let state = sample_state();
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/slash-commands")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let cmds: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(cmds.is_array());
assert_eq!(cmds.as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn slash_commands_returns_configured_commands() {
use recursive::http::SlashCommandInfo;
let provider = Arc::new(MockProvider::new(vec![]));
let state = AppState {
tools: vec![],
config: mock_config(),
tool_registry: ToolRegistry::local(),
provider,
sessions: Arc::new(RwLock::new(HashMap::new())),
event_channels: Arc::new(RwLock::new(HashMap::new())),
metrics: Arc::new(Metrics::default()),
slash_commands: Arc::new(vec![
SlashCommandInfo {
name: "deploy".to_string(),
description: "Deploy the service".to_string(),
source: "skill".to_string(),
aliases: vec!["d".to_string()],
argument_hint: "<env>".to_string(),
},
SlashCommandInfo {
name: "rollback".to_string(),
description: "Roll back the deployment".to_string(),
source: "skill".to_string(),
aliases: vec![],
argument_hint: String::new(),
},
]),
session_ttl_secs: 0,
run_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(8)),
rate_limiter: RateLimiter::new(10, 1.0),
skills: vec![],
};
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.uri("/slash-commands")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let cmds: serde_json::Value = serde_json::from_slice(&body).unwrap();
let arr = cmds.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "deploy");
assert_eq!(arr[0]["source"], "skill");
assert_eq!(arr[0]["aliases"][0], "d");
assert_eq!(arr[0]["argument_hint"], "<env>");
assert_eq!(arr[1]["name"], "rollback");
}
#[tokio::test]
async fn fork_session_returns_201_with_new_id() {
let state = sample_state();
let app = build_router(state.clone());
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let src: serde_json::Value = serde_json::from_slice(&body).unwrap();
let src_id = src["id"].as_str().unwrap().to_string();
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{src_id}/fork"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let fork: serde_json::Value = serde_json::from_slice(&body).unwrap();
let fork_id = fork["id"].as_str().unwrap();
assert!(!fork_id.is_empty(), "fork id should be non-empty");
assert_ne!(fork_id, src_id, "fork id must differ from source");
assert!(fork["message_count"].as_u64().is_some());
}
#[tokio::test]
async fn fork_session_returns_404_for_missing_session() {
let state = sample_state();
let app = build_router(state);
let resp = app
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions/nonexistent/fork")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn fork_session_message_count_is_non_system_only() {
let provider = Arc::new(MockProvider::new(vec![Completion {
content: "hi back".into(),
tool_calls: vec![],
finish_reason: Some("stop".into()),
usage: None,
reasoning_content: None,
}]));
let state = sample_state_with_provider(provider);
let app = build_router(state);
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let src: serde_json::Value = serde_json::from_slice(&body).unwrap();
let src_id = src["id"].as_str().unwrap().to_string();
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{src_id}/messages"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"content": "ping"
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200, "send_message must succeed");
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri(format!("/sessions/{src_id}/fork"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 201);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let fork: serde_json::Value = serde_json::from_slice(&body).unwrap();
let count = fork["message_count"]
.as_u64()
.expect("message_count must be a number");
assert_eq!(
count, 2,
"fork message_count must reflect non-system messages only (user + assistant), got: {fork}"
);
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/sessions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let list: serde_json::Value = serde_json::from_slice(&body).unwrap();
let fork_id = fork["id"].as_str().unwrap();
let listed = list["sessions"]
.as_array()
.expect("sessions is an array")
.iter()
.find(|s| s["id"].as_str() == Some(fork_id))
.expect("forked session must appear in GET /sessions");
assert_eq!(
listed["message_count"], 2,
"GET /sessions must agree with fork response, got: {listed}"
);
}
#[tokio::test]
async fn list_sessions_stable_sort_by_id() {
let state = sample_state();
let app = build_router(state.clone());
for _ in 0..5 {
app.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap(),
)
.await
.unwrap();
}
let fetch = || {
let app = build_router(state.clone());
async move {
let resp = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/sessions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
resp["sessions"]
.as_array()
.expect("sessions should be an array")
.iter()
.map(|s| s["id"].as_str().unwrap().to_string())
.collect::<Vec<_>>()
}
};
let ids_a = fetch().await;
let ids_b = fetch().await;
assert!(!ids_a.is_empty(), "should have sessions");
assert_eq!(ids_a, ids_b, "list_sessions must return a stable order");
let mut sorted = ids_a.clone();
sorted.sort();
assert_eq!(ids_a, sorted, "list_sessions ids must be sorted by id");
}
#[tokio::test]
async fn list_sessions_envelope_total_equals_unpaginated_count() {
let provider = Arc::new(MockProvider::new(vec![]));
let state = sample_state_with_provider(provider);
let app = build_router(state.clone());
for _ in 0..3 {
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 201);
}
let resp = app
.clone()
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/sessions?limit=2&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let page: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
page["total"], 3,
"total must reflect the un-paginated session count"
);
let sessions = page["sessions"]
.as_array()
.expect("sessions must be an array");
assert_eq!(
sessions.len(),
2,
"page slice must respect limit (limit=2 → 2 items)"
);
let resp = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/sessions?limit=2&offset=2")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let page: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(page["total"], 3, "total stays at 3 across pages");
let sessions = page["sessions"]
.as_array()
.expect("sessions must be an array");
assert_eq!(sessions.len(), 1, "offset=2 leaves exactly one item");
let mut seen: Vec<String> = Vec::new();
for s in page["sessions"].as_array().unwrap() {
seen.push(s["id"].as_str().unwrap().to_string());
}
let resp = build_router(state)
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/sessions?limit=2&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.into_body().collect().await.unwrap().to_bytes();
let page: serde_json::Value = serde_json::from_slice(&body).unwrap();
for s in page["sessions"].as_array().unwrap() {
seen.push(s["id"].as_str().unwrap().to_string());
}
let mut unique: Vec<String> = seen.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), 3, "both pages together cover all 3 sessions");
}
}