#![cfg(feature = "server")]
use axon::axon_server::{build_router, inject_backend_resolution, ServerConfig};
use axon::backends::env_available_backends;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[test]
fn s1_injects_into_a_json_object_body() {
let body = br#"{"success":true,"backend":"gemini"}"#;
let out = inject_backend_resolution(body, "gemini", "endpoint_declared");
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(json["backend_resolution"]["backend"], "gemini");
assert_eq!(json["backend_resolution"]["reason"], "endpoint_declared");
assert_eq!(json["success"], true);
assert_eq!(json["backend"], "gemini");
}
#[test]
fn s1_leaves_a_non_object_body_untouched() {
for raw in [
&b"[1,2,3]"[..],
&b"\"a scalar\""[..],
&b"not json at all"[..],
&b""[..],
] {
let out = inject_backend_resolution(raw, "stub", "server_default");
assert_eq!(
out, raw,
"36.j: a non-object body must be returned byte-for-byte \
untouched — observability is best-effort, never corrupting"
);
}
}
fn server_cfg(default_backend: Option<&str>) -> ServerConfig {
ServerConfig {
host: "127.0.0.1".into(),
port: 0,
channel: "memory".into(),
auth_token: String::new(),
log_level: "INFO".into(),
log_format: "json".into(),
log_file: None,
database_url: None,
config_path: None,
strict_type_driven_transport: false,
default_backend: default_backend.map(|s| s.to_string()),
schemas_dir: None,
}
}
async fn deploy(app: &axum::Router, src: &str) {
let body = serde_json::json!({ "source": src, "source_file": "test.axon" });
let req = Request::builder()
.method("POST")
.uri("/v1/deploy")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
assert_eq!(status, StatusCode::OK, "deploy failed: {json}");
assert_eq!(json.get("success").and_then(|v| v.as_bool()), Some(true), "{json}");
}
async fn hit(
app: &axum::Router,
path: &str,
accept_sse: bool,
) -> (StatusCode, Option<String>, Vec<u8>) {
let mut builder = Request::builder()
.method("POST")
.uri(path)
.header("content-type", "application/json");
if accept_sse {
builder = builder.header("accept", "text/event-stream");
}
let resp = app.clone().oneshot(builder.body(Body::from("{}")).unwrap()).await.unwrap();
let status = resp.status();
let backend_hdr = resp
.headers()
.get("x-axon-backend")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let bytes = resp.into_body().collect().await.unwrap().to_bytes().to_vec();
(status, backend_hdr, bytes)
}
#[tokio::test]
async fn s2_json_response_carries_header_and_body_resolution() {
let app = build_router(server_cfg(None));
deploy(
&app,
"flow Chat() -> Unit { step S { ask: \"hi\" } }\n\
axonendpoint E { public: true method: POST path: \"/chat\" execute: Chat backend: stub }",
)
.await;
let (status, backend_hdr, body) = hit(&app, "/chat", false).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
backend_hdr.as_deref(),
Some("stub; reason=endpoint_declared"),
"36.j D8: the X-Axon-Backend header must carry `<backend>; \
reason=<rung>` — here the route declared `backend: stub`"
);
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["backend_resolution"]["backend"], "stub");
assert_eq!(
json["backend_resolution"]["reason"], "endpoint_declared",
"36.j D8: the JSON body must carry the `backend_resolution` \
object naming the rung. Body: {json}"
);
}
#[tokio::test]
async fn s3_server_default_route_reports_the_server_default_rung() {
let app = build_router(server_cfg(Some("stub")));
deploy(
&app,
"flow Chat() -> Unit { step S { ask: \"hi\" } }\n\
axonendpoint E { public: true method: POST path: \"/chat\" execute: Chat }",
)
.await;
let (status, backend_hdr, body) = hit(&app, "/chat", false).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
backend_hdr.as_deref(),
Some("stub; reason=server_default"),
"36.j D8: an undeclared route resolved by the server default \
must report `reason=server_default`"
);
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["backend_resolution"]["reason"], "server_default");
}
#[tokio::test]
async fn s4_sse_response_carries_the_backend_header() {
let app = build_router(server_cfg(None));
deploy(
&app,
"flow Stream() -> Unit { step S { ask: \"hi\" output: Stream<Token> } }\n\
axonendpoint E { public: true method: POST path: \"/stream\" execute: Stream \
backend: stub transport: sse }",
)
.await;
let (status, backend_hdr, _body) = hit(&app, "/stream", true).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
backend_hdr.as_deref(),
Some("stub; reason=endpoint_declared"),
"36.j D8: the SSE response must carry the same X-Axon-Backend \
header — observability is uniform across the JSON and SSE wires"
);
}
#[tokio::test]
async fn s5_honest_failure_header_reports_no_backend_available() {
if !env_available_backends().is_empty() {
eprintln!(
"36.j s5: skipped — provider API key(s) present ({:?}); the \
route resolves and the honest-failure path is not taken.",
env_available_backends()
);
return;
}
let app = build_router(server_cfg(None));
deploy(
&app,
"flow Chat() -> Unit { step S { ask: \"hi\" } }\n\
axonendpoint E { public: true method: POST path: \"/chat\" execute: Chat }",
)
.await;
let (status, backend_hdr, _body) = hit(&app, "/chat", false).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
backend_hdr.as_deref(),
Some("none; reason=no_backend_available"),
"36.j D8: the honest failure must still answer 'why?' — the \
X-Axon-Backend header reports `none; reason=no_backend_available`"
);
}