use super::*;
#[test]
fn validate_short_rejects_empty_string() {
let result = validate_short("agent_id", "");
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert!(err.1.contains("must not be empty"));
}
#[test]
fn validate_short_rejects_whitespace_only() {
let result = validate_short("name", " ");
assert!(result.is_err());
}
#[test]
fn validate_long_rejects_empty_string() {
let result = validate_long("description", "");
assert!(result.is_err());
}
#[test]
fn validate_short_rejects_null_bytes() {
let result = validate_short("agent_id", "hello\0world");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.1.contains("null bytes"));
}
#[test]
fn validate_short_accepts_valid_input() {
assert!(validate_short("agent_id", "my-agent").is_ok());
assert!(validate_short("name", "a").is_ok());
}
#[test]
fn validate_short_rejects_over_max_length() {
let long = "a".repeat(MAX_SHORT_FIELD + 1);
let result = validate_short("agent_id", &long);
assert!(result.is_err());
}
#[test]
fn validate_short_at_exact_max_length() {
let exact = "a".repeat(MAX_SHORT_FIELD);
assert!(validate_short("agent_id", &exact).is_ok());
}
#[test]
fn sanitize_html_escapes_script_tags() {
let input = "<script>alert(1)</script>";
let output = sanitize_html(input);
assert!(!output.contains('<'));
assert!(!output.contains('>'));
assert!(output.contains("<"));
assert!(output.contains(">"));
}
#[test]
fn sanitize_html_preserves_safe_content() {
assert_eq!(sanitize_html("hello world"), "hello world");
}
#[test]
fn sanitize_html_escapes_all_entities() {
assert_eq!(sanitize_html("a&b"), "a&b");
assert_eq!(
sanitize_html(r#"" onmouseover="x"#),
"" onmouseover="x"
);
assert_eq!(sanitize_html("' onclick='y"), "' onclick='y");
assert_eq!(sanitize_html("<"), "&lt;");
}
#[test]
fn pagination_resolve_defaults() {
let pq = PaginationQuery {
limit: None,
offset: None,
};
let (limit, offset) = pq.resolve();
assert_eq!(limit, DEFAULT_PAGE_SIZE);
assert_eq!(offset, 0);
}
#[test]
fn pagination_resolve_clamps_negative_limit() {
let pq = PaginationQuery {
limit: Some(-1),
offset: None,
};
let (limit, _) = pq.resolve();
assert_eq!(limit, 1);
}
#[test]
fn pagination_resolve_clamps_zero_limit() {
let pq = PaginationQuery {
limit: Some(0),
offset: None,
};
let (limit, _) = pq.resolve();
assert_eq!(limit, 1);
}
#[test]
fn pagination_resolve_clamps_huge_limit() {
let pq = PaginationQuery {
limit: Some(999_999),
offset: None,
};
let (limit, _) = pq.resolve();
assert_eq!(limit, MAX_PAGE_SIZE);
}
#[test]
fn pagination_resolve_clamps_negative_offset() {
let pq = PaginationQuery {
limit: None,
offset: Some(-5),
};
let (_, offset) = pq.resolve();
assert_eq!(offset, 0);
}
#[tokio::test]
async fn malformed_json_returns_json_error_body() {
let app = full_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sessions")
.header("content-type", "application/json")
.body(Body::from("{not valid json"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = json_body(resp).await;
assert!(
body["detail"].is_string(),
"error response must be JSON with 'error' field"
);
}
#[tokio::test]
async fn wrong_content_type_returns_json_error() {
let app = full_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sessions")
.header("content-type", "text/plain")
.body(Body::from("{\"agent_id\":\"test\"}"))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status().is_client_error());
let body = json_body(resp).await;
assert!(body["detail"].is_string());
}
#[tokio::test]
async fn method_not_allowed_returns_json_body() {
let app = full_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
let body = json_body(resp).await;
assert!(body["detail"].is_string());
}
#[tokio::test]
async fn security_headers_present_on_response() {
let app = full_app(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let headers = resp.headers();
assert!(
headers.contains_key("content-security-policy"),
"CSP header must be present"
);
assert!(
headers.contains_key("x-frame-options"),
"X-Frame-Options must be present"
);
assert_eq!(
headers.get("x-frame-options").unwrap().to_str().unwrap(),
"DENY"
);
assert!(
headers.contains_key("x-content-type-options"),
"X-Content-Type-Options must be present"
);
assert_eq!(
headers
.get("x-content-type-options")
.unwrap()
.to_str()
.unwrap(),
"nosniff"
);
}
#[tokio::test]
async fn session_list_respects_limit_parameter() {
let state = test_state();
for i in 0..5 {
roboticus_db::sessions::rotate_agent_session(&state.db, &format!("agent-{i}")).unwrap();
}
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/sessions?limit=2")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let sessions = body["sessions"].as_array().unwrap();
assert_eq!(sessions.len(), 2);
}
#[tokio::test]
async fn empty_agent_id_falls_back_to_config_default() {
let app = full_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sessions")
.header("content-type", "application/json")
.body(Body::from(r#"{"agent_id":""}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert!(body["id"].as_str().is_some());
}
#[tokio::test]
async fn change_model_persists_to_disk() {
let state = test_state();
let config_path = state.config_path.as_ref().clone();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/roster/TestBot/model")
.header("content-type", "application/json")
.body(Body::from(
r#"{"model":"anthropic/claude-sonnet-4-20250514"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["updated"], true);
assert_eq!(body["persisted"], true);
let contents = std::fs::read_to_string(&config_path).unwrap();
assert!(
contents.contains("claude-sonnet"),
"config file should contain the new model"
);
}
#[tokio::test]
async fn get_session_returns_full_object() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "test-agent", None).unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["id"], sid);
assert_eq!(body["agent_id"], "test-agent");
assert!(body["created_at"].is_string());
}
#[tokio::test]
async fn get_session_nonexistent_returns_404() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/sessions/nonexistent-session-id")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn create_session_returns_full_session_object() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sessions")
.header("content-type", "application/json")
.body(Body::from(r#"{"agent_id":"agent-alpha"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert!(body["id"].is_string());
assert_eq!(body["agent_id"], "agent-alpha");
assert!(body["created_at"].is_string());
}
#[tokio::test]
async fn list_session_turns_empty() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-a", None).unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/turns"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["turns"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn list_session_turns_returns_seeded_turn() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-b", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-lst-1",
&sid,
Some("gpt-4"),
Some(200),
Some(100),
Some(0.02),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/turns"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let turns = body["turns"].as_array().unwrap();
assert_eq!(turns.len(), 1);
assert_eq!(turns[0]["id"], "turn-lst-1");
assert_eq!(turns[0]["model"], "gpt-4");
}
#[tokio::test]
async fn get_turn_returns_turn_data() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-c", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-get-1",
&sid,
Some("claude-3"),
Some(500),
Some(250),
Some(0.05),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/turn-get-1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["id"], "turn-get-1");
assert_eq!(body["session_id"], sid);
assert_eq!(body["model"], "claude-3");
assert_eq!(body["tokens_in"], 500);
assert_eq!(body["tokens_out"], 250);
}
#[tokio::test]
async fn get_turn_nonexistent_returns_404() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/nonexistent-turn-id")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_turn_tools_empty() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-e", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-tools-1",
&sid,
Some("gpt-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
let app = build_router(test_state()); let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/turn-tools-1/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["tool_calls"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn get_turn_tools_with_seeded_tool_call() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-f", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-tools-2",
&sid,
Some("gpt-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
roboticus_db::tools::record_tool_call(
&state.db,
"turn-tools-2",
"file_read",
r#"{"path":"test.rs"}"#,
Some(r#"{"content":"hello"}"#),
"success",
Some(100),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/turn-tools-2/tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let calls = body["tool_calls"].as_array().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0]["tool_name"], "file_read");
assert_eq!(calls[0]["status"], "success");
}
#[tokio::test]
async fn get_turn_tips_returns_array() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-g", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-tips-1",
&sid,
Some("gpt-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/turn-tips-1/tips")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["turn_id"], "turn-tips-1");
assert!(body["tips"].is_array());
assert!(body["tip_count"].is_number());
}
#[tokio::test]
async fn get_turn_tips_nonexistent_returns_404() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/nonexistent/tips")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn post_turn_feedback_succeeds() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-fb", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-fb-1",
&sid,
Some("gpt-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/turns/turn-fb-1/feedback")
.header("content-type", "application/json")
.body(Body::from(r#"{"grade":4,"comment":"good response"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["turn_id"], "turn-fb-1");
assert_eq!(body["grade"], 4);
}
#[tokio::test]
async fn post_turn_feedback_invalid_grade_returns_400() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-fbv", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-fbv-1",
&sid,
Some("gpt-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/turns/turn-fbv-1/feedback")
.header("content-type", "application/json")
.body(Body::from(r#"{"grade":6}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_turn_feedback_nonexistent_turn_returns_404() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/turns/nonexistent/feedback")
.header("content-type", "application/json")
.body(Body::from(r#"{"grade":3}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_turn_feedback_returns_seeded_feedback() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-gfb", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-gfb-1",
&sid,
Some("gpt-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
roboticus_db::sessions::record_feedback(
&state.db,
"turn-gfb-1",
&sid,
5,
"dashboard",
Some("excellent"),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/turn-gfb-1/feedback")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["grade"], 5);
assert_eq!(body["comment"], "excellent");
}
#[tokio::test]
async fn get_turn_feedback_no_feedback_returns_404() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-nfb", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-nfb-1",
&sid,
Some("gpt-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/turn-nfb-1/feedback")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_session_feedback_returns_list() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-sfb", None).unwrap();
roboticus_db::sessions::create_turn_with_id(
&state.db,
"turn-sfb-1",
&sid,
Some("gpt-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
roboticus_db::sessions::record_feedback(&state.db, "turn-sfb-1", &sid, 3, "dashboard", None)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/feedback"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let fbs = body["feedback"].as_array().unwrap();
assert_eq!(fbs.len(), 1);
assert_eq!(fbs[0]["grade"], 3);
}
#[tokio::test]
async fn get_session_insights_returns_valid_shape() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-ins", None).unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/insights"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["session_id"], sid);
assert!(body["insights"].is_array());
assert!(body["insight_count"].is_number());
assert_eq!(body["turn_count"], 0);
}
#[tokio::test]
async fn post_message_invalid_role_returns_400() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-pm", None).unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/sessions/{sid}/messages"))
.header("content-type", "application/json")
.body(Body::from(r#"{"role":"invalid_role","content":"hello"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_message_nonexistent_session_returns_404() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sessions/nonexistent/messages")
.header("content-type", "application/json")
.body(Body::from(r#"{"role":"user","content":"hello"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn interview_start_duplicate_key_returns_conflict() {
let state = test_state();
let app = build_router(state.clone());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/interview/start")
.header("content-type", "application/json")
.body(Body::from(r#"{"session_key":"dup-key"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let app2 = build_router(state);
let resp2 = app2
.oneshot(
Request::builder()
.method("POST")
.uri("/api/interview/start")
.header("content-type", "application/json")
.body(Body::from(r#"{"session_key":"dup-key"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp2.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn interview_finish_unknown_key_returns_404() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/interview/finish")
.header("content-type", "application/json")
.body(Body::from(r#"{"session_key":"nonexistent"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn interview_turn_unknown_key_returns_404() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/interview/turn")
.header("content-type", "application/json")
.body(Body::from(
r#"{"session_key":"nonexistent","content":"hello"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn backfill_nicknames_returns_ok() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/sessions/backfill-nicknames")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert!(body["backfilled"].is_number());
}
#[tokio::test]
async fn list_messages_empty_session() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-lm", None).unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/messages"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["messages"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn list_messages_returns_seeded_message() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-lm2", None).unwrap();
roboticus_db::sessions::append_message(&state.db, &sid, "user", "hello world").unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/messages"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
let msgs = body["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0]["role"], "user");
assert_eq!(msgs[0]["content"], "hello world");
}
#[tokio::test]
async fn get_skill_found() {
let state = test_state();
let skill_id = roboticus_db::skills::register_skill_full(
&state.db,
"test-skill",
"instruction",
Some("A test skill"),
"/tmp/test.md",
"hash123",
None,
None,
None,
None,
"Safe",
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/skills/{skill_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["name"], "test-skill");
assert_eq!(body["kind"], "instruction");
assert_eq!(body["built_in"], false);
assert_eq!(body["enabled"], true);
}
#[tokio::test]
async fn get_skill_by_id_returns_404() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/skills/nonexistent-id")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn toggle_skill_not_found() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/skills/nonexistent/toggle")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn toggle_skill_success() {
let state = test_state();
let skill_id = roboticus_db::skills::register_skill_full(
&state.db,
"toggleable",
"instruction",
None,
"/tmp/t.md",
"h1",
None,
None,
None,
None,
"Safe",
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/skills/{skill_id}/toggle"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["id"], skill_id);
assert_eq!(body["enabled"], false);
}
#[tokio::test]
async fn toggle_skill_forbidden_for_builtin() {
let state = test_state();
let skill_id = roboticus_db::skills::register_skill_full(
&state.db,
"builtin-skill",
"builtin",
None,
"/tmp/b.md",
"h2",
None,
None,
None,
None,
"Safe",
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/skills/{skill_id}/toggle"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_skill_source_success() {
let state = test_state();
let skills_dir = tempfile::tempdir().unwrap();
let source_path = skills_dir.path().join("editable.md");
std::fs::write(
&source_path,
"---\nname: editable\ndescription: old\nkind: instruction\n---\nOld content\n",
)
.unwrap();
{
let mut cfg = state.config.write().await;
cfg.skills.skills_dir = skills_dir.path().to_path_buf();
}
let skill_id = roboticus_db::skills::register_skill_full(
&state.db,
"editable",
"instruction",
Some("old"),
source_path.to_str().unwrap(),
"h4",
None,
None,
None,
None,
"Safe",
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/skills/{skill_id}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"source_content": "---\nname: editable\ndescription: updated\nkind: instruction\n---\nUpdated content\n"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["updated"], true);
assert_eq!(body["skill"]["name"], "editable");
assert!(
std::fs::read_to_string(source_path)
.unwrap()
.contains("Updated content")
);
}
#[tokio::test]
async fn update_skill_source_forbidden_for_builtin() {
let state = test_state();
let skill_id = roboticus_db::skills::register_skill_full(
&state.db,
"builtin-skill-edit",
"builtin",
None,
"/tmp/builtin.md",
"h5",
None,
None,
None,
None,
"Safe",
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/skills/{skill_id}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"source_content": "nope"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn delete_skill_success() {
let state = test_state();
let skill_id = roboticus_db::skills::register_skill_full(
&state.db,
"deletable",
"instruction",
None,
"/tmp/d.md",
"h3",
None,
None,
None,
None,
"Safe",
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/skills/{skill_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["deleted"], true);
assert_eq!(body["name"], "deletable");
}
#[tokio::test]
async fn delete_skill_not_found() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/skills/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_skill_forbidden_for_builtin() {
let state = test_state();
let skill_id = roboticus_db::skills::register_skill_full(
&state.db,
"builtin-del",
"builtin",
None,
"/tmp/bd.md",
"h4",
None,
None,
None,
None,
"Safe",
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/skills/{skill_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn get_turn_model_selection_not_found() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/model-selection/turns/nonexistent-turn")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_turn_model_selection_found() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-ms", None).unwrap();
let tid = roboticus_db::sessions::create_turn(
&state.db,
&sid,
Some("claude-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
let evt = roboticus_db::model_selection::ModelSelectionEventRow {
id: "mse-test-1".into(),
turn_id: tid.clone(),
session_id: sid.clone(),
agent_id: "agent-ms".into(),
channel: "cli".into(),
selected_model: "claude-4".into(),
strategy: "complexity".into(),
primary_model: "claude-4".into(),
override_model: None,
complexity: Some("high".into()),
user_excerpt: "test".into(),
candidates_json: r#"["claude-4"]"#.into(),
created_at: "2025-01-01T00:00:00".into(),
schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
attribution: None,
metascore_json: None,
features_json: None,
};
roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/turns/{tid}/model-selection"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["selected_model"], "claude-4");
assert_eq!(body["strategy"], "complexity");
assert!(body["candidates"].is_array());
}
#[tokio::test]
async fn list_model_selection_events_empty() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/models/selections")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["count"], 0);
assert_eq!(body["events"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn list_model_selection_events_with_limit() {
let state = test_state();
for i in 0..3 {
let evt = roboticus_db::model_selection::ModelSelectionEventRow {
id: format!("mse-list-{i}"),
turn_id: format!("turn-list-{i}"),
session_id: "sess-list".into(),
agent_id: "agent-list".into(),
channel: "cli".into(),
selected_model: "gpt-4".into(),
strategy: "default".into(),
primary_model: "gpt-4".into(),
override_model: None,
complexity: None,
user_excerpt: "hello".into(),
candidates_json: "[]".into(),
created_at: format!("2025-01-0{i}T00:00:00"),
schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
attribution: None,
metascore_json: None,
features_json: None,
};
roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
}
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/models/selections?limit=2")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["count"], 2);
}
#[tokio::test]
async fn routing_dataset_endpoint_returns_rows_and_summary() {
let state = test_state();
let evt = roboticus_db::model_selection::ModelSelectionEventRow {
id: "mse-dataset-1".into(),
turn_id: "turn-dataset-1".into(),
session_id: "sess-dataset".into(),
agent_id: "agent-dataset".into(),
channel: "cli".into(),
selected_model: "ollama/qwen3:8b".into(),
strategy: "metascore".into(),
primary_model: "ollama/qwen3:8b".into(),
override_model: None,
complexity: Some("0.42".into()),
user_excerpt: "dataset test".into(),
candidates_json: r#"[{"model":"ollama/qwen3:8b","usable":true}]"#.into(),
created_at: "2025-01-01T00:00:00".into(),
schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
attribution: Some("unit-test".into()),
metascore_json: None,
features_json: None,
};
roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
roboticus_db::metrics::record_inference_cost(
&state.db,
"ollama/qwen3:8b",
"ollama",
100,
50,
0.001,
Some("T1"),
false,
Some(120),
Some(0.8),
false,
Some("turn-dataset-1"),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/models/routing-dataset?limit=10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["summary"]["total_rows"], 1);
assert_eq!(body["rows"].as_array().unwrap().len(), 1);
assert_eq!(body["rows"][0]["user_excerpt"], "[redacted]");
}
#[tokio::test]
async fn routing_dataset_endpoint_can_include_user_excerpt_when_opted_in() {
let state = test_state();
let evt = roboticus_db::model_selection::ModelSelectionEventRow {
id: "mse-dataset-2".into(),
turn_id: "turn-dataset-2".into(),
session_id: "sess-dataset".into(),
agent_id: "agent-dataset".into(),
channel: "cli".into(),
selected_model: "ollama/qwen3:8b".into(),
strategy: "metascore".into(),
primary_model: "ollama/qwen3:8b".into(),
override_model: None,
complexity: Some("0.18".into()),
user_excerpt: "sensitive excerpt".into(),
candidates_json: r#"[{"model":"ollama/qwen3:8b","usable":true}]"#.into(),
created_at: "2025-01-01T00:00:00".into(),
schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
attribution: Some("unit-test".into()),
metascore_json: None,
features_json: None,
};
roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
roboticus_db::metrics::record_inference_cost(
&state.db,
"ollama/qwen3:8b",
"ollama",
40,
20,
0.0005,
Some("T1"),
false,
Some(80),
Some(0.7),
false,
Some("turn-dataset-2"),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/models/routing-dataset?limit=10&include_user_excerpt=true")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["rows"][0]["user_excerpt"], "sensitive excerpt");
}
#[tokio::test]
async fn routing_eval_endpoint_returns_summary() {
let state = test_state();
let evt = roboticus_db::model_selection::ModelSelectionEventRow {
id: "mse-eval-1".into(),
turn_id: "turn-eval-1".into(),
session_id: "sess-eval".into(),
agent_id: "agent-eval".into(),
channel: "cli".into(),
selected_model: "ollama/qwen3:8b".into(),
strategy: "metascore".into(),
primary_model: "ollama/qwen3:8b".into(),
override_model: None,
complexity: Some("0.25".into()),
user_excerpt: "eval test".into(),
candidates_json: r#"[{"model":"ollama/qwen3:8b","usable":true}]"#.into(),
created_at: "2025-01-01T00:00:00".into(),
schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
attribution: Some("unit-test".into()),
metascore_json: None,
features_json: None,
};
roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
roboticus_db::metrics::record_inference_cost(
&state.db,
"ollama/qwen3:8b",
"ollama",
120,
60,
0.002,
Some("T1"),
false,
Some(110),
Some(0.85),
false,
Some("turn-eval-1"),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/models/routing-eval")
.header("content-type", "application/json")
.body(Body::from(
r#"{"limit":100,"include_verdicts":true,"cost_aware":false}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert!(body["rows_considered"].as_u64().unwrap_or(0) >= 1);
assert!(body["summary"]["total_rows"].as_u64().unwrap_or(0) >= 1);
assert!(body["verdicts"].is_array());
}
#[tokio::test]
async fn routing_eval_endpoint_rejects_invalid_weights() {
let state = test_state();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/models/routing-eval")
.header("content-type", "application/json")
.body(Body::from(
r#"{"cost_weight":1.3,"accuracy_floor":-0.2,"accuracy_min_obs":0}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn routing_dataset_endpoint_rejects_invalid_since_format() {
let state = test_state();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/models/routing-dataset?since=not-a-date")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn routing_eval_endpoint_rejects_invalid_until_format() {
let state = test_state();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/models/routing-eval")
.header("content-type", "application/json")
.body(Body::from(r#"{"until":"bad-date"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn routing_eval_endpoint_rejects_malformed_candidates_json() {
let state = test_state();
let evt = roboticus_db::model_selection::ModelSelectionEventRow {
id: "mse-eval-bad-candidates".into(),
turn_id: "turn-eval-bad-candidates".into(),
session_id: "sess-eval-bad-candidates".into(),
agent_id: "agent-eval".into(),
channel: "cli".into(),
selected_model: "ollama/qwen3:8b".into(),
strategy: "metascore".into(),
primary_model: "ollama/qwen3:8b".into(),
override_model: None,
complexity: Some("0.4".into()),
user_excerpt: "eval malformed candidates".into(),
candidates_json: "this-is-not-json".into(),
created_at: "2025-01-01T00:00:00".into(),
schema_version: roboticus_db::model_selection::ROUTING_SCHEMA_VERSION,
attribution: Some("unit-test".into()),
metascore_json: None,
features_json: None,
};
roboticus_db::model_selection::record_model_selection_event(&state.db, &evt).unwrap();
roboticus_db::metrics::record_inference_cost(
&state.db,
"ollama/qwen3:8b",
"ollama",
50,
25,
0.001,
Some("T1"),
false,
Some(80),
Some(0.5),
false,
Some("turn-eval-bad-candidates"),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/models/routing-eval")
.header("content-type", "application/json")
.body(Body::from(
r#"{"limit":50000,"since":"2025-01-01","until":"2025-01-02"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn put_turn_feedback_updates_grade() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-fb", None).unwrap();
let tid = roboticus_db::sessions::create_turn(
&state.db,
&sid,
Some("claude-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
roboticus_db::sessions::record_feedback(&state.db, &tid, &sid, 3, "dashboard", Some("ok"))
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/turns/{tid}/feedback"))
.header("content-type", "application/json")
.body(Body::from(r#"{"grade":5,"comment":"great"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["grade"], 5);
assert_eq!(body["updated"], true);
}
#[tokio::test]
async fn put_turn_feedback_rejects_invalid_grade() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/turns/any-turn/feedback")
.header("content-type", "application/json")
.body(Body::from(r#"{"grade":0}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn get_session_feedback_empty() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-fb-empty", None).unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/feedback"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["feedback"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn get_session_feedback_with_entries() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-fb2", None).unwrap();
let t1 =
roboticus_db::sessions::create_turn(&state.db, &sid, None, Some(10), Some(5), Some(0.001))
.unwrap();
let t2 =
roboticus_db::sessions::create_turn(&state.db, &sid, None, Some(20), Some(10), Some(0.002))
.unwrap();
roboticus_db::sessions::record_feedback(&state.db, &t1, &sid, 4, "dashboard", None).unwrap();
roboticus_db::sessions::record_feedback(&state.db, &t2, &sid, 2, "dashboard", Some("bad"))
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/feedback"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["feedback"].as_array().unwrap().len(), 2);
}
#[tokio::test]
async fn get_turn_context_found() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-ctx", None).unwrap();
let tid = roboticus_db::sessions::create_turn(
&state.db,
&sid,
Some("claude-4"),
Some(500),
Some(200),
Some(0.05),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/turns/{tid}/context"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["turn_id"], tid);
assert_eq!(body["tokens_in"], 500);
assert_eq!(body["tokens_out"], 200);
assert_eq!(body["tool_call_count"], 0);
assert_eq!(body["tool_failure_count"], 0);
}
#[tokio::test]
async fn get_turn_context_not_found() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/nonexistent/context")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_turn_tools_returns_empty_list() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-tools", None).unwrap();
let tid =
roboticus_db::sessions::create_turn(&state.db, &sid, None, Some(10), Some(5), Some(0.001))
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/turns/{tid}/tools"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["tool_calls"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn get_turn_tips_found() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-tips", None).unwrap();
let tid = roboticus_db::sessions::create_turn(
&state.db,
&sid,
Some("claude-4"),
Some(100),
Some(50),
Some(0.01),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/turns/{tid}/tips"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["turn_id"], tid);
assert!(body["tips"].is_array());
assert!(body["tip_count"].is_number());
}
#[tokio::test]
async fn get_turn_tips_not_found() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/turns/nonexistent/tips")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_session_insights_empty() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-insights", None).unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/insights"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["session_id"], sid);
assert!(body["insights"].is_array());
assert_eq!(body["turn_count"], 0);
}
#[tokio::test]
async fn get_session_insights_with_turns() {
let state = test_state();
let sid = roboticus_db::sessions::create_new(&state.db, "agent-insights2", None).unwrap();
roboticus_db::sessions::create_turn(
&state.db,
&sid,
Some("claude-4"),
Some(1000),
Some(500),
Some(0.1),
)
.unwrap();
roboticus_db::sessions::create_turn(
&state.db,
&sid,
Some("gpt-4"),
Some(2000),
Some(1000),
Some(0.2),
)
.unwrap();
let app = build_router(state);
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/sessions/{sid}/insights"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["turn_count"], 2);
}
#[tokio::test]
async fn telegram_webhook_not_configured() {
let app = build_public_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/webhooks/telegram")
.header("content-type", "application/json")
.body(Body::from(r#"{"update_id":1}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = json_body(resp).await;
assert_eq!(body["status"], 503);
assert!(body["detail"].as_str().unwrap().contains("Telegram"));
}
#[tokio::test]
async fn whatsapp_verify_not_configured() {
let app = build_public_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=abc&hub.challenge=test123")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn whatsapp_webhook_not_configured() {
let app = build_public_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/webhooks/whatsapp")
.header("content-type", "application/json")
.body(Body::from(r#"{"entry":[]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn dead_letters_empty() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.uri("/api/channels/dead-letter")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["count"], 0);
}
#[tokio::test]
async fn replay_dead_letter_not_found() {
let app = build_router(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/channels/dead-letter/fake-id/replay")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn protected_route_returns_401_with_wrong_api_key() {
use crate::auth::ApiKeyLayer;
let state = test_state();
let app = build_router(state).layer(ApiKeyLayer::new(Some("correct-key".into())));
let req = Request::builder()
.uri("/api/sessions")
.header("x-api-key", "wrong-key")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn no_api_key_configured_allows_all_requests() {
use crate::auth::ApiKeyLayer;
let state = test_state();
let app = build_router(state).layer(ApiKeyLayer::new(None));
let req = Request::builder()
.uri("/api/health")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn auth_middleware_works_with_post_requests() {
use crate::auth::ApiKeyLayer;
let state = test_state();
let app = build_router(state).layer(ApiKeyLayer::new(Some("post-test-key".into())));
let req = Request::builder()
.method("POST")
.uri("/api/sessions")
.header("content-type", "application/json")
.body(Body::from(r#"{"agent_id":"test"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn auth_middleware_post_with_correct_key() {
use crate::auth::ApiKeyLayer;
let state = test_state();
let app = build_router(state).layer(ApiKeyLayer::new(Some("post-test-key".into())));
let req = Request::builder()
.method("POST")
.uri("/api/sessions")
.header("content-type", "application/json")
.header("x-api-key", "post-test-key")
.body(Body::from(r#"{"agent_id":"test"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn stream_rejects_empty_content() {
let app = build_router(test_state());
let req = Request::builder()
.method("POST")
.uri("/api/agent/message/stream")
.header("content-type", "application/json")
.body(Body::from(r#"{"content":" "}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = json_body(resp).await;
assert!(body["detail"].as_str().unwrap().contains("empty"));
}
#[tokio::test]
async fn stream_rejects_oversized_content() {
let app = build_router(test_state());
let huge = "x".repeat(33_000);
let payload = serde_json::json!({"content": huge}).to_string();
let req = Request::builder()
.method("POST")
.uri("/api/agent/message/stream")
.header("content-type", "application/json")
.body(Body::from(payload))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn stream_rejects_missing_content_field() {
let app = build_router(test_state());
let req = Request::builder()
.method("POST")
.uri("/api/agent/message/stream")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert!(
resp.status() == StatusCode::BAD_REQUEST
|| resp.status() == StatusCode::UNPROCESSABLE_ENTITY,
);
}
#[tokio::test]
async fn archive_session_endpoint_works() {
let state = test_state();
let session_id =
roboticus_db::sessions::find_or_create(&state.db, "test-archive-agent", None).unwrap();
let app = build_router(state.clone());
let req = Request::builder()
.method("POST")
.uri(format!("/api/sessions/{session_id}/archive"))
.header("content-type", "application/json")
.body(Body::from(r#"{"reason":"test"}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = json_body(resp).await;
assert_eq!(body["archived"], true);
assert_eq!(body["session_id"].as_str().unwrap(), session_id);
let session = roboticus_db::sessions::get_session(&state.db, &session_id)
.unwrap()
.unwrap();
assert_eq!(session.status, "archived");
}
#[tokio::test]
async fn archive_session_not_found() {
let app = build_router(test_state());
let req = Request::builder()
.method("POST")
.uri("/api/sessions/nonexistent-session-id/archive")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}