use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode};
use cellos_server::{router, AppState};
use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;
const TOKEN: &str = "med-ct-test-token";
fn app_state() -> AppState {
AppState::new(None, TOKEN)
}
async fn drain(resp: axum::response::Response) -> (StatusCode, String, Option<String>, Value) {
let status = resp.status();
let ct = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let allow = resp
.headers()
.get(header::ALLOW)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = resp.into_body().collect().await.expect("body").to_bytes();
let body: Value = serde_json::from_slice(&bytes).unwrap_or_else(|e| {
panic!(
"response body must parse as JSON (status={status}, ct={ct:?}, body={:?}): {e}",
String::from_utf8_lossy(&bytes),
)
});
(status, ct, allow, body)
}
fn assert_problem_json(ct: &str, status: StatusCode, body: &Value) {
assert!(
ct.starts_with("application/problem+json"),
"Content-Type must be application/problem+json (got {ct:?}, status {status})",
);
for required in ["type", "title", "status", "detail"] {
assert!(
body.get(required).is_some(),
"problem document must contain `{required}` field; got {body}",
);
}
assert_eq!(
body.get("status").and_then(Value::as_u64),
Some(u64::from(status.as_u16())),
"problem `status` field must match HTTP status; got {body}",
);
let type_uri = body.get("type").and_then(Value::as_str).unwrap_or_default();
assert!(
type_uri.starts_with("/problems/"),
"`type` must be a /problems/ URI reference, got {type_uri:?}",
);
}
#[tokio::test]
async fn post_formations_oversize_body_is_problem_json_413() {
let app = router(app_state());
let big = vec![b'{'; 200 * 1024];
let req = Request::builder()
.method(Method::POST)
.uri("/v1/formations")
.header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(big))
.expect("build request");
let resp = app.oneshot(req).await.expect("router response");
let (status, ct, _allow, body) = drain(resp).await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
assert_problem_json(&ct, status, &body);
let type_uri = body.get("type").and_then(Value::as_str).unwrap();
assert_eq!(type_uri, "/problems/payload-too-large");
let detail = body
.get("detail")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
!detail.is_empty(),
"413 detail must not be empty (got {body})",
);
}
#[tokio::test]
async fn get_formation_with_bad_uuid_is_problem_json_400() {
let app = router(app_state());
let req = Request::builder()
.method(Method::GET)
.uri("/v1/formations/notauuid")
.header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("router response");
let (status, ct, _, body) = drain(resp).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_problem_json(&ct, status, &body);
}
#[tokio::test]
async fn get_cells_with_bad_query_is_problem_json_400() {
let app = router(app_state());
let req = Request::builder()
.method(Method::GET)
.uri("/v1/cells?cursor=abc")
.header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("router response");
let (status, ct, _, body) = drain(resp).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_problem_json(&ct, status, &body);
}
#[tokio::test]
async fn get_events_with_overflow_since_is_problem_json_400() {
let app = router(app_state());
let req = Request::builder()
.method(Method::GET)
.uri("/v1/events?since=9999999999999999999999")
.header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("router response");
let (status, ct, _, body) = drain(resp).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_problem_json(&ct, status, &body);
}
#[tokio::test]
async fn unmatched_route_returns_problem_json_404() {
let app = router(app_state());
let req = Request::builder()
.method(Method::GET)
.uri("/v1/nonexistent")
.header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("router response");
let (status, ct, _, body) = drain(resp).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_problem_json(&ct, status, &body);
let detail = body
.get("detail")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
detail.contains("/v1/nonexistent") || detail.contains("nonexistent"),
"404 detail should mention the unmatched path; got {detail:?}",
);
let type_uri = body.get("type").and_then(Value::as_str).unwrap();
assert_eq!(type_uri, "/problems/not-found");
}
#[tokio::test]
async fn wrong_method_returns_problem_json_405_with_allow_header() {
let app = router(app_state());
let req = Request::builder()
.method(Method::POST)
.uri("/v1/version")
.header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("router response");
let (status, ct, allow, body) = drain(resp).await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
assert_problem_json(&ct, status, &body);
let type_uri = body.get("type").and_then(Value::as_str).unwrap();
assert_eq!(type_uri, "/problems/method-not-allowed");
let allow = allow.expect("405 must carry Allow header (RFC 9110 §15.5.6)");
assert!(
allow.to_ascii_uppercase().contains("GET"),
"Allow on /v1/version (a GET route) must list GET; got {allow:?}",
);
}
#[tokio::test]
async fn patch_formations_is_problem_json_405_with_allow() {
let app = router(app_state());
let req = Request::builder()
.method(Method::PATCH)
.uri("/v1/formations")
.header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("router response");
let (status, ct, allow, body) = drain(resp).await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
assert_problem_json(&ct, status, &body);
let allow = allow.expect("405 must carry Allow header");
let upper = allow.to_ascii_uppercase();
assert!(
upper.contains("POST") && upper.contains("GET"),
"Allow on /v1/formations must include POST and GET; got {allow:?}",
);
}
#[tokio::test]
async fn success_responses_are_not_rewritten() {
let app = router(app_state());
let req = Request::builder()
.method(Method::GET)
.uri("/v1/version")
.header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("router response");
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
assert!(
ct.starts_with("application/json"),
"200 responses must stay application/json, got {ct:?}",
);
assert!(
!ct.starts_with("application/problem+json"),
"200 must NOT be rewritten to problem+json; got {ct:?}",
);
}
#[tokio::test]
async fn existing_problem_json_responses_pass_through_unchanged() {
let app = router(app_state());
let req = Request::builder()
.method(Method::GET)
.uri("/v1/version")
.body(Body::empty())
.expect("build request");
let resp = app.oneshot(req).await.expect("router response");
let (status, ct, _, body) = drain(resp).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_problem_json(&ct, status, &body);
let type_uri = body.get("type").and_then(Value::as_str).unwrap();
assert_eq!(
type_uri, "/problems/unauthorized",
"auth-layer problem document must keep its stable type URI",
);
}