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