slave-framework 0.9.1

Runtime for slave apps (a Laravel-inspired Rust framework for MCP-first apps): server lifecycle, config, logging, a Toasty database (PostgreSQL), and OpenID Connect sign-in
Documentation
//! Test doubles for apps' integration tests: a database ([`TestDatabase`])
//! and an OpenID Connect provider ([`TestIdentityProvider`]).
//!
//! Each [`TestDatabase`] is a fresh PostgreSQL database on the server in
//! `TEST_DATABASE_URL` (default [`DEFAULT_SERVER_URL`]), dropped when the
//! test ends, so tests never share data and can run in parallel.

use std::sync::atomic::{AtomicUsize, Ordering};

use crate::database::{DatabaseConfig, URL_ENV};

mod idp;

pub use idp::{TEST_CLIENT_ID, TestIdentityProvider, TokenSpec};

/// Environment variable naming the PostgreSQL server tests create their
/// databases on (any database on it; the user must be allowed to create
/// databases).
pub const SERVER_URL_ENV: &str = "TEST_DATABASE_URL";

/// The server used when `TEST_DATABASE_URL` is not set.
pub const DEFAULT_SERVER_URL: &str = "postgresql://localhost:5432/postgres";

/// An empty database for one test, dropped when this is dropped.
pub struct TestDatabase {
    config: DatabaseConfig,
    server: String,
    name: String,
}

impl TestDatabase {
    /// Creates an empty database. Panics if the server is not reachable.
    pub fn start() -> Self {
        static COUNTER: AtomicUsize = AtomicUsize::new(0);
        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
        let server =
            std::env::var(SERVER_URL_ENV).unwrap_or_else(|_| DEFAULT_SERVER_URL.to_owned());
        let name = format!("slave_test_{}_{n}", std::process::id());
        admin(
            &server,
            &[
                format!("DROP DATABASE IF EXISTS \"{name}\""),
                format!("CREATE DATABASE \"{name}\""),
            ],
        )
        .unwrap_or_else(|err| {
            panic!(
                "cannot create a test database on {SERVER_URL_ENV}={server}: {err} \
                 (start PostgreSQL or set {SERVER_URL_ENV})"
            )
        });
        let config = DatabaseConfig {
            url: with_database(&server, &name),
        };
        Self {
            config,
            server,
            name,
        }
    }

    pub fn config(&self) -> &DatabaseConfig {
        &self.config
    }

    /// `DATABASE_URL` for processes under test.
    pub fn envs(&self) -> [(&'static str, &str); 1] {
        [(URL_ENV, self.config.url.as_str())]
    }

    /// A pooled handle to this database for `models`.
    pub async fn connect(&self, models: toasty::ModelSet) -> toasty::Db {
        crate::database::connect(models, &self.config)
            .await
            .expect("open the test database")
    }
}

impl Drop for TestDatabase {
    fn drop(&mut self) {
        let drop = format!("DROP DATABASE IF EXISTS \"{}\" WITH (FORCE)", self.name);
        if let Err(err) = admin(&self.server, &[drop]) {
            eprintln!("warning: could not drop test database {}: {err}", self.name);
        }
    }
}

/// Runs `statements` on `server`, on a thread of its own so it works from
/// sync code and from inside a Tokio runtime alike.
fn admin(server: &str, statements: &[String]) -> Result<(), String> {
    let server = server.to_owned();
    let statements = statements.to_vec();
    std::thread::spawn(move || {
        let runtime = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .map_err(|err| err.to_string())?;
        runtime.block_on(async {
            let (client, connection) = tokio_postgres::connect(&server, tokio_postgres::NoTls)
                .await
                .map_err(|err| err.to_string())?;
            let connection = tokio::spawn(connection);
            for statement in &statements {
                client
                    .batch_execute(statement)
                    .await
                    .map_err(|err| err.to_string())?;
            }
            drop(client);
            let _ = connection.await;
            Ok(())
        })
    })
    .join()
    .map_err(|_| "the database thread panicked".to_owned())?
}

/// `url` with its database (path) replaced by `name`, keeping the query.
fn with_database(url: &str, name: &str) -> String {
    let (base, query) = match url.split_once('?') {
        Some((base, query)) => (base, Some(query)),
        None => (url, None),
    };
    let authority_start = base.find("://").map_or(0, |i| i + 3);
    let base = match base[authority_start..].find('/') {
        Some(slash) => &base[..authority_start + slash],
        None => base,
    };
    match query {
        Some(query) => format!("{base}/{name}?{query}"),
        None => format!("{base}/{name}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn replaces_the_database_in_urls() {
        assert_eq!(
            with_database("postgresql://localhost:5432/postgres", "t1"),
            "postgresql://localhost:5432/t1"
        );
        assert_eq!(
            with_database("postgresql://u:p@db/x?sslmode=disable", "t1"),
            "postgresql://u:p@db/t1?sslmode=disable"
        );
        assert_eq!(with_database("postgresql://db", "t1"), "postgresql://db/t1");
    }
}