Skip to main content

caelix_actix/
test_app.rs

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
34/// In-process Caelix application for integration tests.
35///
36/// Builds the same DI container and route table as production, then serves
37/// requests through Actix's test service (no TCP listener).
38pub struct TestApplication {
39    container: Arc<Container>,
40    call: CallFn,
41    shutdown_fn: for<'a> fn(&'a Container) -> BoxFuture<'a, caelix_core::Result<()>>,
42}
43
44/// Builder for [`TestApplication`]. Await it (or call [`compile`](Self::compile))
45/// to finish startup.
46pub 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    /// Start configuring a test application for module `M`.
58    ///
59    /// ```ignore
60    /// let app = TestApplication::new::<AppModule>().await;
61    /// let app = TestApplication::new::<AppModule>()
62    ///     .override_provider(UserRepository::in_memory())
63    ///     .await;
64    /// ```
65    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    /// Runs the `get` public API operation.
78    pub fn get(&self, path: &str) -> TestRequestBuilder<'_> {
79        TestRequestBuilder::new(self, actix_test::TestRequest::get().uri(path))
80    }
81
82    /// Runs the `post` public API operation.
83    pub fn post(&self, path: &str) -> TestRequestBuilder<'_> {
84        TestRequestBuilder::new(self, actix_test::TestRequest::post().uri(path))
85    }
86
87    /// Runs the `put` public API operation.
88    pub fn put(&self, path: &str) -> TestRequestBuilder<'_> {
89        TestRequestBuilder::new(self, actix_test::TestRequest::put().uri(path))
90    }
91
92    /// Runs the `patch` public API operation.
93    pub fn patch(&self, path: &str) -> TestRequestBuilder<'_> {
94        TestRequestBuilder::new(self, actix_test::TestRequest::patch().uri(path))
95    }
96
97    /// Runs the `delete` public API operation.
98    pub fn delete(&self, path: &str) -> TestRequestBuilder<'_> {
99        TestRequestBuilder::new(self, actix_test::TestRequest::delete().uri(path))
100    }
101
102    /// Runs the `container` public API operation.
103    pub fn container(&self) -> &Arc<Container> {
104        &self.container
105    }
106
107    /// Runs the `resolve` public API operation.
108    pub fn resolve<T: Send + Sync + 'static>(&self) -> Result<Arc<T>> {
109        self.container.resolve::<T>()
110    }
111
112    /// Run module `on_shutdown` hooks. Dropping without this skips shutdown hooks.
113    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    /// Replace a provider by concrete type `T` with a pre-built instance.
128    ///
129    /// `T` must be the same type modules register and inject via `Arc<T>`.
130    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    /// Replace a provider with an async factory producing type `T`.
136    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    /// Runs the `body_limit` public API operation.
152    pub fn body_limit(mut self, bytes: usize) -> Self {
153        self.body_limit = bytes;
154        self
155    }
156
157    #[cfg(feature = "uploads")]
158    /// Changes the directory used to stage multipart uploads in this test application.
159    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    /// Serves OpenAPI JSON and Swagger UI in the in-process test application.
165    #[cfg(feature = "openapi")]
166    /// Runs the `with_openapi` public API operation.
167    pub fn with_openapi(mut self, config: OpenApiConfig) -> Self {
168        self.openapi = Some(config);
169        self
170    }
171
172    /// Runs the `compile` public API operation.
173    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
241/// Public Caelix type `TestRequestBuilder`.
242pub 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    /// Sets the immediate socket peer address for this request.
256    pub fn peer_addr(mut self, peer_addr: SocketAddr) -> Self {
257        self.request = self.request.peer_addr(peer_addr);
258        self
259    }
260
261    /// Runs the `json` public API operation.
262    pub fn json(mut self, body: impl Serialize) -> Self {
263        self.request = self.request.set_json(body);
264        self
265    }
266
267    /// Runs the `header` public API operation.
268    pub fn header(mut self, name: &str, value: &str) -> Self {
269        self.request = self.request.insert_header((name, value));
270        self
271    }
272
273    /// Appends a request header without replacing existing values.
274    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    /// Runs the `set_payload` public API operation.
280    pub fn set_payload(mut self, bytes: impl Into<Bytes>) -> Self {
281        self.request = self.request.set_payload(bytes);
282        self
283    }
284
285    /// Runs the `send` public API operation.
286    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
292/// Public Caelix type `TestResponse`.
293pub struct TestResponse {
294    response: ServiceResponse,
295}
296
297impl TestResponse {
298    /// Runs the `status` public API operation.
299    pub fn status(&self) -> StatusCode {
300        StatusCode::from_u16(self.response.status().as_u16())
301            .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
302    }
303
304    /// Returns a response header as UTF-8 text.
305    pub fn header(&self, name: &str) -> Option<&str> {
306        self.response.headers().get(name)?.to_str().ok()
307    }
308
309    /// Runs the `assert_status` public API operation.
310    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    /// Runs the `json` public API operation.
320    pub async fn json<T: DeserializeOwned>(self) -> T {
321        actix_test::read_body_json(self.response).await
322    }
323
324    /// Runs the `body` public API operation.
325    pub async fn body(self) -> Bytes {
326        actix_test::read_body(self.response).await
327    }
328
329    /// Runs the `text` public API operation.
330    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}