#![allow(clippy::missing_docs_in_private_items)]
use axum::{
body::Body,
http::{header::CONTENT_TYPE, Request, StatusCode},
routing::post,
Router,
};
use tower::ServiceExt;
use super::{build_app, echo, health, run_with_listener_until, write_openapi_spec, AppState};
use crate::utils::time::now_secs;
struct SucceedingCronShim {
base: std::path::PathBuf,
previous: Option<std::ffi::OsString>,
}
impl SucceedingCronShim {
fn new() -> Self {
use std::os::unix::fs::PermissionsExt;
let base = std::env::temp_dir().join(format!("moadim-httpcshim-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&base).unwrap();
let store = base.join("store");
std::fs::write(&store, "").unwrap();
let store_display = store.to_string_lossy().into_owned();
let script = base.join("crontab-ok.sh");
std::fs::write(
&script,
format!(
"#!/bin/sh\nSTORE=\"{store_display}\"\nif [ \"$1\" = \"-l\" ]; then cat \"$STORE\"; elif [ \"$1\" = \"-\" ]; then cat > \"$STORE\"; fi\n"
),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let previous = std::env::var_os("MOADIM_CRONTAB_BIN");
unsafe {
std::env::set_var("MOADIM_CRONTAB_BIN", &script);
}
Self { base, previous }
}
}
impl Drop for SucceedingCronShim {
fn drop(&mut self) {
unsafe {
match self.previous.take() {
Some(val) => std::env::set_var("MOADIM_CRONTAB_BIN", val),
None => std::env::remove_var("MOADIM_CRONTAB_BIN"),
}
}
let _ = std::fs::remove_dir_all(&self.base);
}
}
struct TempHome;
impl TempHome {
fn set() -> Self {
let dir = std::env::temp_dir().join(format!("moadim-httptest-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).expect("create temp home");
unsafe {
std::env::set_var("MOADIM_HOME_OVERRIDE", &dir);
}
Self
}
}
impl Drop for TempHome {
fn drop(&mut self) {
unsafe {
std::env::remove_var("MOADIM_HOME_OVERRIDE");
}
}
}
#[test]
fn write_openapi_spec_writes_json_to_path() {
let dir = std::env::temp_dir().join(format!("moadim-openapi-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("openapi.json");
write_openapi_spec(&path);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("openapi"), "spec JSON should be written");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_openapi_spec_logs_on_write_failure() {
let dir = std::env::temp_dir().join(format!("moadim-openapi-fail-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let blocker = dir.join("blocker");
std::fs::write(&blocker, "i am a file").unwrap();
let unwritable = blocker.join("openapi.json");
write_openapi_spec(&unwritable);
assert!(!unwritable.exists(), "the write should have failed");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn put_machine_updates_name() {
let dir = std::env::temp_dir().join(format!("moadim-machine-put-{}", uuid::Uuid::new_v4()));
std::env::set_var("MOADIM_HOME_OVERRIDE", &dir);
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/machine")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"name":"my-box"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["name"].as_str().unwrap(), "my-box");
let _ = std::fs::remove_dir_all(&dir);
std::env::remove_var("MOADIM_HOME_OVERRIDE");
}
#[tokio::test]
async fn put_machine_rejects_empty_name() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/machine")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"name":" "}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn put_machine_returns_500_on_write_failure() {
let dir = std::env::temp_dir().join(format!("moadim-machine-fail-{}", uuid::Uuid::new_v4()));
std::fs::write(&dir, b"").unwrap();
std::env::set_var("MOADIM_HOME_OVERRIDE", &dir);
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/v1/machine")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"name":"new-name"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
let _ = std::fs::remove_file(&dir);
std::env::remove_var("MOADIM_HOME_OVERRIDE");
}
#[tokio::test]
async fn build_app_serves_machine() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/machine")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(body["name"].is_string() && !body["name"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn build_app_serves_root() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn build_app_compresses_root_with_gzip() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/")
.header(axum::http::header::ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get(axum::http::header::CONTENT_ENCODING)
.unwrap(),
"gzip"
);
}
#[tokio::test]
async fn build_app_serves_root_uncompressed_without_accept_encoding() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp
.headers()
.get(axum::http::header::CONTENT_ENCODING)
.is_none());
}
#[tokio::test]
async fn build_app_serves_root_with_etag() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let etag = resp
.headers()
.get(axum::http::header::ETAG)
.expect("ETag header present")
.to_str()
.unwrap()
.to_owned();
assert!(etag.starts_with('"') && etag.ends_with('"'));
assert_eq!(
resp.headers()
.get(axum::http::header::CACHE_CONTROL)
.unwrap(),
"no-cache"
);
}
#[tokio::test]
async fn build_app_returns_304_when_if_none_match_matches() {
let app = build_app(crate::routines::new_store());
let first = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let etag = first
.headers()
.get(axum::http::header::ETAG)
.unwrap()
.to_str()
.unwrap()
.to_owned();
let resp = app
.oneshot(
Request::builder()
.uri("/")
.header(axum::http::header::IF_NONE_MATCH, &etag)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
assert_eq!(
resp.headers().get(axum::http::header::ETAG).unwrap(),
etag.as_str()
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(body.is_empty(), "304 response must not carry a body");
}
#[tokio::test]
async fn build_app_serves_root_when_if_none_match_stale() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/")
.header(axum::http::header::IF_NONE_MATCH, "\"not-the-real-etag\"")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn build_app_sets_security_headers_on_ui_and_api() {
for uri in ["/", "/api/v1/health"] {
let resp = build_app(crate::routines::new_store())
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
assert_eq!(
resp.headers().get("x-content-type-options").unwrap(),
"nosniff"
);
assert_eq!(
resp.headers().get("referrer-policy").unwrap(),
"no-referrer"
);
assert_eq!(
resp.headers().get("content-security-policy").unwrap(),
"default-src 'self'; \
script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
font-src 'self' https://fonts.gstatic.com; \
img-src 'self' data:; \
connect-src 'self'; \
base-uri 'none'; \
form-action 'none'; \
object-src 'none'; \
frame-ancestors 'none'"
);
}
}
#[tokio::test]
async fn build_app_serves_agents() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/agents")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let agents: Vec<String> = serde_json::from_slice(&bytes).unwrap();
assert!(!agents.is_empty(), "agents list should never be empty");
}
#[tokio::test]
async fn build_app_serves_machines() {
let routines = crate::routines::new_store();
routines.lock().unwrap().insert(
"r1".to_string(),
crate::routines::Routine {
model: None,
id: "r1".to_string(),
schedule: "@daily".to_string(),
title: "R".to_string(),
agent: "claude".to_string(),
prompt: "p".to_string(),
repositories: vec![],
machines: vec!["alpha-box".to_string(), "shared".to_string()],
tags: vec![],
enabled: true,
source: "managed".to_string(),
created_at: 0,
updated_at: 0,
last_manual_trigger_at: None,
last_scheduled_trigger_at: None,
ttl_secs: None,
max_runtime_secs: None,
},
);
let resp = build_app(routines)
.oneshot(
Request::builder()
.uri("/api/v1/machines")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let machines: Vec<String> = serde_json::from_slice(&bytes).unwrap();
let mut expected = vec![
crate::machine::current_machine(),
"alpha-box".to_string(),
"shared".to_string(),
];
expected.sort();
expected.dedup();
assert_eq!(machines, expected);
}
#[tokio::test]
async fn build_app_serves_health() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
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["running"], true);
assert!(
json["dependencies"]["tmux"].is_boolean(),
"health payload should carry a boolean dependencies.tmux flag, got: {json}"
);
assert_eq!(json["version"], env!("CARGO_PKG_VERSION"));
assert!(
json["machine"].is_string() && !json["machine"].as_str().unwrap().is_empty(),
"health payload should carry a non-empty machine name, got: {json}"
);
}
#[tokio::test]
async fn build_app_serves_ui_at_root() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp.headers().get(CONTENT_TYPE).unwrap();
assert!(ctype.to_str().unwrap().starts_with("text/html"));
}
#[tokio::test]
async fn build_app_redirects_ui_to_root() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(Request::builder().uri("/ui").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(resp.headers().get("location").unwrap(), "/");
}
#[tokio::test]
async fn build_app_spa_fallback_serves_ui_on_client_routes() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.uri("/routines")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ctype = resp.headers().get(CONTENT_TYPE).unwrap();
assert!(ctype.to_str().unwrap().starts_with("text/html"));
}
#[tokio::test]
async fn router_unknown_api_path_returns_json_404_not_spa() {
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.uri("/api/v1/bogus")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let ctype = resp.headers().get(CONTENT_TYPE).unwrap();
assert!(ctype.to_str().unwrap().starts_with("application/json"));
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], "not found");
}
#[tokio::test]
async fn router_unknown_api_path_non_get_returns_404() {
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/bogus")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn router_routines_cleanup_returns_removed_count() {
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/routines/cleanup")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(val["removed"].is_u64());
}
#[tokio::test]
async fn echo_returns_message_and_timestamp() {
let app = Router::new().route("/echo", post(echo));
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/echo")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"message":"hello"}"#))
.unwrap(),
)
.await
.unwrap();
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["message"], "hello");
assert!(json["timestamp"].as_u64().is_some());
}
#[tokio::test]
async fn echo_rejects_invalid_json() {
let app = Router::new().route("/echo", post(echo));
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/echo")
.header(CONTENT_TYPE, "application/json")
.body(Body::from("not-json"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn echo_rejects_missing_message_field() {
let app = Router::new().route("/echo", post(echo));
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/echo")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"other":"field"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn router_routine_full_lifecycle() {
let _home = TempHome::set();
let routines = crate::routines::new_store();
let body = r#"{"schedule":"@daily","title":"Http Routine","agent":"claude","prompt":"p","repositories":[{"repository":"r","branch":"main"}]}"#;
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/routines")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let created: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let id = created["id"].as_str().unwrap().to_string();
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.uri("/api/v1/routines")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.uri(format!("/api/v1/routines/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/api/v1/routines/{id}"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"title":"Patched"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("PUT")
.uri(format!("/api/v1/routines/{id}"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"prompt":"replaced"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/routines/{id}/trigger"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/routines/{id}/scheduled-trigger"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.uri(format!("/api/v1/routines/{id}/logs"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/routines/{id}/flags"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"type":"bug","description":"broken thing","scope":"general"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let flag: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let filename = flag["filename"].as_str().unwrap().to_string();
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.uri(format!("/api/v1/routines/{id}/flags"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let flags: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(flags.as_array().unwrap().len(), 1);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/routines/{id}/flags/{filename}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/api/v1/routines/{id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(!crate::paths::routine_dir(&id).exists());
}
#[tokio::test]
async fn router_flag_create_rejects_bad_scope() {
let _home = TempHome::set();
let routines = crate::routines::new_store();
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/routines")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"schedule":"@daily","title":"Flag Scope Routine","agent":"claude","prompt":"p"}"#,
))
.unwrap(),
)
.await
.unwrap();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let id = serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/v1/routines/{id}/flags"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"type":"bug","description":"d","scope":"nowhere"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn router_flag_not_found_paths() {
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.uri("/api/v1/routines/no-such/flags")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/routines/no-such/flags/bug-1.md")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn router_routine_create_invalid_cron_400() {
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/routines")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"schedule":"bad","title":"t","agent":"a","prompt":"p"}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn router_routine_not_found_paths() {
for (method, suffix) in [
("GET", ""),
("DELETE", ""),
("POST", "/trigger"),
("POST", "/scheduled-trigger"),
("GET", "/logs"),
] {
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method(method)
.uri(format!("/api/v1/routines/no-such{suffix}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND, "{method} {suffix}");
}
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("PATCH")
.uri("/api/v1/routines/no-such")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"title":"x"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn run_with_listener_serves_over_tcp() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(run_with_listener_until(
crate::routines::new_store(),
listener,
std::future::pending(),
));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.unwrap();
stream
.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut buf = vec![0u8; 512];
let n = stream.read(&mut buf).await.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(response.starts_with("HTTP/1.1 200"), "got: {response}");
handle.abort();
}
#[tokio::test]
async fn build_app_shutdown_route_acknowledges() {
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/shutdown")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
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"], "shutting down");
}
#[tokio::test]
async fn build_app_restart_route_acknowledges() {
let _home = TempHome::set();
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/restart")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
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"], "restarting");
assert!(json["helper_pid"].as_u64().unwrap() > 0);
}
#[tokio::test]
async fn shutdown_route_stops_the_serving_loop() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(run_with_listener_until(
crate::routines::new_store(),
listener,
std::future::pending(),
));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.unwrap();
stream
.write_all(
b"POST /api/v1/shutdown HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
let mut buf = vec![0u8; 512];
let n = stream.read(&mut buf).await.unwrap();
assert!(
String::from_utf8_lossy(&buf[..n]).starts_with("HTTP/1.1 200"),
"shutdown should be acknowledged"
);
let joined = tokio::time::timeout(std::time::Duration::from_secs(5), handle).await;
assert!(joined.is_ok(), "server did not shut down after /shutdown");
assert!(joined.unwrap().unwrap().is_ok());
}
#[tokio::test]
async fn run_with_listener_until_exits_on_immediate_shutdown() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let result = run_with_listener_until(crate::routines::new_store(), listener, async {}).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn mcp_endpoint_triggers_factory() {
let app = build_app(crate::routines::new_store());
let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#;
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/mcp")
.header(CONTENT_TYPE, "application/json")
.header("accept", "application/json, text/event-stream")
.header("host", "localhost")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status().as_u16() < 500);
}
#[tokio::test]
async fn router_serves_routines_ical_feed() {
let routines = crate::routines::new_store();
routines.lock().unwrap().insert(
"r1".to_string(),
crate::routines::Routine {
model: None,
id: "r1".to_string(),
schedule: "@daily".to_string(),
title: "My Routine".to_string(),
agent: "claude".to_string(),
prompt: "do the thing".to_string(),
repositories: vec![],
machines: vec![crate::machine::current_machine()],
enabled: true,
source: "managed".to_string(),
created_at: 0,
updated_at: 0,
last_manual_trigger_at: None,
last_scheduled_trigger_at: None,
tags: vec![],
ttl_secs: None,
max_runtime_secs: None,
},
);
let resp = build_app(routines)
.oneshot(
Request::builder()
.uri("/api/v1/routines.ics")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(CONTENT_TYPE).unwrap(),
"text/calendar; charset=utf-8"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.starts_with("BEGIN:VCALENDAR"));
assert!(body.contains("BEGIN:VEVENT"));
assert!(body.contains("SUMMARY:My Routine"));
}
#[tokio::test]
async fn get_lock_status_returns_unlocked_by_default() {
let _home = TempHome::set();
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.uri("/api/v1/routines/lock")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
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["shared"], false);
assert_eq!(json["local"], false);
assert_eq!(json["locked"], false);
}
#[tokio::test]
async fn lock_route_creates_sentinel_and_returns_status() {
let _home = TempHome::set();
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/routines/lock")
.header("content-type", "application/json")
.body(Body::from(r#"{"scope":"shared"}"#))
.unwrap(),
)
.await
.unwrap();
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["shared"], true);
assert_eq!(json["locked"], true);
crate::global_lock::set_lock(crate::global_lock::LockScope::Shared, false).unwrap();
}
#[tokio::test]
async fn lock_route_unknown_scope_is_bad_request() {
let _home = TempHome::set();
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/routines/lock")
.header("content-type", "application/json")
.body(Body::from(r#"{"scope":"global"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn unlock_route_removes_sentinel_and_returns_status() {
let _home = TempHome::set();
crate::global_lock::set_lock(crate::global_lock::LockScope::Local, true).unwrap();
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/routines/lock?scope=local")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
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["local"], false);
assert_eq!(json["locked"], false);
}
#[tokio::test]
async fn unlock_route_all_removes_both_sentinels() {
let _home = TempHome::set();
crate::global_lock::set_lock(crate::global_lock::LockScope::Shared, true).unwrap();
crate::global_lock::set_lock(crate::global_lock::LockScope::Local, true).unwrap();
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/routines/lock?scope=all")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
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["shared"], false);
assert_eq!(json["local"], false);
assert_eq!(json["locked"], false);
}
#[tokio::test]
async fn lock_route_sync_success_path() {
let _home = TempHome::set();
let _shim = SucceedingCronShim::new();
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/routines/lock")
.header("content-type", "application/json")
.body(Body::from(r#"{"scope":"local"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
crate::global_lock::set_lock(crate::global_lock::LockScope::Local, false).unwrap();
}
#[tokio::test]
async fn unlock_route_sync_success_path() {
let _home = TempHome::set();
let _shim = SucceedingCronShim::new();
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/routines/lock?scope=all")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn unlock_route_unknown_scope_is_bad_request() {
let _home = TempHome::set();
let resp = build_app(crate::routines::new_store())
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/v1/routines/lock?scope=everything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn router_serves_per_routine_ical_feed_via_query() {
let routines = crate::routines::new_store();
let mk = |id: &str, title: &str| crate::routines::Routine {
id: id.to_string(),
schedule: "@daily".to_string(),
title: title.to_string(),
agent: "claude".to_string(),
model: None,
prompt: "do the thing".to_string(),
repositories: vec![],
enabled: true,
source: "managed".to_string(),
created_at: 0,
updated_at: 0,
last_manual_trigger_at: None,
last_scheduled_trigger_at: None,
machines: vec![],
tags: vec![],
ttl_secs: None,
max_runtime_secs: None,
};
{
let mut lock = routines.lock().unwrap();
lock.insert("a".to_string(), mk("a", "Routine A"));
lock.insert("b".to_string(), mk("b", "Routine B"));
}
let fetch = |uri: &'static str| {
let app = build_app(routines.clone());
async move {
let resp = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
};
let filtered = fetch("/api/v1/routines.ics?routine=a").await;
assert!(filtered.contains("UID:a-"));
assert!(!filtered.contains("UID:b-"));
assert!(filtered.contains("X-WR-CALNAME:Routine A\r\n"));
let unknown = fetch("/api/v1/routines.ics?routine=missing").await;
assert!(unknown.starts_with("BEGIN:VCALENDAR"));
assert!(unknown.ends_with("END:VCALENDAR\r\n"));
assert_eq!(unknown.matches("BEGIN:VEVENT").count(), 0);
}
#[tokio::test]
async fn health_uptime_clamps_to_zero_on_backward_clock_skew() {
let state = AppState {
routines: crate::routines::new_store(),
uptime_start: now_secs() + 10_000,
shutdown: std::sync::Arc::new(tokio::sync::Notify::new()),
};
let resp = health(axum::extract::State(state)).await;
assert_eq!(resp.0.uptime_secs, 0);
assert_eq!(resp.0.status, "ok");
assert!(resp.0.running);
}
#[tokio::test]
async fn build_app_restart_route_returns_500_when_spawn_fails() {
let dir = std::env::temp_dir().join(format!("moadim-restart-fail-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".config"), b"blocker").unwrap();
unsafe {
std::env::set_var("MOADIM_HOME_OVERRIDE", &dir);
}
let app = build_app(crate::routines::new_store());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/restart")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
unsafe {
std::env::remove_var("MOADIM_HOME_OVERRIDE");
}
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"restart route should return 500 when spawn_restart fails"
);
}