Skip to main content

umbral_testing/
lib.rs

1//! umbral-testing — test helpers for umbral apps.
2//!
3//! Test-case + client ergonomics, in the Rust shape. The
4//! repeated work in every plugin's `tests/integration.rs` was four
5//! things: spin up a fresh sqlite pool, build the router, send
6//! requests, read the response. This crate collapses those into:
7//!
8//! - [`TempPool`] — a tempfile-backed SQLite pool that's dropped
9//!   when the guard goes out of scope.
10//! - [`TestClient`] — wraps an [`axum::Router`] with HTTP-verb-
11//!   shaped methods, a per-client cookie jar (so a session set on
12//!   one request rides on the next), and JSON helpers.
13//! - [`TestResponse`] — owns the response bytes and headers and
14//!   exposes assertion helpers (`assert_status`, `body_json`,
15//!   `assert_body_contains`).
16//!
17//! This crate is **NOT** a plugin. It's a sibling utility library
18//! consumed by test code — drop `umbral-testing` into a crate's
19//! `[dev-dependencies]` and you don't carry it into release builds.
20//!
21//! ```ignore
22//! use umbral_testing::{TempPool, TestClient};
23//!
24//! #[tokio::test]
25//! async fn list_endpoint_returns_seeded_rows() {
26//!     let pool = TempPool::new().await;
27//!     // ... build router using pool.handle() ...
28//!     let client = TestClient::new(router);
29//!     let resp = client.get("/api/notes").await;
30//!     resp.assert_status_ok();
31//!     let notes: Vec<Note> = resp.body_json();
32//!     assert_eq!(notes.len(), 2);
33//! }
34//! ```
35
36use std::sync::Mutex;
37
38use axum::Router;
39use axum::body::Body;
40use http::header::{COOKIE, HeaderName, HeaderValue, SET_COOKIE};
41use http::{HeaderMap, Method, Request, StatusCode};
42use http_body_util::BodyExt;
43use serde::Serialize;
44use serde::de::DeserializeOwned;
45use sqlx::SqlitePool;
46use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
47use tempfile::TempDir;
48use tower::ServiceExt;
49
50/// A tempfile-backed SQLite pool. Holding the [`TempPool`] keeps the
51/// underlying directory alive; dropping it deletes the database file
52/// and every WAL artefact alongside.
53///
54/// In-memory SQLite (`sqlite::memory:`) would be the obvious choice
55/// but it isolates per-connection: pool size > 1 means different
56/// connections see different databases. The tempfile path
57/// sidesteps that completely.
58pub struct TempPool {
59    pool: SqlitePool,
60    _dir: TempDir,
61}
62
63impl TempPool {
64    /// Build a fresh pool with `max_connections = 5`.
65    pub async fn new() -> Self {
66        Self::with_max_connections(5).await
67    }
68
69    pub async fn with_max_connections(n: u32) -> Self {
70        let dir = tempfile::tempdir().expect("tempdir for TempPool");
71        let path = dir.path().join("umbral_test.sqlite");
72        let pool = SqlitePoolOptions::new()
73            .max_connections(n)
74            .connect_with(
75                SqliteConnectOptions::new()
76                    .filename(&path)
77                    .create_if_missing(true)
78                    // A file-backed pool with >1 connection contends under the
79                    // load of a full `cargo test --workspace` run; without a
80                    // busy-timeout SQLite returns SQLITE_BUSY instantly instead
81                    // of waiting, which surfaces as flaky "empty body" failures.
82                    // Mirrors the 5s busy-timeout the framework's real
83                    // `connect_sqlite` applies to production pools.
84                    .busy_timeout(std::time::Duration::from_secs(5)),
85            )
86            .await
87            .expect("connect to tempfile sqlite");
88        Self { pool, _dir: dir }
89    }
90
91    /// Borrow the underlying pool. Clone for ownership.
92    pub fn handle(&self) -> &SqlitePool {
93        &self.pool
94    }
95
96    /// Clone the pool out. Each clone shares the same backing
97    /// connection pool.
98    pub fn clone_handle(&self) -> SqlitePool {
99        self.pool.clone()
100    }
101}
102
103/// A simple cookie jar: a flat list of `name=value` pairs. Good
104/// enough for end-to-end test flows that exchange session and CSRF
105/// cookies; not RFC 6265 compliant (no domain, path, or expiry
106/// tracking).
107#[derive(Default)]
108struct CookieJar {
109    cookies: Vec<(String, String)>,
110}
111
112impl CookieJar {
113    fn set_from_header(&mut self, header: &str) {
114        // Server `Set-Cookie` shape: `name=value; Path=/; ...`. Take
115        // the bit before the first `;` as the name=value pair.
116        let pair = header.split(';').next().unwrap_or("").trim();
117        if let Some((name, value)) = pair.split_once('=') {
118            self.cookies.retain(|(n, _)| n != name);
119            self.cookies.push((name.to_string(), value.to_string()));
120        }
121    }
122
123    fn cookie_header(&self) -> Option<String> {
124        if self.cookies.is_empty() {
125            return None;
126        }
127        Some(
128            self.cookies
129                .iter()
130                .map(|(n, v)| format!("{n}={v}"))
131                .collect::<Vec<_>>()
132                .join("; "),
133        )
134    }
135
136    fn get(&self, name: &str) -> Option<&str> {
137        self.cookies
138            .iter()
139            .find(|(n, _)| n == name)
140            .map(|(_, v)| v.as_str())
141    }
142}
143
144/// A test client over an axum [`Router`]. Stateful: cookies set on
145/// one response automatically ride on the next request.
146pub struct TestClient {
147    router: Router,
148    jar: Mutex<CookieJar>,
149    default_headers: Mutex<HeaderMap>,
150}
151
152impl TestClient {
153    pub fn new(router: Router) -> Self {
154        Self {
155            router,
156            jar: Mutex::new(CookieJar::default()),
157            default_headers: Mutex::new(HeaderMap::new()),
158        }
159    }
160
161    /// Add a header that rides on every subsequent request. Useful
162    /// for setting an `Authorization` once per test.
163    pub fn set_default_header(&self, name: HeaderName, value: HeaderValue) {
164        self.default_headers
165            .lock()
166            .expect("default headers poisoned")
167            .insert(name, value);
168    }
169
170    /// Read a cookie the server has set on the jar.
171    pub fn cookie(&self, name: &str) -> Option<String> {
172        self.jar
173            .lock()
174            .expect("cookie jar poisoned")
175            .get(name)
176            .map(str::to_string)
177    }
178
179    pub async fn get(&self, uri: &str) -> TestResponse {
180        self.request(Method::GET, uri, Body::empty(), None).await
181    }
182
183    pub async fn post(&self, uri: &str, body: Body) -> TestResponse {
184        self.request(Method::POST, uri, body, None).await
185    }
186
187    /// POST a value serialized to JSON with `Content-Type:
188    /// application/json`.
189    pub async fn post_json<T: Serialize + ?Sized>(&self, uri: &str, body: &T) -> TestResponse {
190        let bytes = serde_json::to_vec(body).expect("serialize body");
191        self.request(
192            Method::POST,
193            uri,
194            Body::from(bytes),
195            Some(("content-type", "application/json")),
196        )
197        .await
198    }
199
200    pub async fn put_json<T: Serialize + ?Sized>(&self, uri: &str, body: &T) -> TestResponse {
201        let bytes = serde_json::to_vec(body).expect("serialize body");
202        self.request(
203            Method::PUT,
204            uri,
205            Body::from(bytes),
206            Some(("content-type", "application/json")),
207        )
208        .await
209    }
210
211    pub async fn delete(&self, uri: &str) -> TestResponse {
212        self.request(Method::DELETE, uri, Body::empty(), None).await
213    }
214
215    /// Send a fully-formed request. Use for verbs without a typed
216    /// helper or for unusual headers.
217    pub async fn send(&self, method: Method, uri: &str, body: Body) -> TestResponse {
218        self.request(method, uri, body, None).await
219    }
220
221    async fn request(
222        &self,
223        method: Method,
224        uri: &str,
225        body: Body,
226        content_type: Option<(&str, &str)>,
227    ) -> TestResponse {
228        let mut builder = Request::builder().method(method).uri(uri);
229
230        // Replay default headers.
231        for (k, v) in self.default_headers.lock().expect("dh").iter() {
232            builder = builder.header(k, v);
233        }
234        if let Some((k, v)) = content_type {
235            builder = builder.header(k, v);
236        }
237        if let Some(c) = self.jar.lock().expect("jar").cookie_header() {
238            builder = builder.header(COOKIE, c);
239        }
240
241        let req = builder.body(body).expect("build request");
242        let resp = self
243            .router
244            .clone()
245            .oneshot(req)
246            .await
247            .expect("router oneshot");
248
249        // Harvest set-cookies into the jar before stripping the body.
250        let status = resp.status();
251        let headers = resp.headers().clone();
252        for v in headers.get_all(SET_COOKIE) {
253            if let Ok(s) = v.to_str() {
254                self.jar.lock().expect("jar set").set_from_header(s);
255            }
256        }
257        let bytes = resp
258            .into_body()
259            .collect()
260            .await
261            .expect("collect body")
262            .to_bytes();
263
264        TestResponse {
265            status,
266            headers,
267            body: bytes.to_vec(),
268        }
269    }
270}
271
272/// The result of one round trip. Owns the response bytes so the
273/// caller can read them more than once (e.g. snapshot the raw body
274/// before parsing JSON, then assert).
275pub struct TestResponse {
276    pub status: StatusCode,
277    pub headers: HeaderMap,
278    pub body: Vec<u8>,
279}
280
281impl TestResponse {
282    pub fn status(&self) -> StatusCode {
283        self.status
284    }
285
286    pub fn headers(&self) -> &HeaderMap {
287        &self.headers
288    }
289
290    pub fn body_bytes(&self) -> &[u8] {
291        &self.body
292    }
293
294    pub fn body_text(&self) -> String {
295        String::from_utf8_lossy(&self.body).into_owned()
296    }
297
298    /// Parse the body as JSON. Panics with the raw body in the
299    /// message on a parse error — much friendlier in a failing test
300    /// than a bare serde error.
301    pub fn body_json<T: DeserializeOwned>(&self) -> T {
302        serde_json::from_slice(&self.body).unwrap_or_else(|e| {
303            panic!(
304                "body_json: failed to parse response as JSON ({e}). raw body:\n{}",
305                self.body_text()
306            )
307        })
308    }
309
310    /// Read the value of a single response header. None if missing
311    /// or non-UTF-8.
312    pub fn header(&self, name: &str) -> Option<String> {
313        self.headers
314            .get(name)
315            .and_then(|v| v.to_str().ok())
316            .map(str::to_string)
317    }
318
319    pub fn assert_status(&self, expected: StatusCode) -> &Self {
320        assert_eq!(
321            self.status,
322            expected,
323            "expected status {expected}, got {} with body:\n{}",
324            self.status,
325            self.body_text()
326        );
327        self
328    }
329
330    pub fn assert_status_ok(&self) -> &Self {
331        self.assert_status(StatusCode::OK)
332    }
333
334    pub fn assert_body_contains(&self, needle: &str) -> &Self {
335        let body = self.body_text();
336        assert!(
337            body.contains(needle),
338            "expected body to contain {needle:?}\n--- got ---\n{body}\n-----------"
339        );
340        self
341    }
342
343    pub fn assert_header(&self, name: &str, expected: &str) -> &Self {
344        let actual = self.header(name);
345        assert_eq!(
346            actual.as_deref(),
347            Some(expected),
348            "expected header {name} to be {expected:?}, got {actual:?}"
349        );
350        self
351    }
352}
353
354// =========================================================================
355// Factory — realistic test data (feature #79).
356// =========================================================================
357
358/// Re-export of the [`fake`] crate so factories can reach its generators
359/// (`umbral_testing::fake::faker::...`, the `Fake` trait) without adding a
360/// direct dependency of their own.
361pub use fake;
362
363use std::sync::atomic::{AtomicU64, Ordering};
364
365/// A process-wide monotonic counter for unique values within a test run.
366/// Use it to keep `unique` columns (slugs, emails, crate names) from
367/// colliding across a `create_batch`:
368///
369/// ```ignore
370/// slug: format!("plugin-{}", umbral_testing::seq()),
371/// ```
372pub fn seq() -> u64 {
373    static SEQ: AtomicU64 = AtomicU64::new(0);
374    SEQ.fetch_add(1, Ordering::Relaxed) + 1
375}
376
377/// Error from a [`Factory`] persistence call.
378#[derive(Debug)]
379pub enum FactoryError {
380    /// The ORM write failed (constraint violation, missing table, an FK
381    /// that doesn't exist yet, …).
382    Write(umbral::orm::write::WriteError),
383}
384
385impl std::fmt::Display for FactoryError {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        match self {
388            FactoryError::Write(e) => write!(f, "factory write failed: {e}"),
389        }
390    }
391}
392
393impl std::error::Error for FactoryError {}
394
395impl From<umbral::orm::write::WriteError> for FactoryError {
396    fn from(e: umbral::orm::write::WriteError) -> Self {
397        FactoryError::Write(e)
398    }
399}
400
401/// A factory for producing realistic instances of a model — the
402/// factory_boy / FactoryBot shape, in Rust.
403///
404/// You define a zero-sized marker type and point it at a [`Model`] through
405/// the associated type. The orphan rule is why the impl lives on a marker
406/// rather than on the model: in a downstream test crate both the model and
407/// this trait are foreign, so `impl Factory for Plugin` wouldn't compile —
408/// but `impl Factory for PluginFactory` (a local marker) does.
409///
410/// ```ignore
411/// use umbral_testing::{Factory, fake::{Fake, faker::{lorem::en::*, company::en::*}}, seq};
412///
413/// struct PluginFactory;
414/// impl Factory for PluginFactory {
415///     type Model = Plugin;
416///     fn build() -> Plugin {
417///         let mut p = Plugin::default();
418///         p.name = CompanyName().fake();
419///         p.slug = format!("plugin-{}", seq());          // unique per call
420///         p.short_description = Sentence(4..8).fake();
421///         p
422///     }
423/// }
424///
425/// // In a test, after `App::builder()...build()` has set the ambient pool
426/// // and the tables exist:
427/// let one      = PluginFactory::create().await?;                    // one row
428/// let many     = PluginFactory::create_batch(5).await?;             // five rows
429/// let featured = PluginFactory::create_with(|p| p.featured = true).await?;
430/// ```
431///
432/// [`build`](Factory::build) is pure (no I/O); the `create*` methods
433/// persist through the ORM against the ambient pool, so a built app must
434/// be in scope. Combine with [`TestClient`] to then exercise a handler
435/// against the rows the factory produced.
436///
437/// [`Model`]: umbral::orm::Model
438#[async_trait::async_trait]
439pub trait Factory {
440    /// The model this factory produces. The bound set is exactly what
441    /// `#[derive(Model)]` already provides on every model (the ORM's
442    /// `create` path needs `Serialize` + `FromRow` + `HydrateRelated`), so
443    /// in practice you only ever write `type Model = YourModel;`.
444    type Model: umbral::orm::Model
445        + serde::Serialize
446        + for<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow>
447        + for<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow>
448        + umbral::orm::HydrateRelated;
449
450    /// A fresh, unsaved instance with realistic fake values. Pure — no
451    /// database I/O. Override `unique` fields with [`seq`] so a batch
452    /// doesn't collide.
453    fn build() -> Self::Model;
454
455    /// Build and persist one row through the ORM.
456    async fn create() -> Result<Self::Model, FactoryError> {
457        Self::create_with(|_| {}).await
458    }
459
460    /// Build one row, apply `tweak` to override specific fields, then
461    /// persist. This is the `create(featured = true)` override hook.
462    async fn create_with<F>(tweak: F) -> Result<Self::Model, FactoryError>
463    where
464        F: FnOnce(&mut Self::Model) + Send,
465    {
466        let mut instance = Self::build();
467        tweak(&mut instance);
468        umbral::orm::Manager::<Self::Model>::default()
469            .create(instance)
470            .await
471            .map_err(FactoryError::Write)
472    }
473
474    /// Build and persist `n` rows.
475    async fn create_batch(n: usize) -> Result<Vec<Self::Model>, FactoryError> {
476        let mut out = Vec::with_capacity(n);
477        for _ in 0..n {
478            out.push(Self::create().await?);
479        }
480        Ok(out)
481    }
482}
483
484// =========================================================================
485// Schema — build the test database from the models themselves (feature #79)
486// =========================================================================
487
488/// Create a table for every registered model, deriving the schema from the
489/// models themselves.
490///
491/// A thin alias for [`umbral::migrate::create_tables_for_tests`], which is where
492/// the implementation lives — the migration engine already knows how to turn a
493/// `ModelMeta` into DDL, and umbral-core's own test suite needs the same helper
494/// without a dev-dependency cycle back through this crate.
495///
496/// **This is the one that stops your tests lying to you.** The alternative is a
497/// hand-written `CREATE TABLE` in the test file: a *second source of truth* for
498/// the schema, which drifts the moment a model grows a column. The ORM then
499/// queries a column that isn't there, and any error-swallowing on the path (an
500/// `unwrap_or(false)`) turns that into a test that PASSES WITH THE WRONG ANSWER.
501///
502/// ```ignore
503/// umbral::App::builder()
504///     .settings(settings)
505///     .database("default", pool.clone_handle())
506///     .model::<Note>()
507///     .build()?;
508///
509/// umbral_testing::create_tables().await?;   // schema == your models. always.
510/// ```
511///
512/// Idempotent, and works on both backends. Most tests want [`boot`], which calls
513/// this for you.
514pub async fn create_tables() -> Result<(), umbral::migrate::MigrateError> {
515    umbral::migrate::create_tables_for_tests().await.map(|_| ())
516}
517
518// =========================================================================
519// boot — one app per test process, without the OnceCell dance (feature #79)
520// =========================================================================
521
522/// Boot a throwaway app for a test binary: in-memory SQLite, your models and
523/// plugins, and a schema derived from those models.
524///
525/// `App::build()` initialises process-wide state (settings, the ambient pool,
526/// the model registry) and **panics if it runs twice**. Every test file in this
527/// repo therefore reinvents the same `OnceCell` + `Mutex` dance, and gets it
528/// subtly wrong in different ways. This is that dance, once, in the library:
529/// the first call builds, every later call is a no-op, so each `#[tokio::test]`
530/// can just say what it needs at the top.
531///
532/// ```ignore
533/// use umbral_testing::{boot, Factory};
534///
535/// #[tokio::test]
536/// async fn a_note_can_be_created() {
537///     boot(|b| b.model::<Note>()).await;      // safe to call from every test
538///     let note = NoteFactory::create().await.unwrap();
539///     assert_eq!(Note::objects().count().await.unwrap(), 1);
540/// }
541/// ```
542///
543/// The closure receives the [`AppBuilder`] mid-flight, so plugins, models and
544/// settings tweaks all go in there:
545///
546/// ```ignore
547/// boot(|b| b.plugin(AuthPlugin::<AuthUser>::default()).model::<Note>()).await;
548/// ```
549///
550/// The schema is created by [`create_tables`], so it is the models' schema — no
551/// hand-written `CREATE TABLE` to drift out of sync.
552///
553/// Rows persist for the life of the test binary (one database per process). Tests
554/// in the same file share it, so make your fixtures distinct — [`seq`] is there
555/// for exactly that — or assert on rows you created rather than on global counts.
556///
557/// [`AppBuilder`]: umbral::AppBuilder
558pub async fn boot<F>(configure: F)
559where
560    F: FnOnce(umbral::AppBuilder) -> umbral::AppBuilder,
561{
562    static BOOTED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
563    BOOTED
564        .get_or_init(|| async {
565            let pool = umbral::db::connect_sqlite("sqlite::memory:")
566                .await
567                .expect("umbral-testing: in-memory sqlite pool");
568            let mut settings =
569                umbral::Settings::from_env().expect("umbral-testing: settings from env");
570            settings.database_url = "sqlite::memory:".to_string();
571
572            let builder = umbral::App::builder()
573                .settings(settings)
574                .database("default", pool);
575
576            configure(builder)
577                .build()
578                .expect("umbral-testing: App::build");
579
580            create_tables()
581                .await
582                .expect("umbral-testing: create the test schema");
583        })
584        .await;
585}