use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use lightshuttle_control::{ControlServer, ControlState};
use lightshuttle_runtime::{
LifecycleEvent, LifecycleHandle, LifecycleHandleError, LogChunkStream, ResourceStatus,
ResourceView,
};
use tokio::sync::broadcast;
use tower::ServiceExt;
#[derive(Clone, Default)]
struct StubHandle {
resources: Arc<Mutex<Vec<ResourceView>>>,
}
impl StubHandle {
fn with_resources(views: Vec<ResourceView>) -> Self {
Self {
resources: Arc::new(Mutex::new(views)),
}
}
}
impl LifecycleHandle for StubHandle {
async fn list(&self) -> Result<Vec<ResourceView>, LifecycleHandleError> {
Ok(self.resources.lock().expect("stub mutex").clone())
}
async fn get(&self, name: &str) -> Result<ResourceView, LifecycleHandleError> {
self.resources
.lock()
.expect("stub mutex")
.iter()
.find(|v| v.name == name)
.cloned()
.ok_or_else(|| LifecycleHandleError::UnknownResource(name.to_owned()))
}
async fn restart(&self, _name: &str) -> Result<(), LifecycleHandleError> {
Err(LifecycleHandleError::NotSupported("restart"))
}
async fn logs(
&self,
name: &str,
_follow: bool,
) -> Result<LogChunkStream, LifecycleHandleError> {
Err(LifecycleHandleError::UnknownResource(name.to_owned()))
}
fn subscribe_events(&self) -> broadcast::Receiver<LifecycleEvent> {
let (_tx, rx) = broadcast::channel(1);
rx
}
}
fn sample_view(name: &str, kind: &str) -> ResourceView {
ResourceView {
name: name.to_owned(),
kind: kind.to_owned(),
status: ResourceStatus::Running,
healthy: true,
image: format!("{kind}:latest"),
started_at: Some(SystemTime::UNIX_EPOCH),
last_error: None,
}
}
fn build_app(views: Vec<ResourceView>) -> axum::Router {
let handle = StubHandle::with_resources(views);
let state = ControlState::new("demo", handle);
ControlServer::new(state).into_router()
}
#[tokio::test]
async fn list_returns_every_resource_as_json_array() {
let app = build_app(vec![
sample_view("cache", "redis"),
sample_view("db", "postgres"),
]);
let response = app
.oneshot(
Request::get("/api/resources")
.body(Body::empty())
.expect("request builds"),
)
.await
.expect("router responds");
assert_eq!(response.status(), StatusCode::OK);
let bytes = response
.into_body()
.collect()
.await
.expect("body collected")
.to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
let arr = json.as_array().expect("array body");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "cache");
assert_eq!(arr[0]["kind"], "redis");
assert_eq!(arr[0]["status"], "Running");
assert_eq!(arr[1]["name"], "db");
}
#[tokio::test]
async fn list_returns_empty_array_when_stack_is_empty() {
let app = build_app(Vec::new());
let response = app
.oneshot(
Request::get("/api/resources")
.body(Body::empty())
.expect("request builds"),
)
.await
.expect("router responds");
assert_eq!(response.status(), StatusCode::OK);
let bytes = response
.into_body()
.collect()
.await
.expect("body collected")
.to_bytes();
assert_eq!(bytes.as_ref(), b"[]");
}
#[tokio::test]
async fn get_returns_single_view_for_known_resource() {
let app = build_app(vec![sample_view("cache", "redis")]);
let response = app
.oneshot(
Request::get("/api/resources/cache")
.body(Body::empty())
.expect("request builds"),
)
.await
.expect("router responds");
assert_eq!(response.status(), StatusCode::OK);
let bytes = response
.into_body()
.collect()
.await
.expect("body collected")
.to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
assert_eq!(json["name"], "cache");
assert_eq!(json["kind"], "redis");
assert_eq!(json["healthy"], true);
}
#[tokio::test]
async fn get_returns_404_with_error_body_for_unknown_resource() {
let app = build_app(vec![sample_view("cache", "redis")]);
let response = app
.oneshot(
Request::get("/api/resources/nope")
.body(Body::empty())
.expect("request builds"),
)
.await
.expect("router responds");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let bytes = response
.into_body()
.collect()
.await
.expect("body collected")
.to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
assert_eq!(json["error"], "unknown resource");
assert_eq!(json["resource"], "nope");
}
#[tokio::test]
async fn restart_returns_202_for_known_resource() {
let app = build_app(vec![sample_view("cache", "redis")]);
let response = app
.oneshot(
Request::post("/api/resources/cache/restart")
.body(Body::empty())
.expect("request builds"),
)
.await
.expect("router responds");
assert_eq!(response.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn restart_returns_404_for_unknown_resource() {
let app = build_app(vec![sample_view("cache", "redis")]);
let response = app
.oneshot(
Request::post("/api/resources/nope/restart")
.body(Body::empty())
.expect("request builds"),
)
.await
.expect("router responds");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let bytes = response
.into_body()
.collect()
.await
.expect("body collected")
.to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
assert_eq!(json["error"], "unknown resource");
assert_eq!(json["resource"], "nope");
}