#[tokio::test]
async fn index_returns_html() {
let app = test_app().await;
let resp = get_req(app, "/").await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(ct.contains("text/html"));
}
#[tokio::test]
async fn sse_endpoint_returns_200() {
let app = test_app().await;
let resp = app
.oneshot(
Request::builder()
.uri("/sse")
.header("Accept", "text/event-stream")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(ct.contains("text/event-stream"));
}
#[tokio::test]
async fn stats_has_all_fields() {
let app = test_app().await;
let resp = get_req(app, "/api/stats").await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(json.get("running").is_some());
assert!(json.get("queued").is_some());
assert!(json.get("succeeded").is_some());
assert!(json.get("failed").is_some());
assert!(json.get("uptime_secs").is_some());
}
#[tokio::test]
async fn create_task_accepts_low_priority() {
let app = test_app().await;
let body = serde_json::to_string(&serde_json::json!({
"goal": "low priority maintenance",
"priority": "low",
}))
.unwrap();
let resp = send_req(app, "POST", "/api/tasks", Some(body)).await;
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn create_task_accepts_normal_priority() {
let app = test_app().await;
let body = serde_json::to_string(&serde_json::json!({
"goal": "normal priority work",
"priority": "normal",
}))
.unwrap();
let resp = send_req(app, "POST", "/api/tasks", Some(body)).await;
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn tasks_list_response_has_tasks_array() {
let app = test_app().await;
let resp = get_req(app, "/api/tasks").await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(json["tasks"].is_array());
}
#[tokio::test]
async fn metrics_has_all_metric_names() {
let app = test_app().await;
let resp = get_req(app, "/metrics").await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert!(body.contains("lopi_agents_running"));
assert!(body.contains("lopi_agents_queued"));
assert!(body.contains("lopi_tasks_succeeded_total"));
assert!(body.contains("lopi_tasks_failed_total"));
assert!(body.contains("lopi_uptime_seconds"));
assert!(body.contains("lopi_audit_log_total"));
}
#[tokio::test]
async fn health_response_has_service_field() {
let app = test_app().await;
let resp = get_req(app, "/api/health").await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["status"], "ok");
assert_eq!(json["service"], "lopi");
}
#[tokio::test]
async fn create_task_response_has_queued_field() {
let app = test_app().await;
let body = serde_json::to_string(&serde_json::json!({
"goal": "check queued field",
}))
.unwrap();
let resp = send_req(app, "POST", "/api/tasks", Some(body)).await;
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(json.get("queued").is_some());
assert!(json.get("id").is_some());
assert!(json.get("goal").is_some());
}
#[tokio::test]
async fn create_task_with_repo_returns_201() {
let app = test_app().await;
let body = serde_json::to_string(&serde_json::json!({
"goal": "task with repo override",
"repo": "/tmp/myrepo",
}))
.unwrap();
let resp = send_req(app, "POST", "/api/tasks", Some(body)).await;
assert_eq!(resp.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn duplicate_task_returns_201_with_duplicate_of() {
let app = test_app().await;
let goal = "deduplicated task goal unique xyz";
let body = serde_json::to_string(&serde_json::json!({
"goal": goal,
}))
.unwrap();
let resp1 = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/tasks")
.header("Content-Type", "application/json")
.body(Body::from(body.clone()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp1.status(), StatusCode::CREATED);
let bytes1 = axum::body::to_bytes(resp1.into_body(), usize::MAX)
.await
.unwrap();
let json1: serde_json::Value = serde_json::from_slice(&bytes1).unwrap();
assert_eq!(json1["queued"], true);
let resp2 = send_req(app, "POST", "/api/tasks", Some(body)).await;
assert_eq!(resp2.status(), StatusCode::CREATED);
let bytes2 = axum::body::to_bytes(resp2.into_body(), usize::MAX)
.await
.unwrap();
let json2: serde_json::Value = serde_json::from_slice(&bytes2).unwrap();
assert!(json2["duplicate_of"].is_string());
}
#[tokio::test]
async fn task_logs_known_task_no_logs_returns_empty_array() {
let (app, store) = test_app_with_store().await;
let task = Task::new("known task, no logs yet");
store.save_task(&task, "running").await.unwrap();
let tid = task.id.0.to_string();
let resp = get_req(app, &format!("/api/tasks/{tid}/logs")).await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["task_id"], tid);
assert_eq!(json["logs"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn task_logs_returns_seeded_rows_oldest_first() {
let (app, store) = test_app_with_store().await;
let task = Task::new("task with logs");
store.save_task(&task, "running").await.unwrap();
let tid = task.id.0.to_string();
let now = chrono::Utc::now();
for (i, level) in ["info", "warn", "info"].iter().enumerate() {
store
.record_task_log(&tid, now, level, &format!("line {i}"))
.await
.unwrap();
}
let resp = get_req(app, &format!("/api/tasks/{tid}/logs?n=10")).await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let logs = json["logs"].as_array().unwrap();
assert_eq!(logs.len(), 3);
assert_eq!(logs[0]["line"], "line 0");
assert_eq!(logs[1]["level"], "warn");
assert_eq!(logs[2]["line"], "line 2");
}
#[tokio::test]
async fn global_logs_empty_store_returns_empty_array() {
let app = test_app().await;
let resp = get_req(app, "/api/logs").await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["logs"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn global_logs_returns_rows_across_tasks_oldest_first() {
let (app, store) = test_app_with_store().await;
let now = chrono::Utc::now();
store.record_task_log("t-a", now, "info", "a1").await.unwrap();
store.record_task_log("t-b", now, "error", "b1").await.unwrap();
store.record_task_log("t-a", now, "info", "a2").await.unwrap();
let resp = get_req(app, "/api/logs?n=2").await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let logs = json["logs"].as_array().unwrap();
assert_eq!(logs.len(), 2);
assert_eq!(logs[0]["task_id"], "t-b");
assert_eq!(logs[0]["level"], "error");
assert_eq!(logs[1]["line"], "a2");
}
#[tokio::test]
async fn agent_rate_limit_zero_per_minute_returns_422() {
let app = test_app().await;
let body = serde_json::to_string(&serde_json::json!({
"max_per_minute": 0,
"max_concurrent": 4,
}))
.unwrap();
let resp = send_req(app, "POST", "/api/agents/alpha/rate-limit", Some(body)).await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn agent_rate_limit_round_trip_register_get_delete() {
let app = test_app().await;
let body = serde_json::to_string(&serde_json::json!({
"max_per_minute": 30,
"max_concurrent": 2,
}))
.unwrap();
let resp = send_req(app.clone(), "POST", "/api/agents/alpha/rate-limit", Some(body)).await;
assert_eq!(resp.status(), StatusCode::CREATED);
let resp = get_req(app.clone(), "/api/agents/alpha/rate-limit").await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["max_per_minute"], 30);
assert_eq!(json["max_concurrent"], 2);
assert_eq!(json["in_flight"], 0);
let resp = send_req(app.clone(), "DELETE", "/api/agents/alpha/rate-limit", None).await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = get_req(app, "/api/agents/alpha/rate-limit").await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}