#![allow(
clippy::missing_docs_in_private_items,
reason = "test helpers and fixtures do not need doc comments"
)]
use axum::{
body::Body,
http::{header::CONTENT_TYPE, Request, StatusCode},
};
use tower::ServiceExt;
use super::build_app;
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");
}
}
}
#[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()
.uri(format!("/api/v1/routines/{id}/prompt-preview"))
.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 preview = String::from_utf8(bytes.to_vec()).unwrap();
assert!(preview.contains("- r (branch main)\n"));
assert!(preview.trim_end().ends_with('p'));
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()
.uri(format!("/api/v1/routines/{id}/runs"))
.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 runs: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(runs, serde_json::json!([]));
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.uri("/api/v1/routines/runs")
.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 runs: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(runs, serde_json::json!([]));
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.uri("/api/v1/routines/runs?limit=5")
.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}/runs/not-a-real-workbench-1/log"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let resp = build_app(routines.clone())
.oneshot(
Request::builder()
.uri(format!(
"/api/v1/routines/{id}/runs/not-a-real-workbench-1/summary"
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
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_routine_not_found_paths() {
for (method, suffix) in [
("GET", ""),
("DELETE", ""),
("POST", "/trigger"),
("POST", "/scheduled-trigger"),
("GET", "/prompt-preview"),
("GET", "/logs"),
("GET", "/runs"),
("GET", "/runs/some-workbench-1/log"),
("GET", "/runs/some-workbench-1/summary"),
] {
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);
}