Skip to main content

cratefield_testing/
harness.rs

1//! [`TestHarness`]: a built harness over fakes whose database runs
2//! in-memory SQLite by default, or a throwaway Postgres database for the
3//! parity suite (issues #9, #20) — migrations applied per module at
4//! creation.
5
6use cratefield_adapter_sqlite::SqliteDatabase;
7use cratefield_core::{
8    Database, Harness, HarnessBuilder, HmacSigner, MapConfig, Module, Port, Ports, Runtime,
9    UlidIdGen, Venture,
10};
11use std::sync::Arc;
12
13use crate::dialect::Dialect;
14use crate::fakes::{
15    FakeCaptcha, FakeDefer, FakeHttpClient, FakeMailer, FakeRateLimiter, FixedClock, MemoryKeyValue,
16};
17
18struct TestRuntime;
19
20impl Runtime for TestRuntime {
21    fn provides(&self) -> Vec<Port> {
22        Port::ALL.to_vec()
23    }
24}
25
26/// A harness with every port faked and a migrated database, migrations
27/// applied per module on creation. The same database handle is wired
28/// into the router and exposed for assertions — SQLite in memory by
29/// default ([`TestHarness::new`]), or a throwaway Postgres 16 database
30/// for the parity suite ([`TestHarness::with_database`], issue #20).
31pub struct TestHarness {
32    /// The assembled router: hand it to [`crate::request`].
33    pub router: axum::Router,
34    pub harness: Harness,
35    pub mailer: FakeMailer,
36    pub captcha: FakeCaptcha,
37    pub rate_limiter: FakeRateLimiter,
38    pub clock: FixedClock,
39    pub kv: MemoryKeyValue,
40    pub http: FakeHttpClient,
41    pub defer: FakeDefer,
42    pub signer: Arc<HmacSigner>,
43    /// The migrated database backing the `Database` port (shared with
44    /// the router — assertions see module writes). On Postgres every
45    /// call is marshalled onto the kit's own runtime, so any executor
46    /// can drive it.
47    pub db: Arc<dyn Database>,
48    /// The modules passed in (for conformance access).
49    pub modules: Vec<Arc<dyn Module>>,
50    /// The engine `db` runs against: `"sqlite"` or `"postgres"`.
51    pub dialect: &'static str,
52    #[cfg(feature = "postgres")]
53    pg: Option<crate::pg::PgFixture>,
54}
55
56impl Drop for TestHarness {
57    fn drop(&mut self) {
58        // Postgres kits close their pool and drop their throwaway
59        // database on the kit's runtime; this kit's router and port
60        // handles go first so the close waits only on handles a test
61        // leaked (a test's own threads are joined).
62        #[cfg(feature = "postgres")]
63        if let Some(pg) = self.pg.take() {
64            self.router = axum::Router::new();
65            self.db = Arc::new(crate::fakes::EmptyDatabase);
66            pg.shutdown();
67        }
68    }
69}
70
71impl TestHarness {
72    /// Builds the harness (venture `test-venture.test`), applies every
73    /// module's sqlite migrations to a fresh in-memory database, and
74    /// assembles the router.
75    ///
76    /// # Panics
77    ///
78    /// Panics when the harness cannot build (invalid module set) or a
79    /// migration fails — exactly what a module test should surface.
80    #[must_use]
81    pub fn new(modules: Vec<Box<dyn Module>>) -> Self {
82        Self::with_ports(modules, |_| {})
83    }
84
85    /// [`TestHarness::new`] with a patch over the default ports: swap in a
86    /// token-checking `FakeCaptcha`, a scripted `FakeRateLimiter`, or a
87    /// `MapConfig` carrying `ADMIN_TOKEN`, after the standard fakes (and
88    /// the migrated database) are in place.
89    ///
90    /// # Panics
91    ///
92    /// Panics when the harness cannot build or a migration fails.
93    #[must_use]
94    pub fn with_ports(modules: Vec<Box<dyn Module>>, patch: impl FnOnce(&mut Ports)) -> Self {
95        Self::with_database_and_ports(modules, Dialect::Sqlite, patch)
96    }
97
98    /// [`TestHarness::new`] over the chosen [`Dialect`] (issue #20):
99    /// `Dialect::Sqlite` is the in-memory default; `Dialect::Postgres
100    /// { url }` creates a throwaway Postgres 16 database on that server,
101    /// applies every module's migrations (the `postgres` set when
102    /// shipped, else the portable-linted `sqlite` set) and drops the
103    /// database when the harness drops. Requires building
104    /// `cratefield-testing` with the `postgres` feature.
105    ///
106    /// # Panics
107    ///
108    /// Panics when the harness cannot build, a migration fails, the
109    /// Postgres server is unreachable, or the `postgres` feature is off
110    /// and the Postgres dialect was requested anyway.
111    #[must_use]
112    pub fn with_database(modules: Vec<Box<dyn Module>>, dialect: Dialect) -> Self {
113        Self::with_database_and_ports(modules, dialect, |_| {})
114    }
115
116    /// [`TestHarness::with_database`] with a patch over the default
117    /// ports (the parity counterpart of [`TestHarness::with_ports`]).
118    ///
119    /// # Panics
120    ///
121    /// Panics for the same reasons as [`TestHarness::with_database`].
122    #[must_use]
123    pub fn with_database_and_ports(
124        modules: Vec<Box<dyn Module>>,
125        dialect: Dialect,
126        patch: impl FnOnce(&mut Ports),
127    ) -> Self {
128        let shared: Vec<Arc<dyn Module>> = modules.into_iter().map(Arc::from).collect();
129        Self::from_arcs(shared, dialect, patch)
130    }
131
132    /// One harness per dialect available in the environment — the parity
133    /// loop (issue #20): `for kit in TestHarness::all_dialects(make) {
134    /// … }` runs one test definition against SQLite and Postgres. The
135    /// factory runs once per dialect so every kit gets **fresh module
136    /// instances**: a module may carry per-build state (email-signup
137    /// parks its `ModuleContext` for the `waitlist.confirmed` handler),
138    /// and a shared instance would keep the first dialect's context
139    /// alive in the next dialect's router.
140    ///
141    /// # Panics
142    ///
143    /// Panics like [`TestHarness::with_database`] for any dialect built.
144    #[must_use]
145    pub fn all_dialects(make_modules: impl Fn() -> Vec<Box<dyn Module>>) -> Vec<Self> {
146        Dialect::available()
147            .into_iter()
148            .map(|dialect| Self::with_database(make_modules(), dialect))
149            .collect()
150    }
151
152    /// [`TestHarness::all_dialects`] with a port patch applied to every
153    /// kit. The patch runs once per dialect, so it must be a `Fn` over
154    /// cloneable captures (`Arc` handles), not a one-shot mover.
155    ///
156    /// # Panics
157    ///
158    /// Panics like [`TestHarness::with_database`] for any dialect built.
159    #[must_use]
160    pub fn all_dialects_with_ports(
161        make_modules: impl Fn() -> Vec<Box<dyn Module>>,
162        patch: impl Fn(&mut Ports) + Clone,
163    ) -> Vec<Self> {
164        Dialect::available()
165            .into_iter()
166            .map(|dialect| {
167                let patch = patch.clone();
168                Self::with_database_and_ports(make_modules(), dialect, move |ports| patch(ports))
169            })
170            .collect()
171    }
172
173    pub(crate) fn from_arcs(
174        shared: Vec<Arc<dyn Module>>,
175        dialect: Dialect,
176        patch: impl FnOnce(&mut Ports),
177    ) -> Self {
178        Self::from_arcs_with_builder(shared, dialect, |builder| builder, patch)
179    }
180
181    /// [`TestHarness::with_ports`] with a hook over the `HarnessBuilder`
182    /// before it builds: mount a UI renderer (`.ui(..)`), add a template
183    /// override, anything the venture would do in `harness.rs`.
184    ///
185    /// # Panics
186    ///
187    /// Panics when the harness cannot build or a migration fails.
188    #[must_use]
189    pub fn with_builder(
190        modules: Vec<Box<dyn Module>>,
191        configure: impl FnOnce(HarnessBuilder) -> HarnessBuilder,
192        patch: impl FnOnce(&mut Ports),
193    ) -> Self {
194        let shared: Vec<Arc<dyn Module>> = modules.into_iter().map(Arc::from).collect();
195        Self::from_arcs_with_builder(shared, Dialect::Sqlite, configure, patch)
196    }
197
198    fn from_arcs_with_builder(
199        shared: Vec<Arc<dyn Module>>,
200        dialect: Dialect,
201        configure: impl FnOnce(HarnessBuilder) -> HarnessBuilder,
202        patch: impl FnOnce(&mut Ports),
203    ) -> Self {
204        let mut builder = Harness::builder().venture(
205            Venture::new("test-venture", "test.example").cors_origins(["https://test.example"]),
206        );
207        for module in &shared {
208            builder = builder.module_arc(Arc::clone(module));
209        }
210        let harness = configure(builder)
211            .runtime(TestRuntime)
212            .build()
213            .expect("test harness builds");
214
215        let dialect_name = dialect.name();
216        #[cfg(feature = "postgres")]
217        let (db, pg) = backing(dialect, &shared);
218        #[cfg(not(feature = "postgres"))]
219        let (db, _no_postgres_feature) = backing(dialect, &shared);
220
221        let mailer = FakeMailer::new(crate::fakes::MailerMode::SendOk);
222        let captcha = FakeCaptcha::allow_all();
223        let rate_limiter = FakeRateLimiter::always_allow();
224        let clock = FixedClock(
225            time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("fixed epoch"),
226        );
227        let kv = MemoryKeyValue::new();
228        let http = FakeHttpClient::ok_json("{}");
229        let defer = FakeDefer::new();
230        let signer = Arc::new(
231            HmacSigner::new(crate::TEST_HARNESS_SECRET, None).expect("test secret is long enough"),
232        );
233
234        let mut ports = Ports::with_config(Arc::new(MapConfig::default()));
235        ports.db = Some(db.clone());
236        ports.mailer = Some(Arc::new(mailer.clone()));
237        ports.captcha = Some(Arc::new(captcha.clone()));
238        ports.rate_limiter = Some(Arc::new(rate_limiter.clone()));
239        ports.signer = Some(signer.clone());
240        ports.kv = Some(Arc::new(kv.clone()));
241        ports.http = Some(Arc::new(http.clone()));
242        ports.clock = Some(Arc::new(clock.clone()));
243        ports.id_gen = Some(Arc::new(UlidIdGen));
244        ports.defer = Some(Arc::new(defer.clone()));
245        patch(&mut ports);
246
247        let router = harness.router(ports);
248        Self {
249            router,
250            harness,
251            mailer,
252            captcha,
253            rate_limiter,
254            clock,
255            kv,
256            http,
257            defer,
258            signer,
259            db,
260            modules: shared,
261            dialect: dialect_name,
262            #[cfg(feature = "postgres")]
263            pg,
264        }
265    }
266}
267
268/// A fresh in-memory SQLite database with every module's sqlite
269/// migrations applied. Panics on the first failure, naming the module.
270fn sqlite_backing(modules: &[Arc<dyn Module>]) -> Arc<dyn Database> {
271    let db = Arc::new(SqliteDatabase::in_memory().expect("in-memory sqlite"));
272    for module in modules {
273        db.apply_migrations(module.name(), module.migrations().sqlite)
274            .unwrap_or_else(|err| panic!("migration for {}: {err}", module.name()));
275    }
276    db
277}
278
279/// The migrated database (and, on Postgres, the fixture owning the
280/// throwaway database and its runtime) for the dialect.
281#[cfg(feature = "postgres")]
282fn backing(
283    dialect: Dialect,
284    modules: &[Arc<dyn Module>],
285) -> (Arc<dyn Database>, Option<crate::pg::PgFixture>) {
286    match dialect {
287        Dialect::Sqlite => (sqlite_backing(modules), None),
288        Dialect::Postgres { url } => {
289            let fixture = crate::pg::PgFixture::create(&url, modules)
290                .unwrap_or_else(|message| panic!("postgres parity kit: {message}"));
291            let db = fixture.database();
292            (db, Some(fixture))
293        }
294    }
295}
296
297/// Without the `postgres` feature there is no Postgres fixture type; the
298/// second element of the pair is always `None` and asking for the
299/// Postgres dialect fails loudly instead of silently passing.
300#[cfg(not(feature = "postgres"))]
301fn backing(
302    dialect: Dialect,
303    modules: &[Arc<dyn Module>],
304) -> (Arc<dyn Database>, Option<std::convert::Infallible>) {
305    match dialect {
306        Dialect::Sqlite => (sqlite_backing(modules), None),
307        Dialect::Postgres { .. } => panic!(
308            "cratefield-testing was built without the `postgres` feature — the Postgres \
309             parity leg needs it (dev-depend on cratefield-testing with \
310             features = [\"postgres\"])"
311        ),
312    }
313}