use axum::{Json, Router, extract::State, http::StatusCode, routing::get};
use serde::Deserialize;
use serde_json::json;
use std::sync::atomic::Ordering;
use crate::AppState;
const KEY_ACP_IDLE_RECYCLE_MIN: &str = "acp_idle_recycle_min";
const MIN_ACP_IDLE_RECYCLE_MIN: u64 = 1;
const MAX_ACP_IDLE_RECYCLE_MIN: u64 = 60;
const DEFAULT_ACP_IDLE_RECYCLE_MIN: u64 = 5;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/settings/acp-idle-recycle", get(get_acp_idle_recycle).put(set_acp_idle_recycle))
}
#[derive(Deserialize)]
struct SetAcpIdleRecycleRequest {
minutes: u64,
}
async fn get_acp_idle_recycle(
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let value: Option<String> = sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_IDLE_RECYCLE_MIN
))
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let minutes = value
.as_deref()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_ACP_IDLE_RECYCLE_MIN);
Ok(Json(json!({ "minutes": minutes })))
}
async fn set_acp_idle_recycle(
State(state): State<AppState>,
Json(req): Json<SetAcpIdleRecycleRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
if !(MIN_ACP_IDLE_RECYCLE_MIN..=MAX_ACP_IDLE_RECYCLE_MIN).contains(&req.minutes) {
return Err(StatusCode::BAD_REQUEST);
}
sqlx::query(&format!(
"INSERT INTO settings (key, value) VALUES ('{}', ?) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
KEY_ACP_IDLE_RECYCLE_MIN
))
.bind(req.minutes.to_string())
.execute(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
state.acp_idle_recycle_secs.store(req.minutes * 60, Ordering::Relaxed);
Ok(Json(json!({ "minutes": req.minutes })))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::AcpSupervisor;
use crate::auth::LoginGuard;
use crate::engine::EngineRegistry;
use sqlx::sqlite::SqlitePoolOptions;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64};
async fn test_state() -> AppState {
let db = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("in-memory sqlite pool");
sqlx::migrate!("./migrations").run(&db).await.expect("run migrations");
AppState {
jwt_secret: "test-secret".into(),
api_keys: HashMap::new(),
auth_enabled: Arc::new(AtomicBool::new(false)),
acp_idle_recycle_secs: Arc::new(AtomicU64::new(300)),
login_guard: LoginGuard::new(),
engines: EngineRegistry::new(db.clone()),
acp_supervisor: AcpSupervisor::default(),
db,
}
}
async fn db_value(db: &sqlx::SqlitePool) -> Option<String> {
sqlx::query_scalar::<_, String>(&format!(
"SELECT value FROM settings WHERE key = '{}'",
KEY_ACP_IDLE_RECYCLE_MIN
))
.fetch_optional(db)
.await
.expect("query settings")
}
#[tokio::test]
async fn get_without_record_returns_default_5() {
let state = test_state().await;
let res = get_acp_idle_recycle(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "minutes": 5 }));
}
#[tokio::test]
async fn get_with_unparseable_record_returns_default_5() {
let state = test_state().await;
sqlx::query(&format!(
"INSERT INTO settings (key, value) VALUES ('{}', 'abc')",
KEY_ACP_IDLE_RECYCLE_MIN
))
.execute(&state.db)
.await
.expect("seed");
let res = get_acp_idle_recycle(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "minutes": 5 }));
}
#[tokio::test]
async fn put_valid_value_persists_and_updates_memory() {
let state = test_state().await;
let res = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 10 }),
)
.await
.expect("put ok");
assert_eq!(res.0, json!({ "minutes": 10 }));
assert_eq!(db_value(&state.db).await.as_deref(), Some("10"));
assert_eq!(state.acp_idle_recycle_secs.load(Ordering::Relaxed), 600);
}
#[tokio::test]
async fn put_out_of_range_rejects_and_keeps_db_unchanged() {
let state = test_state().await;
let _ = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 10 }),
)
.await
.expect("put ok");
for bad in [0, 61] {
let err = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: bad }),
)
.await
.expect_err("should reject");
assert_eq!(err, StatusCode::BAD_REQUEST);
}
assert_eq!(db_value(&state.db).await.as_deref(), Some("10"));
assert_eq!(state.acp_idle_recycle_secs.load(Ordering::Relaxed), 600);
}
#[tokio::test]
async fn put_then_get_returns_updated_value() {
let state = test_state().await;
let _ = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 20 }),
)
.await
.expect("put ok");
let res = get_acp_idle_recycle(State(state)).await.expect("get ok");
assert_eq!(res.0, json!({ "minutes": 20 }));
}
#[tokio::test]
async fn put_overwrites_existing_value() {
let state = test_state().await;
let _ = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 10 }),
)
.await
.expect("put ok");
let _ = set_acp_idle_recycle(
State(state.clone()),
Json(SetAcpIdleRecycleRequest { minutes: 30 }),
)
.await
.expect("put ok");
assert_eq!(db_value(&state.db).await.as_deref(), Some("30"));
assert_eq!(state.acp_idle_recycle_secs.load(Ordering::Relaxed), 1800);
}
}