Skip to main content

gize_testing/
lib.rs

1//! Test utilities for Gize-generated applications (WS-B5).
2//!
3//! Two small, dependency-free helpers make it practical to test a generated app end-to-end:
4//! [`EphemeralSqlite`] gives a throwaway SQLite database (no server needed — ideal for CI),
5//! and [`App`] spawns a compiled app binary against it and waits until it is accepting
6//! connections. Both clean up on `Drop`. Bring your own HTTP client for the assertions.
7//!
8//! ```no_run
9//! use gize_testing::{App, EphemeralSqlite};
10//!
11//! let db = EphemeralSqlite::new();
12//! let app = App::spawn("target/debug/blog", &db)?;
13//! // app.base_url() is now serving; make requests, assert, then drop to shut down.
14//! # Ok::<(), std::io::Error>(())
15//! ```
16
17use std::net::{TcpListener, TcpStream};
18use std::path::PathBuf;
19use std::process::{Child, Command};
20use std::sync::atomic::{AtomicUsize, Ordering};
21use std::time::{Duration, Instant};
22
23/// A throwaway SQLite database file, removed when dropped. Serverless — perfect for CI.
24pub struct EphemeralSqlite {
25    path: PathBuf,
26}
27
28impl EphemeralSqlite {
29    /// Create a unique temp database path (the file is created by the app/migrator on first
30    /// connect via `?mode=rwc`).
31    pub fn new() -> Self {
32        static COUNTER: AtomicUsize = AtomicUsize::new(0);
33        let name = format!(
34            "gize-test-{}-{}.db",
35            std::process::id(),
36            COUNTER.fetch_add(1, Ordering::Relaxed)
37        );
38        Self {
39            path: std::env::temp_dir().join(name),
40        }
41    }
42
43    /// The `sqlite://…?mode=rwc` URL for `DATABASE_URL` (creates the file if missing).
44    pub fn url(&self) -> String {
45        format!("sqlite://{}?mode=rwc", self.path.display())
46    }
47}
48
49impl Default for EphemeralSqlite {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl Drop for EphemeralSqlite {
56    fn drop(&mut self) {
57        // Best-effort cleanup of the db file and its sqlite side-files.
58        let _ = std::fs::remove_file(&self.path);
59        let name = self
60            .path
61            .file_name()
62            .unwrap()
63            .to_string_lossy()
64            .into_owned();
65        for suffix in ["-wal", "-shm"] {
66            let mut side = self.path.clone();
67            side.set_file_name(format!("{name}{suffix}"));
68            let _ = std::fs::remove_file(&side);
69        }
70    }
71}
72
73/// Pick a free TCP port by binding to `:0` and reading back the assigned port.
74pub fn free_port() -> u16 {
75    TcpListener::bind("127.0.0.1:0")
76        .expect("binding a free port")
77        .local_addr()
78        .expect("reading the local address")
79        .port()
80}
81
82/// A spawned generated app. Killed when dropped.
83pub struct App {
84    child: Child,
85    port: u16,
86}
87
88impl App {
89    /// Spawn `binary` against `db` on a free port, waiting until it accepts connections.
90    ///
91    /// Sets `DATABASE_URL`, `PORT` and a throwaway `GIZE_JWT_SECRET` in the child's
92    /// environment. Assumes migrations have already been applied to `db`.
93    pub fn spawn(binary: &str, db: &EphemeralSqlite) -> std::io::Result<Self> {
94        let port = free_port();
95        let child = Command::new(binary)
96            .env("DATABASE_URL", db.url())
97            .env("PORT", port.to_string())
98            .env("GIZE_JWT_SECRET", "gize-testing-secret")
99            .spawn()?;
100
101        let mut app = App { child, port };
102        app.wait_until_ready(Duration::from_secs(30))?;
103        Ok(app)
104    }
105
106    /// The base URL, e.g. `http://127.0.0.1:8080`.
107    pub fn base_url(&self) -> String {
108        format!("http://127.0.0.1:{}", self.port)
109    }
110
111    /// Block until the app's port accepts a TCP connection, or `timeout` elapses.
112    fn wait_until_ready(&mut self, timeout: Duration) -> std::io::Result<()> {
113        let deadline = Instant::now() + timeout;
114        let addr = format!("127.0.0.1:{}", self.port);
115        loop {
116            if TcpStream::connect(&addr).is_ok() {
117                return Ok(());
118            }
119            if Instant::now() >= deadline {
120                return Err(std::io::Error::new(
121                    std::io::ErrorKind::TimedOut,
122                    format!("app did not start listening on {addr} within {timeout:?}"),
123                ));
124            }
125            std::thread::sleep(Duration::from_millis(100));
126        }
127    }
128}
129
130impl Drop for App {
131    fn drop(&mut self) {
132        let _ = self.child.kill();
133        let _ = self.child.wait();
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn ephemeral_sqlite_urls_are_unique_and_rwc() {
143        let a = EphemeralSqlite::new();
144        let b = EphemeralSqlite::new();
145        assert_ne!(a.url(), b.url());
146        assert!(a.url().starts_with("sqlite://"));
147        assert!(a.url().ends_with("?mode=rwc"));
148    }
149
150    #[test]
151    fn free_port_is_bindable() {
152        let port = free_port();
153        // The port was free; binding it now should succeed (nothing grabbed it yet).
154        assert!(
155            TcpListener::bind(("127.0.0.1", port)).is_ok(),
156            "expected the reported free port to be bindable"
157        );
158    }
159}