1use std::sync::Arc;
32
33use axum::body::Body;
34use axum::http::{HeaderMap, HeaderName, HeaderValue, Method};
35
36use arcly_http_core::core::engine::DiContainerBuilder;
37use arcly_http_core::web::context::{Claims, RequestContext};
38
39pub struct TestRequest {
41 method: Method,
42 path: String,
43 query: String,
44 headers: HeaderMap,
45 body: bytes::Bytes,
46 claims: Option<serde_json::Value>,
47 container: DiContainerBuilder,
48}
49
50impl TestRequest {
51 pub fn new(method: Method, path: impl Into<String>) -> Self {
52 Self {
53 method,
54 path: path.into(),
55 query: String::new(),
56 headers: HeaderMap::new(),
57 body: bytes::Bytes::new(),
58 claims: None,
59 container: DiContainerBuilder::new(),
60 }
61 }
62
63 pub fn get(path: impl Into<String>) -> Self {
64 Self::new(Method::GET, path)
65 }
66 pub fn post(path: impl Into<String>) -> Self {
67 Self::new(Method::POST, path)
68 }
69 pub fn put(path: impl Into<String>) -> Self {
70 Self::new(Method::PUT, path)
71 }
72 pub fn delete(path: impl Into<String>) -> Self {
73 Self::new(Method::DELETE, path)
74 }
75
76 pub fn header(mut self, name: &str, value: &str) -> Self {
79 let n = name.parse::<HeaderName>().expect("valid header name");
80 let v = HeaderValue::from_str(value).expect("valid header value");
81 self.headers.insert(n, v);
82 self
83 }
84
85 pub fn query(mut self, q: impl Into<String>) -> Self {
87 self.query = q.into();
88 self
89 }
90
91 pub fn json(mut self, value: &serde_json::Value) -> Self {
93 self.body = bytes::Bytes::from(value.to_string());
94 self.headers
95 .insert("content-type", HeaderValue::from_static("application/json"));
96 self
97 }
98
99 pub fn body(mut self, bytes: impl Into<bytes::Bytes>) -> Self {
101 self.body = bytes.into();
102 self
103 }
104
105 pub fn claims(mut self, claims: serde_json::Value) -> Self {
109 self.claims = Some(claims);
110 self
111 }
112
113 pub fn provide<T: Send + Sync + 'static>(mut self, value: T) -> Self {
116 self.container.register(value);
117 self
118 }
119
120 pub async fn build(self) -> RequestContext {
123 let container = self.container.freeze();
124
125 let uri = if self.query.is_empty() {
126 self.path.clone()
127 } else {
128 format!("{}?{}", self.path, self.query)
129 };
130 let mut req = axum::http::Request::builder()
131 .method(self.method)
132 .uri(&uri)
133 .body(Body::from(self.body))
134 .expect("test request builds");
135 *req.headers_mut() = self.headers;
136
137 let (parts, body) = req.into_parts();
138 let ctx = arcly_http_core::web::boundary::assemble_context(
139 parts,
140 body,
141 Default::default(),
142 container,
143 "",
144 None,
145 )
146 .await
147 .expect("test request under the default body cap");
148
149 match self.claims {
150 Some(serde_json::Value::Object(map)) => {
151 ctx.__with_claims(Some(Arc::new(map as Claims)))
152 }
153 Some(other) => {
154 let mut map = serde_json::Map::new();
155 map.insert("value".into(), other);
156 ctx.__with_claims(Some(Arc::new(map)))
157 }
158 None => ctx,
159 }
160 }
161}
162
163pub struct TestServer {
165 pub base_url: String,
167 trigger: Arc<tokio::sync::Notify>,
168 server: tokio::task::JoinHandle<()>,
169}
170
171impl TestServer {
172 pub async fn launch<RootMod: arcly_http_core::core::engine::Module>(
176 plugins: Vec<Box<dyn arcly_http_core::core::plugins::ArclyPlugin>>,
177 config: crate::app::LaunchConfig,
178 ) -> Self {
179 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
184 .await
185 .expect("bind test port");
186 let addr = listener.local_addr().expect("local addr").to_string();
187
188 let base_url = format!("http://{addr}");
189 let info = arcly_http_core::openapi::OpenApiInfo::new("test-server", "0");
190 let trigger = Arc::new(tokio::sync::Notify::new());
191 let config = config.shutdown_trigger(Arc::clone(&trigger));
192 let server = tokio::spawn(async move {
193 if let Err(e) =
194 crate::app::App::launch_on_listener::<RootMod>(listener, info, plugins, config)
195 .await
196 {
197 tracing::error!(error = %e, "test server exited with error");
198 }
199 });
200
201 for _ in 0..3000 {
204 if server.is_finished() {
205 panic!("test server failed during boot on {addr} — check plugin on_init/on_start errors");
206 }
207 if tokio::net::TcpStream::connect(&addr).await.is_ok() {
208 return Self {
209 base_url,
210 trigger,
211 server,
212 };
213 }
214 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
215 }
216 panic!("test server did not become ready on {addr}");
217 }
218
219 pub async fn shutdown(self) {
225 self.trigger.notify_one();
226 tokio::time::timeout(std::time::Duration::from_secs(30), self.server)
227 .await
228 .expect("graceful shutdown must complete within 30s")
229 .expect("server task must not panic");
230 }
231}