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}