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