use super::*;
use crate::test_utils::RecordingHttpClient;
use futures::executor::block_on;
fn request(body: &'static str) -> Request<&'static str> {
Request::builder()
.method(http::Method::POST)
.uri("https://example.test/v1/echo?x=1")
.header("x-probe", "yes")
.body(body)
.expect("valid request")
}
#[test]
fn unary_request_passes_through_unchanged_and_body_converts() {
let inner = RecordingHttpClient::new(r#"{"ok":true}"#);
let erased = DynHttpClient::new(inner.clone());
let response = block_on(erased.send::<_, Vec<u8>>(request("hello"))).expect("send");
let body = block_on(response.into_body()).expect("body");
assert_eq!(body, br#"{"ok":true}"#);
let captured = inner.requests();
assert_eq!(captured.len(), 1);
let req = &captured[0];
assert_eq!(req.uri, "https://example.test/v1/echo?x=1");
assert_eq!(req.body, Bytes::from_static(b"hello"));
assert_eq!(
req.headers.get("x-probe").and_then(|v| v.to_str().ok()),
Some("yes")
);
}
#[test]
fn erasing_an_erased_client_is_a_clone_not_a_second_layer() {
let erased = DynHttpClient::new(RecordingHttpClient::new(""));
let again = DynHttpClient::new(erased.clone());
assert!(erased.ptr_eq(&again));
assert!(erased.ptr_eq(&erased.clone()));
}
#[test]
fn debug_never_prints_the_inner_transport() {
let erased = DynHttpClient::new(RecordingHttpClient::new("secret-bearing config"));
assert_eq!(format!("{erased:?}"), "DynHttpClient");
}
mod middleware {
use super::*;
use crate::http_client::middleware::HttpMiddleware;
use crate::test_utils::MockStreamingClient;
use crate::wasm_compat::WasmBoxedFuture;
use http::{HeaderMap, HeaderValue, Method, StatusCode, Uri};
use std::sync::{Arc, Mutex};
struct Tagger {
tag: &'static str,
log: Arc<Mutex<Vec<String>>>,
}
impl Tagger {
fn log(&self, phase: &str) {
self.log
.lock()
.expect("log")
.push(format!("{}:{}", self.tag, phase));
}
}
impl HttpMiddleware for Tagger {
fn before_request_headers<'a>(
&'a self,
_method: &'a Method,
_uri: &'a Uri,
headers: &'a mut HeaderMap,
) -> WasmBoxedFuture<'a, Result<()>> {
Box::pin(async move {
self.log("headers");
headers.append("x-tag", HeaderValue::from_static(self.tag));
Ok(())
})
}
fn before_request_body<'a>(
&'a self,
_method: &'a Method,
_uri: &'a Uri,
headers: &'a HeaderMap,
body: Bytes,
) -> WasmBoxedFuture<'a, Result<Bytes>> {
Box::pin(async move {
self.log("body");
assert!(
headers
.get_all("x-tag")
.iter()
.any(|v| v.as_bytes() == self.tag.as_bytes()),
"body hooks run after all header hooks"
);
let mut bytes = body.to_vec();
bytes.extend_from_slice(self.tag.as_bytes());
Ok(Bytes::from(bytes))
})
}
fn after_response<'a>(
&'a self,
_method: &'a Method,
_uri: &'a Uri,
status: StatusCode,
_headers: &'a HeaderMap,
) -> WasmBoxedFuture<'a, Result<()>> {
Box::pin(async move {
self.log(&format!("response:{}", status.as_u16()));
Ok(())
})
}
}
#[test]
fn phases_run_in_attachment_order_and_mutations_chain() {
let inner = RecordingHttpClient::new("ok");
let log = Arc::new(Mutex::new(Vec::new()));
let erased = DynHttpClient::new(inner.clone())
.with_middleware(Tagger {
tag: "a",
log: log.clone(),
})
.with_middleware(Tagger {
tag: "b",
log: log.clone(),
});
block_on(erased.send::<_, Bytes>(request("body-"))).expect("send");
let captured = inner.requests();
assert_eq!(captured.len(), 1);
let tags: Vec<_> = captured[0]
.headers
.get_all("x-tag")
.iter()
.map(|v| v.to_str().expect("ascii"))
.collect();
assert_eq!(tags, ["a", "b"]);
assert_eq!(captured[0].body, Bytes::from_static(b"body-ab"));
assert_eq!(
log.lock().expect("log").as_slice(),
[
"a:headers",
"b:headers",
"a:body",
"b:body",
"a:response:200",
"b:response:200"
]
);
}
struct FailAt(&'static str);
impl HttpMiddleware for FailAt {
fn before_request_headers<'a>(
&'a self,
_method: &'a Method,
_uri: &'a Uri,
_headers: &'a mut HeaderMap,
) -> WasmBoxedFuture<'a, Result<()>> {
let fail = self.0 == "headers";
Box::pin(async move {
if fail {
return Err(crate::http_client::Error::non_success_with_details(
StatusCode::BAD_REQUEST,
http::HeaderMap::new(),
"rejected headers".into(),
));
}
Ok(())
})
}
fn after_response<'a>(
&'a self,
_method: &'a Method,
_uri: &'a Uri,
_status: StatusCode,
_headers: &'a HeaderMap,
) -> WasmBoxedFuture<'a, Result<()>> {
let fail = self.0 == "response";
Box::pin(async move {
if fail {
return Err(crate::http_client::Error::non_success_with_details(
StatusCode::TOO_MANY_REQUESTS,
http::HeaderMap::new(),
"rejected response".into(),
));
}
Ok(())
})
}
}
#[test]
fn request_side_failure_aborts_before_sending() {
let inner = RecordingHttpClient::new("ok");
let erased = DynHttpClient::new(inner.clone()).with_middleware(FailAt("headers"));
let Err(err) = block_on(erased.send::<_, Bytes>(request("hello"))) else {
panic!("must abort")
};
assert_eq!(err.non_success_status(), Some(StatusCode::BAD_REQUEST));
assert!(inner.requests().is_empty(), "nothing reached the transport");
}
#[test]
fn response_hook_failure_surfaces_as_the_request_error() {
let inner = RecordingHttpClient::new("ok");
let erased = DynHttpClient::new(inner.clone()).with_middleware(FailAt("response"));
let Err(err) = block_on(erased.send::<_, Bytes>(request("hello"))) else {
panic!("must fail")
};
assert_eq!(
err.non_success_status(),
Some(StatusCode::TOO_MANY_REQUESTS)
);
assert_eq!(inner.requests().len(), 1);
}
#[test]
fn streaming_response_hook_sees_status_and_headers_before_consumption() {
let seen = Arc::new(Mutex::new(None));
struct CaptureContentType(Arc<Mutex<Option<String>>>);
impl HttpMiddleware for CaptureContentType {
fn after_response<'a>(
&'a self,
_method: &'a Method,
_uri: &'a Uri,
status: StatusCode,
headers: &'a HeaderMap,
) -> WasmBoxedFuture<'a, Result<()>> {
Box::pin(async move {
let content_type = headers
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
*self.0.lock().expect("seen") = Some(format!(
"{}:{}",
status.as_u16(),
content_type.unwrap_or_default()
));
Ok(())
})
}
}
let inner = MockStreamingClient {
sse_bytes: Bytes::from_static(b"data: hi\n\n"),
};
let erased = DynHttpClient::new(inner).with_middleware(CaptureContentType(seen.clone()));
let response = block_on(erased.send_streaming(request(""))).expect("stream");
assert_eq!(
seen.lock().expect("seen").as_deref(),
Some("200:text/event-stream")
);
drop(response);
}
#[test]
fn erasing_preserves_middleware_and_ptr_eq_ignores_it() {
let log = Arc::new(Mutex::new(Vec::new()));
let plain = DynHttpClient::new(RecordingHttpClient::new("ok"));
let layered = plain.clone().with_middleware(Tagger {
tag: "a",
log: log.clone(),
});
assert!(plain.ptr_eq(&layered));
let reerased = DynHttpClient::new(layered);
block_on(reerased.send::<_, Bytes>(request("x"))).expect("send");
assert!(!log.lock().expect("log").is_empty());
}
}
#[test]
fn transport_errors_surface_unchanged() {
let inner = RecordingHttpClient::with_error(http::StatusCode::TOO_MANY_REQUESTS, "slow");
let erased = DynHttpClient::new(inner);
let Err(err) = block_on(erased.send::<_, Bytes>(request(""))) else {
panic!("expected a transport error")
};
assert_eq!(
err.non_success_status(),
Some(http::StatusCode::TOO_MANY_REQUESTS)
);
}