Skip to main content

armature_testing/
test_app.rs

1// Test Application Builder
2
3use armature_core::{Application, Container, Module, Provider, Router};
4use std::sync::Arc;
5
6/// Test application for integration testing
7pub struct TestApp {
8    pub app: Application,
9    pub container: Arc<Container>,
10}
11
12impl TestApp {
13    /// Create a new test application
14    ///
15    /// `armature_core::Container` clones share the same underlying
16    /// (`Arc`-backed) storage, so cloning the caller-supplied container into
17    /// `Application::new` — rather than handing the `Application` a brand
18    /// new, empty `Container::new()` — keeps `self.container` and the
19    /// container the running `Application` resolves against in sync:
20    /// anything registered through either handle is visible through both.
21    pub fn new(container: Container, router: Router) -> Self {
22        let app = Application::new(container.clone(), router);
23        Self {
24            app,
25            container: Arc::new(container),
26        }
27    }
28
29    /// Get a service from the container.
30    ///
31    /// Delegates to the stored `armature_core::Container`. Returns `None`
32    /// if no provider of type `T` was registered (via `TestAppBuilder`,
33    /// `add_module`, or directly on the container).
34    pub fn get<T: Provider + Clone + 'static>(&self) -> Option<T> {
35        self.container.get::<T>().ok().map(|arc| (*arc).clone())
36    }
37
38    /// Create a test client for making requests
39    ///
40    /// # Examples
41    ///
42    /// ```no_run
43    /// use armature_testing::TestAppBuilder;
44    /// use armature_core::HttpResponse;
45    ///
46    /// # tokio_test::block_on(async {
47    /// let app = TestAppBuilder::new()
48    ///     .with_route("/test", |_req| async {
49    ///         Ok(HttpResponse::ok().with_body(b"OK".to_vec()))
50    ///     })
51    ///     .build();
52    ///
53    /// let client = app.client();
54    /// let response = client.get("/test").await;
55    /// assert_eq!(response.status(), Some(200));
56    /// # });
57    /// ```
58    pub fn client(&self) -> crate::TestClient {
59        crate::TestClient::new(self.app.router.clone())
60    }
61}
62
63/// Builder for test applications.
64///
65/// Provides a fluent API for creating test applications with custom
66/// routes, services, and configuration.
67///
68/// # Examples
69///
70/// Basic test app:
71///
72/// ```no_run
73/// use armature_testing::TestAppBuilder;
74/// use armature_core::{HttpResponse, Error};
75///
76/// # tokio_test::block_on(async {
77/// let app = TestAppBuilder::new()
78///     .with_route("/test", |_req| async {
79///         Ok(HttpResponse::ok().with_body(b"test".to_vec()))
80///     })
81///     .build();
82///
83/// let client = app.client();
84/// let response = client.get("/test").await;
85/// assert_eq!(response.status(), Some(200));
86/// # });
87/// ```
88pub struct TestAppBuilder {
89    container: Container,
90    router: Router,
91}
92
93/// Maximum `imports()`/`re_exports()` nesting depth `wire_module` will
94/// recurse through before panicking.
95///
96/// `wire_module` dedups modules by concrete `TypeId`
97/// (`Module::module_type_id`), so a self-importing or diamond-shaped module
98/// graph no longer recurses unboundedly on its own: the cyclic/repeated
99/// module is skipped the second time it's reached, terminating the walk.
100/// This constant is only a defense-in-depth backstop for a pathological
101/// *non-cyclic* graph — e.g. a long linear chain of distinct modules each
102/// importing the next — that could otherwise still recurse arbitrarily deep
103/// and overflow the stack (which would abort the whole test binary rather
104/// than fail a single test). 64 levels is far beyond any real module
105/// hierarchy while still panicking well before the stack is at risk.
106const WIRE_MODULE_MAX_DEPTH: usize = 64;
107
108impl TestAppBuilder {
109    /// Create a new test app builder
110    pub fn new() -> Self {
111        Self {
112            container: Container::new(),
113            router: Router::new(),
114        }
115    }
116
117    /// Register a provider
118    pub fn register<T: Provider + Clone + 'static>(self, provider: T) -> Self {
119        self.container.register(provider);
120        self
121    }
122
123    /// Add a module.
124    ///
125    /// Registers the module's providers and controllers (recursively,
126    /// through `imports()`/`re_exports()`) via the same
127    /// `ProviderRegistration` / `ControllerRegistration` function pointers
128    /// `Application` itself calls, so DI and routes wired by the module are
129    /// real — not a no-op.
130    ///
131    /// Like `Application::register_module`, this deduplicates
132    /// diamond-imported modules: `Module::module_type_id()` is monomorphized
133    /// per concrete module type, so it genuinely identifies the concrete
134    /// module behind a `&dyn Module` reference (unlike
135    /// `std::any::type_name_of_val`, which only ever reports `"dyn
136    /// Module"`, the trait object's own type, for every module). A module
137    /// reachable via two distinct import paths — e.g. a root importing `A`
138    /// and `B`, both of which import shared module `S` — registers its
139    /// providers and controllers exactly once.
140    ///
141    /// Module guards are **not** applied: `TestApp`'s router dispatch (via
142    /// `TestClient`) routes directly against `Router`, without guard
143    /// evaluation, so there is nothing here for a guard to scope against.
144    ///
145    /// # Panics
146    ///
147    /// Panics if a controller factory or route registrar returns an error —
148    /// a misconfigured module should fail the test loudly rather than
149    /// silently register nothing. Also panics if the import graph nests
150    /// deeper than `WIRE_MODULE_MAX_DEPTH` levels — see that constant's doc
151    /// comment; with dedup in place this is only a defense-in-depth backstop
152    /// for pathological non-cyclic graphs, since true cycles now terminate
153    /// via the visited set.
154    pub fn add_module<M: Module>(mut self, module: M) -> Self {
155        let mut visited = std::collections::HashSet::new();
156        Self::wire_module(&self.container, &mut self.router, &mut visited, &module, 0);
157        self
158    }
159
160    /// Recursively register a module's providers and controllers into
161    /// `container`/`router`. Mirrors `armature_core::Application`'s private
162    /// `register_module` wiring (minus guard scoping; see `add_module`'s doc
163    /// comment for the guard-scoping difference).
164    ///
165    /// `visited` is keyed on `Module::module_type_id()` and carried across
166    /// the whole recursion (not per-branch), so a module reached via two
167    /// different import paths — a diamond — registers only on the first
168    /// visit, exactly like `Application::register_module`.
169    ///
170    /// `depth` counts levels of `imports()`/`re_exports()` nesting below the
171    /// module passed to `add_module` (which is depth 0). With `visited` in
172    /// place, genuine cycles (including self-imports) terminate on their own
173    /// — the repeated module is already in `visited` — so `depth` now exists
174    /// solely as a defense-in-depth cap on pathological non-cyclic graphs;
175    /// see `WIRE_MODULE_MAX_DEPTH`.
176    fn wire_module(
177        container: &Container,
178        router: &mut Router,
179        visited: &mut std::collections::HashSet<std::any::TypeId>,
180        module: &dyn Module,
181        depth: usize,
182    ) {
183        if depth >= WIRE_MODULE_MAX_DEPTH {
184            panic!("module import graph exceeds depth {WIRE_MODULE_MAX_DEPTH} — cyclic imports?");
185        }
186
187        // Each module registers once: diamond imports must not duplicate
188        // providers/routes, and cyclic imports (including self-imports)
189        // must not recurse forever. Mirrors
190        // `Application::register_module`'s visited-set semantics.
191        if !visited.insert(module.module_type_id()) {
192            return;
193        }
194
195        for imported in module.imports() {
196            Self::wire_module(container, router, visited, imported.as_ref(), depth + 1);
197        }
198        for re_exported in module.re_exports() {
199            Self::wire_module(container, router, visited, re_exported.as_ref(), depth + 1);
200        }
201
202        for provider_reg in module.providers() {
203            (provider_reg.register_fn)(container);
204        }
205
206        for controller_reg in module.controllers() {
207            let instance = (controller_reg.factory)(container).unwrap_or_else(|e| {
208                panic!(
209                    "TestAppBuilder::add_module: failed to instantiate controller `{}`: {}",
210                    controller_reg.type_name, e
211                )
212            });
213            (controller_reg.route_registrar)(container, router, instance).unwrap_or_else(|e| {
214                panic!(
215                    "TestAppBuilder::add_module: failed to register routes for controller `{}`: {}",
216                    controller_reg.type_name, e
217                )
218            });
219        }
220    }
221
222    /// Set custom container
223    pub fn with_container(self, container: Container) -> Self {
224        Self { container, ..self }
225    }
226
227    /// Set custom router
228    pub fn with_router(self, router: Router) -> Self {
229        Self { router, ..self }
230    }
231
232    /// Add a test route with a handler
233    ///
234    /// # Examples
235    ///
236    /// ```no_run
237    /// use armature_testing::TestAppBuilder;
238    /// use armature_core::HttpResponse;
239    ///
240    /// let app = TestAppBuilder::new()
241    ///     .with_route("/api/health", |_req| async {
242    ///         Ok(HttpResponse::ok().with_body(b"OK".to_vec()))
243    ///     })
244    ///     .build();
245    /// ```
246    pub fn with_route<F, Fut>(mut self, path: &str, handler: F) -> Self
247    where
248        F: Fn(armature_core::HttpRequest) -> Fut + Send + Sync + Clone + 'static,
249        Fut: std::future::Future<Output = Result<armature_core::HttpResponse, armature_core::Error>>
250            + Send
251            + 'static,
252    {
253        use armature_core::{HttpMethod, Route};
254        // Use the optimized Route::new which enables handler monomorphization
255        self.router
256            .add_route(Route::new(HttpMethod::GET, path, handler));
257        self
258    }
259
260    /// Build the test application
261    pub fn build(self) -> TestApp {
262        TestApp::new(self.container, self.router)
263    }
264}
265
266impl Default for TestAppBuilder {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_builder_creation() {
278        let _builder = TestAppBuilder::new();
279    }
280
281    #[test]
282    fn test_app_creation() {
283        let builder = TestAppBuilder::new();
284        let _app = builder.build();
285    }
286
287    #[derive(Clone)]
288    struct Widget {
289        value: u32,
290    }
291
292    #[test]
293    fn registered_provider_is_retrievable_via_test_app_get() {
294        let app = TestAppBuilder::new().register(Widget { value: 7 }).build();
295
296        let widget = app.get::<Widget>().expect("Widget should be registered");
297        assert_eq!(widget.value, 7);
298    }
299
300    #[test]
301    fn unregistered_provider_returns_none() {
302        let app = TestAppBuilder::new().build();
303        assert!(app.get::<Widget>().is_none());
304    }
305
306    #[test]
307    fn with_container_flows_into_test_app() {
308        let container = Container::new();
309        container.register(Widget { value: 99 });
310
311        let app = TestAppBuilder::new().with_container(container).build();
312
313        let widget = app.get::<Widget>().expect("Widget should be registered");
314        assert_eq!(widget.value, 99);
315    }
316
317    // -- add_module: real provider/controller wiring, not a no-op --------
318
319    use armature_core::{
320        ControllerRegistration, Error, HttpMethod, HttpRequest, HttpResponse, ProviderRegistration,
321    };
322    use std::any::{Any, TypeId};
323
324    #[derive(Clone)]
325    struct Counter {
326        value: u32,
327    }
328
329    fn register_counter(container: &Container) {
330        container.register(Counter { value: 42 });
331    }
332
333    struct CounterController {
334        count: Arc<Counter>,
335    }
336
337    fn counter_factory(container: &Container) -> Result<Box<dyn Any + Send + Sync>, Error> {
338        let count = container.resolve::<Counter>()?;
339        Ok(Box::new(CounterController { count }))
340    }
341
342    fn counter_route_registrar(
343        _container: &Container,
344        router: &mut Router,
345        instance: Box<dyn Any + Send + Sync>,
346    ) -> Result<(), Error> {
347        let controller = instance
348            .downcast::<CounterController>()
349            .expect("instance should be CounterController");
350        let count = controller.count.clone();
351        router.add_route(armature_core::Route::new(
352            HttpMethod::GET,
353            "/counter",
354            move |_req: HttpRequest| {
355                let count = count.clone();
356                async move { Ok(HttpResponse::ok().with_body(count.value.to_string().into_bytes())) }
357            },
358        ));
359        Ok(())
360    }
361
362    struct CounterModule;
363    impl Module for CounterModule {
364        fn providers(&self) -> Vec<ProviderRegistration> {
365            vec![ProviderRegistration {
366                type_id: TypeId::of::<Counter>(),
367                type_name: "Counter",
368                register_fn: register_counter,
369            }]
370        }
371        fn controllers(&self) -> Vec<ControllerRegistration> {
372            vec![ControllerRegistration {
373                type_id: TypeId::of::<CounterController>(),
374                type_name: "CounterController",
375                base_path: "/counter",
376                factory: counter_factory,
377                route_registrar: counter_route_registrar,
378            }]
379        }
380        fn imports(&self) -> Vec<Box<dyn Module>> {
381            vec![]
382        }
383        fn exports(&self) -> Vec<TypeId> {
384            vec![]
385        }
386    }
387
388    #[tokio::test]
389    async fn add_module_registers_providers_and_controllers_for_real() {
390        let app = TestAppBuilder::new().add_module(CounterModule).build();
391
392        // The module's provider is retrievable directly...
393        let counter = app
394            .get::<Counter>()
395            .expect("Counter should be registered by the module");
396        assert_eq!(counter.value, 42);
397
398        // ...and reaches the controller's handler via DI, proving the
399        // container the module registered into is the same one the running
400        // Application resolves against.
401        let response = app.client().get("/counter").await;
402        assert_eq!(response.status(), Some(200));
403        assert_eq!(response.body_string(), Some("42".to_string()));
404    }
405
406    #[derive(Clone)]
407    struct Pinger;
408
409    fn register_pinger(container: &Container) {
410        container.register(Pinger);
411    }
412
413    struct PingController;
414
415    fn ping_factory(container: &Container) -> Result<Box<dyn Any + Send + Sync>, Error> {
416        container.resolve::<Pinger>()?;
417        Ok(Box::new(PingController))
418    }
419
420    fn ping_route_registrar(
421        _container: &Container,
422        router: &mut Router,
423        instance: Box<dyn Any + Send + Sync>,
424    ) -> Result<(), Error> {
425        instance
426            .downcast::<PingController>()
427            .expect("instance should be PingController");
428        router.add_route(armature_core::Route::new(
429            HttpMethod::GET,
430            "/ping",
431            |_req: HttpRequest| async { Ok(HttpResponse::ok().with_body(b"pong".to_vec())) },
432        ));
433        Ok(())
434    }
435
436    struct PingModule;
437    impl Module for PingModule {
438        fn providers(&self) -> Vec<ProviderRegistration> {
439            vec![ProviderRegistration {
440                type_id: TypeId::of::<Pinger>(),
441                type_name: "Pinger",
442                register_fn: register_pinger,
443            }]
444        }
445        fn controllers(&self) -> Vec<ControllerRegistration> {
446            vec![ControllerRegistration {
447                type_id: TypeId::of::<PingController>(),
448                type_name: "PingController",
449                base_path: "/ping",
450                factory: ping_factory,
451                route_registrar: ping_route_registrar,
452            }]
453        }
454        fn imports(&self) -> Vec<Box<dyn Module>> {
455            vec![]
456        }
457        fn exports(&self) -> Vec<TypeId> {
458            vec![]
459        }
460    }
461
462    /// A parent module importing two *distinct* submodules. This guards the
463    /// other side of `wire_module`'s `module_type_id()`-keyed dedup (see
464    /// `add_module_dedups_diamond_imported_module_registers_shared_provider_once`
465    /// below for the diamond/same-module case): two genuinely different
466    /// modules must never be mistaken for the same visited entry, so both
467    /// must still register even though both are reached from the same
468    /// parent.
469    struct ParentModule;
470    impl Module for ParentModule {
471        fn providers(&self) -> Vec<ProviderRegistration> {
472            vec![]
473        }
474        fn controllers(&self) -> Vec<ControllerRegistration> {
475            vec![]
476        }
477        fn imports(&self) -> Vec<Box<dyn Module>> {
478            vec![Box::new(CounterModule), Box::new(PingModule)]
479        }
480        fn exports(&self) -> Vec<TypeId> {
481            vec![]
482        }
483    }
484
485    #[tokio::test]
486    async fn add_module_registers_all_distinct_imported_modules() {
487        let app = TestAppBuilder::new().add_module(ParentModule).build();
488
489        // Both imported modules' providers must be registered...
490        assert!(app.get::<Counter>().is_some());
491        assert!(app.get::<Pinger>().is_some());
492
493        // ...and both imported modules' routes must be reachable.
494        let counter_response = app.client().get("/counter").await;
495        assert_eq!(counter_response.status(), Some(200));
496        assert_eq!(counter_response.body_string(), Some("42".to_string()));
497
498        let ping_response = app.client().get("/ping").await;
499        assert_eq!(ping_response.status(), Some(200));
500        assert_eq!(ping_response.body_string(), Some("pong".to_string()));
501    }
502
503    // -- wire_module: diamond-imported modules must register exactly once -
504
505    /// Counts calls to `register_shared`, proving how many times
506    /// `SharedModule`'s provider registration actually ran — independent of
507    /// whatever the container ends up holding (a second `register` call
508    /// would just overwrite the first, masking a double-registration).
509    static SHARED_REGISTRATIONS: std::sync::atomic::AtomicU32 =
510        std::sync::atomic::AtomicU32::new(0);
511
512    #[derive(Clone)]
513    struct Shared;
514
515    fn register_shared(container: &Container) {
516        SHARED_REGISTRATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
517        container.register(Shared);
518    }
519
520    struct SharedModule;
521    impl Module for SharedModule {
522        fn providers(&self) -> Vec<ProviderRegistration> {
523            vec![ProviderRegistration {
524                type_id: TypeId::of::<Shared>(),
525                type_name: "Shared",
526                register_fn: register_shared,
527            }]
528        }
529        fn controllers(&self) -> Vec<ControllerRegistration> {
530            vec![]
531        }
532        fn imports(&self) -> Vec<Box<dyn Module>> {
533            vec![]
534        }
535        fn exports(&self) -> Vec<TypeId> {
536            vec![]
537        }
538    }
539
540    /// Imports `SharedModule` — the left branch of the diamond.
541    struct DiamondLeftModule;
542    impl Module for DiamondLeftModule {
543        fn providers(&self) -> Vec<ProviderRegistration> {
544            vec![]
545        }
546        fn controllers(&self) -> Vec<ControllerRegistration> {
547            vec![]
548        }
549        fn imports(&self) -> Vec<Box<dyn Module>> {
550            vec![Box::new(SharedModule)]
551        }
552        fn exports(&self) -> Vec<TypeId> {
553            vec![]
554        }
555    }
556
557    /// Also imports `SharedModule` — the right branch of the diamond.
558    struct DiamondRightModule;
559    impl Module for DiamondRightModule {
560        fn providers(&self) -> Vec<ProviderRegistration> {
561            vec![]
562        }
563        fn controllers(&self) -> Vec<ControllerRegistration> {
564            vec![]
565        }
566        fn imports(&self) -> Vec<Box<dyn Module>> {
567            vec![Box::new(SharedModule)]
568        }
569        fn exports(&self) -> Vec<TypeId> {
570            vec![]
571        }
572    }
573
574    /// Imports both `DiamondLeftModule` and `DiamondRightModule`, so
575    /// `SharedModule` is reachable via two distinct import paths — a classic
576    /// diamond. `Application::register_module` dedups this by the concrete
577    /// module's `TypeId` (`Module::module_type_id`); `wire_module` must do
578    /// the same so a test app's module graph wires identically to the real
579    /// `Application`'s.
580    struct DiamondRootModule;
581    impl Module for DiamondRootModule {
582        fn providers(&self) -> Vec<ProviderRegistration> {
583            vec![]
584        }
585        fn controllers(&self) -> Vec<ControllerRegistration> {
586            vec![]
587        }
588        fn imports(&self) -> Vec<Box<dyn Module>> {
589            vec![Box::new(DiamondLeftModule), Box::new(DiamondRightModule)]
590        }
591        fn exports(&self) -> Vec<TypeId> {
592            vec![]
593        }
594    }
595
596    #[test]
597    fn add_module_dedups_diamond_imported_module_registers_shared_provider_once() {
598        let app = TestAppBuilder::new().add_module(DiamondRootModule).build();
599
600        assert_eq!(
601            SHARED_REGISTRATIONS.load(std::sync::atomic::Ordering::SeqCst),
602            1,
603            "SharedModule is reachable via both DiamondLeftModule and \
604             DiamondRightModule; its provider must register exactly once"
605        );
606        assert!(app.get::<Shared>().is_some());
607    }
608
609    // -- wire_module: self-importing modules dedup via the visited set ---
610
611    /// Counts calls to `register_self_importing`, proving
612    /// `SelfImportingModule`'s provider registers exactly once despite the
613    /// module importing itself (same rationale as `SHARED_REGISTRATIONS`
614    /// above: counting the registration call itself, not container state
615    /// afterward, is what actually proves single-registration).
616    static SELF_IMPORT_REGISTRATIONS: std::sync::atomic::AtomicU32 =
617        std::sync::atomic::AtomicU32::new(0);
618
619    #[derive(Clone)]
620    struct SelfImportMarker;
621
622    fn register_self_importing(container: &Container) {
623        SELF_IMPORT_REGISTRATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
624        container.register(SelfImportMarker);
625    }
626
627    /// A module that imports itself.
628    ///
629    /// Before `wire_module` deduped by `Module::module_type_id()`, wiring
630    /// this in recursed unboundedly and overflowed the stack; the depth cap
631    /// (`WIRE_MODULE_MAX_DEPTH`) existed specifically to turn that into a
632    /// clean panic instead of aborting the whole test binary. Now the
633    /// module is inserted into the visited set on its very first (root)
634    /// entry, so when its own `imports()` yields another
635    /// `SelfImportingModule`, that recursive call finds the type already
636    /// visited and returns immediately — the cycle terminates on its own,
637    /// one level deep, without ever approaching the depth cap.
638    struct SelfImportingModule;
639    impl Module for SelfImportingModule {
640        fn providers(&self) -> Vec<ProviderRegistration> {
641            vec![ProviderRegistration {
642                type_id: TypeId::of::<SelfImportMarker>(),
643                type_name: "SelfImportMarker",
644                register_fn: register_self_importing,
645            }]
646        }
647        fn controllers(&self) -> Vec<ControllerRegistration> {
648            vec![]
649        }
650        fn imports(&self) -> Vec<Box<dyn Module>> {
651            vec![Box::new(SelfImportingModule)]
652        }
653        fn exports(&self) -> Vec<TypeId> {
654            vec![]
655        }
656    }
657
658    #[test]
659    fn add_module_dedups_self_importing_module_registers_once_without_panicking() {
660        let app = TestAppBuilder::new()
661            .add_module(SelfImportingModule)
662            .build();
663
664        assert_eq!(
665            SELF_IMPORT_REGISTRATIONS.load(std::sync::atomic::Ordering::SeqCst),
666            1,
667            "a self-importing module is visited on its first (root) entry, \
668             so its own self-import is skipped as already-visited — its \
669             provider must register exactly once, not zero or many"
670        );
671        assert!(app.get::<SelfImportMarker>().is_some());
672    }
673}