1use std::{
2 future::{Future, IntoFuture},
3 marker::PhantomData,
4 net::{IpAddr, Ipv4Addr, SocketAddr},
5 pin::Pin,
6 rc::Rc,
7 sync::Arc,
8};
9
10use actix_http::Request;
11use actix_web::{
12 App, Error,
13 dev::{Service, ServiceResponse},
14 test as actix_test, web,
15};
16use bytes::Bytes;
17#[cfg(feature = "uploads")]
18use caelix_core::UploadConfig;
19use caelix_core::{
20 BoxFuture, Container, Module, ProviderDependency, ProviderOverrides, Result, StatusCode,
21 build_container_with_overrides, register_module_controllers_with_container, shutdown_module,
22};
23use serde::{Serialize, de::DeserializeOwned};
24
25#[cfg(feature = "openapi")]
26use crate::application::OpenApiServices;
27use crate::application::{DEFAULT_BODY_LIMIT_BYTES, configure_caelix_services};
28#[cfg(feature = "openapi")]
29use caelix_core::openapi::{OpenApiConfig, build_openapi};
30
31type CallFuture = Pin<Box<dyn Future<Output = std::result::Result<ServiceResponse, Error>>>>;
32type CallFn = Box<dyn Fn(Request) -> CallFuture>;
33
34pub struct TestApplication {
39 container: Arc<Container>,
40 call: CallFn,
41 shutdown_fn: for<'a> fn(&'a Container) -> BoxFuture<'a, caelix_core::Result<()>>,
42}
43
44pub struct TestApplicationBuilder<M> {
47 overrides: ProviderOverrides,
48 body_limit: usize,
49 #[cfg(feature = "uploads")]
50 upload_config: UploadConfig,
51 #[cfg(feature = "openapi")]
52 openapi: Option<OpenApiConfig>,
53 _module: PhantomData<M>,
54}
55
56impl TestApplication {
57 pub fn new<M: Module + 'static>() -> TestApplicationBuilder<M> {
66 TestApplicationBuilder {
67 overrides: ProviderOverrides::new(),
68 body_limit: DEFAULT_BODY_LIMIT_BYTES,
69 #[cfg(feature = "uploads")]
70 upload_config: UploadConfig::default(),
71 #[cfg(feature = "openapi")]
72 openapi: None,
73 _module: PhantomData,
74 }
75 }
76
77 pub fn get(&self, path: &str) -> TestRequestBuilder<'_> {
79 TestRequestBuilder::new(self, actix_test::TestRequest::get().uri(path))
80 }
81
82 pub fn post(&self, path: &str) -> TestRequestBuilder<'_> {
84 TestRequestBuilder::new(self, actix_test::TestRequest::post().uri(path))
85 }
86
87 pub fn put(&self, path: &str) -> TestRequestBuilder<'_> {
89 TestRequestBuilder::new(self, actix_test::TestRequest::put().uri(path))
90 }
91
92 pub fn patch(&self, path: &str) -> TestRequestBuilder<'_> {
94 TestRequestBuilder::new(self, actix_test::TestRequest::patch().uri(path))
95 }
96
97 pub fn delete(&self, path: &str) -> TestRequestBuilder<'_> {
99 TestRequestBuilder::new(self, actix_test::TestRequest::delete().uri(path))
100 }
101
102 pub fn container(&self) -> &Arc<Container> {
104 &self.container
105 }
106
107 pub fn resolve<T: Send + Sync + 'static>(&self) -> Result<Arc<T>> {
109 self.container.resolve::<T>()
110 }
111
112 pub async fn shutdown(self) -> Result<()> {
114 (self.shutdown_fn)(&self.container).await
115 }
116
117 async fn call(&self, request: Request) -> caelix_core::Result<ServiceResponse> {
118 (self.call)(request).await.map_err(|err| {
119 caelix_core::InternalServerErrorException::new(std::io::Error::other(format!(
120 "test request failed: {err}"
121 )))
122 })
123 }
124}
125
126impl<M: Module + 'static> TestApplicationBuilder<M> {
127 pub fn override_provider<T: Send + Sync + 'static>(mut self, value: T) -> Self {
131 self.overrides = std::mem::take(&mut self.overrides).insert_instance(value);
132 self
133 }
134
135 pub fn override_provider_factory<T, Fut, E>(
137 mut self,
138 dependencies: Vec<ProviderDependency>,
139 factory: impl Fn(Arc<Container>) -> Fut + Send + Sync + 'static,
140 ) -> Self
141 where
142 T: Send + Sync + 'static,
143 Fut: Future<Output = std::result::Result<T, E>> + Send + 'static,
144 E: std::fmt::Debug + Send + 'static,
145 {
146 self.overrides =
147 std::mem::take(&mut self.overrides).insert_factory::<T, Fut, E>(dependencies, factory);
148 self
149 }
150
151 pub fn body_limit(mut self, bytes: usize) -> Self {
153 self.body_limit = bytes;
154 self
155 }
156
157 #[cfg(feature = "uploads")]
158 pub fn upload_temp_dir(mut self, path: impl Into<std::path::PathBuf>) -> Self {
160 self.upload_config = self.upload_config.upload_temp_dir(path);
161 self
162 }
163
164 #[cfg(feature = "openapi")]
166 pub fn with_openapi(mut self, config: OpenApiConfig) -> Self {
168 self.openapi = Some(config);
169 self
170 }
171
172 pub async fn compile(self) -> Result<TestApplication> {
174 let container = build_container_with_overrides::<M>(self.overrides).await?;
175
176 let container = Arc::new(container);
177 let body_limit = self.body_limit;
178 #[cfg(feature = "uploads")]
179 let upload_config = self.upload_config;
180 let configure_fn: fn(&mut web::ServiceConfig, Arc<Container>) =
181 |cfg, container| register_module_controllers_with_container::<M>(cfg, container);
182 #[cfg(feature = "openapi")]
183 let openapi = match self.openapi {
184 Some(config) => {
185 let document = build_openapi::<M>(&config)?;
186 Some(OpenApiServices {
187 config,
188 document: document.to_json().expect("OpenAPI document must serialize"),
189 })
190 }
191 None => None,
192 };
193
194 let route_container = container.clone();
195 let app = App::new()
196 .app_data(web::Data::from(container.clone()))
197 .configure(move |cfg| {
198 configure_caelix_services(
199 cfg,
200 body_limit,
201 #[cfg(feature = "uploads")]
202 upload_config.clone(),
203 configure_fn,
204 route_container.clone(),
205 {
206 #[cfg(feature = "openapi")]
207 {
208 openapi.as_ref()
209 }
210 #[cfg(not(feature = "openapi"))]
211 {
212 None
213 }
214 },
215 )
216 });
217
218 let service = Rc::new(actix_test::init_service(app).await);
219 let call: CallFn = Box::new(move |request| {
220 let service = service.clone();
221 Box::pin(async move { service.call(request).await })
222 });
223
224 Ok(TestApplication {
225 container,
226 call,
227 shutdown_fn: |container| Box::pin(async move { shutdown_module::<M>(container).await }),
228 })
229 }
230}
231
232impl<M: Module + 'static> IntoFuture for TestApplicationBuilder<M> {
233 type Output = caelix_core::Result<TestApplication>;
234 type IntoFuture = Pin<Box<dyn Future<Output = caelix_core::Result<TestApplication>>>>;
235
236 fn into_future(self) -> Self::IntoFuture {
237 Box::pin(async move { self.compile().await })
238 }
239}
240
241pub struct TestRequestBuilder<'a> {
243 app: &'a TestApplication,
244 request: actix_test::TestRequest,
245}
246
247impl<'a> TestRequestBuilder<'a> {
248 fn new(app: &'a TestApplication, request: actix_test::TestRequest) -> Self {
249 Self {
250 app,
251 request: request.peer_addr(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 12345)),
252 }
253 }
254
255 pub fn peer_addr(mut self, peer_addr: SocketAddr) -> Self {
257 self.request = self.request.peer_addr(peer_addr);
258 self
259 }
260
261 pub fn json(mut self, body: impl Serialize) -> Self {
263 self.request = self.request.set_json(body);
264 self
265 }
266
267 pub fn header(mut self, name: &str, value: &str) -> Self {
269 self.request = self.request.insert_header((name, value));
270 self
271 }
272
273 pub fn append_header(mut self, name: &str, value: &str) -> Self {
275 self.request = self.request.append_header((name, value));
276 self
277 }
278
279 pub fn set_payload(mut self, bytes: impl Into<Bytes>) -> Self {
281 self.request = self.request.set_payload(bytes);
282 self
283 }
284
285 pub async fn send(self) -> Result<TestResponse> {
287 let response = self.app.call(self.request.to_request()).await?;
288 Ok(TestResponse { response })
289 }
290}
291
292pub struct TestResponse {
294 response: ServiceResponse,
295}
296
297impl TestResponse {
298 pub fn status(&self) -> StatusCode {
300 StatusCode::from_u16(self.response.status().as_u16())
301 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
302 }
303
304 pub fn header(&self, name: &str) -> Option<&str> {
306 self.response.headers().get(name)?.to_str().ok()
307 }
308
309 pub fn assert_status(self, expected: StatusCode) -> Self {
311 let actual = self.status();
312 assert_eq!(
313 actual, expected,
314 "unexpected HTTP status: expected {expected}, got {actual}"
315 );
316 self
317 }
318
319 pub async fn json<T: DeserializeOwned>(self) -> T {
321 actix_test::read_body_json(self.response).await
322 }
323
324 pub async fn body(self) -> Bytes {
326 actix_test::read_body(self.response).await
327 }
328
329 pub async fn text(self) -> Result<String> {
331 let bytes = self.body().await;
332 String::from_utf8(bytes.to_vec()).map_err(|err| {
333 caelix_core::InternalServerErrorException::new(std::io::Error::other(err))
334 })
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::to_actix_response;
342 use actix_web::HttpResponse;
343 use caelix_core::{
344 Controller, Injectable, IntoCaelixResponse, ModuleMetadata, Response,
345 StatusCode as CaelixStatus,
346 };
347 use serde::Deserialize;
348 use serde_json::{Value, json};
349 use std::{
350 any::Any,
351 sync::atomic::{AtomicUsize, Ordering},
352 };
353
354 static SHUTDOWN_COUNT: AtomicUsize = AtomicUsize::new(0);
355
356 struct GreetingService {
357 message: &'static str,
358 }
359
360 impl Injectable for GreetingService {
361 fn dependencies() -> Vec<caelix_core::ProviderDependency> {
362 caelix_core::provider_dependencies![]
363 }
364
365 fn create(_container: &Container) -> caelix_core::BoxFuture<'_, caelix_core::Result<Self>> {
366 Box::pin(async move {
367 Ok(Self {
368 message: "hello from production",
369 })
370 })
371 }
372 }
373
374 struct GreetingController {
375 service: Arc<GreetingService>,
376 }
377
378 impl Injectable for GreetingController {
379 fn dependencies() -> Vec<caelix_core::ProviderDependency> {
380 caelix_core::provider_dependencies![GreetingService]
381 }
382
383 fn create(container: &Container) -> caelix_core::BoxFuture<'_, caelix_core::Result<Self>> {
384 Box::pin(async move {
385 Ok(Self {
386 service: container.resolve::<GreetingService>()?,
387 })
388 })
389 }
390 }
391
392 impl GreetingController {
393 async fn greet(service: web::Data<Container>) -> HttpResponse {
394 let controller = service.resolve::<GreetingController>().unwrap();
395 to_actix_response(
396 Response::Body(json!({ "message": controller.service.message })).into_response(),
397 )
398 }
399
400 async fn echo(body: web::Json<Value>) -> HttpResponse {
401 to_actix_response(
402 Response::WithStatus(CaelixStatus::CREATED, body.into_inner()).into_response(),
403 )
404 }
405 }
406
407 impl Controller for GreetingController {
408 fn base_path() -> &'static str {
409 "/greet"
410 }
411
412 fn register_routes(cfg_any: &mut dyn Any) {
413 let cfg = cfg_any
414 .downcast_mut::<web::ServiceConfig>()
415 .expect("expected actix ServiceConfig");
416
417 cfg.route(
418 "/greet",
419 web::get().to(|container: web::Data<Container>| async move {
420 GreetingController::greet(container).await
421 }),
422 );
423 cfg.route(
424 "/greet/echo",
425 web::post().to(|body: web::Json<Value>| async move {
426 GreetingController::echo(body).await
427 }),
428 );
429 }
430 }
431
432 struct GreetingModule;
433 impl Module for GreetingModule {
434 fn register() -> ModuleMetadata {
435 ModuleMetadata::new()
436 .provider::<GreetingService>()
437 .controller::<GreetingController>()
438 }
439 }
440
441 struct NestedGreetingModule;
442 impl Module for NestedGreetingModule {
443 fn register() -> ModuleMetadata {
444 ModuleMetadata::new()
445 .provider::<GreetingService>()
446 .export::<GreetingService>()
447 }
448 }
449
450 struct RootGreetingModule;
451 impl Module for RootGreetingModule {
452 fn register() -> ModuleMetadata {
453 ModuleMetadata::new()
454 .import::<NestedGreetingModule>()
455 .controller::<GreetingController>()
456 }
457 }
458
459 struct ShutdownService;
460
461 impl Injectable for ShutdownService {
462 fn dependencies() -> Vec<caelix_core::ProviderDependency> {
463 caelix_core::provider_dependencies![]
464 }
465
466 fn create(_container: &Container) -> caelix_core::BoxFuture<'_, caelix_core::Result<Self>> {
467 Box::pin(async move { Ok(Self) })
468 }
469
470 fn on_shutdown(&self) -> caelix_core::BoxFuture<'_, caelix_core::Result<()>> {
471 Box::pin(async move {
472 SHUTDOWN_COUNT.fetch_add(1, Ordering::SeqCst);
473 Ok(())
474 })
475 }
476 }
477
478 struct ShutdownModule;
479 impl Module for ShutdownModule {
480 fn register() -> ModuleMetadata {
481 ModuleMetadata::new().provider::<ShutdownService>()
482 }
483 }
484
485 #[actix_web::test]
486 async fn test_application_get_json() {
487 let app = TestApplication::new::<GreetingModule>().await.unwrap();
488
489 let body: Value = app
490 .get("/greet")
491 .send()
492 .await
493 .unwrap()
494 .assert_status(CaelixStatus::OK)
495 .json()
496 .await;
497 assert_eq!(body, json!({ "message": "hello from production" }));
498 }
499
500 #[actix_web::test]
501 async fn test_application_post_json_created() {
502 let app = TestApplication::new::<GreetingModule>().await.unwrap();
503
504 #[derive(Deserialize)]
505 struct Echo {
506 name: String,
507 }
508
509 let response = app
510 .post("/greet/echo")
511 .json(json!({ "name": "Ronnie" }))
512 .send()
513 .await
514 .unwrap()
515 .assert_status(CaelixStatus::CREATED);
516
517 let body: Echo = response.json().await;
518 assert_eq!(body.name, "Ronnie");
519 }
520
521 #[actix_web::test]
522 async fn test_application_override_nested_provider() {
523 let app = TestApplication::new::<RootGreetingModule>()
524 .override_provider(GreetingService {
525 message: "hello from test",
526 })
527 .await
528 .unwrap();
529
530 let body: Value = app.get("/greet").send().await.unwrap().json().await;
531 assert_eq!(body, json!({ "message": "hello from test" }));
532 assert_eq!(
533 app.resolve::<GreetingService>().unwrap().message,
534 "hello from test"
535 );
536 }
537
538 #[actix_web::test]
539 async fn test_application_enforces_body_limit() {
540 let app = TestApplication::new::<GreetingModule>()
541 .body_limit(8)
542 .await
543 .unwrap();
544
545 let response = app
546 .post("/greet/echo")
547 .header("content-type", "application/json")
548 .set_payload(r#"{"too":"large"}"#)
549 .send()
550 .await
551 .unwrap();
552
553 response.assert_status(CaelixStatus::PAYLOAD_TOO_LARGE);
554 }
555
556 #[actix_web::test]
557 async fn test_application_not_found_is_caelix_json() {
558 let app = TestApplication::new::<GreetingModule>().await.unwrap();
559
560 let body: Value = app
561 .get("/missing")
562 .send()
563 .await
564 .unwrap()
565 .assert_status(CaelixStatus::NOT_FOUND)
566 .json()
567 .await;
568 assert_eq!(
569 body,
570 json!({
571 "status": 404,
572 "error": "Not Found",
573 "message": "Cannot GET /missing"
574 })
575 );
576 }
577
578 #[actix_web::test]
579 async fn test_application_shutdown_runs_hooks() {
580 SHUTDOWN_COUNT.store(0, Ordering::SeqCst);
581
582 let app = TestApplication::new::<ShutdownModule>().await.unwrap();
583 app.shutdown().await.unwrap();
584
585 assert_eq!(SHUTDOWN_COUNT.load(Ordering::SeqCst), 1);
586 }
587}