slave-framework 0.10.0

Runtime for slave apps (a Laravel-inspired Rust framework for MCP-first apps): server lifecycle, config, logging, a SurrealDB database with SurrealQL migrations, 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 database on the SurrealDB server in
//! `TEST_SURREAL_URL` (default [`DEFAULT_SERVER_URL`], signed in as
//! `TEST_SURREAL_USER`/`TEST_SURREAL_PASS`, default `root`/`root`), removed
//! when the test ends, so tests never share data and can run in parallel.
//! Start one with `surreal start --user root --pass root`.

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

use crate::database::{AuthLevel, DatabaseConfig, Db, is_transaction_conflict};

mod idp;

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

/// Environment variable naming the SurrealDB server tests create their
/// databases on. Its user must be a root user.
pub const SERVER_URL_ENV: &str = "TEST_SURREAL_URL";
pub const USER_ENV: &str = "TEST_SURREAL_USER";
pub const PASSWORD_ENV: &str = "TEST_SURREAL_PASS";

/// The server used when `TEST_SURREAL_URL` is not set.
pub const DEFAULT_SERVER_URL: &str = crate::database::DEFAULT_URL;

/// The namespace test databases are created in.
pub const NAMESPACE: &str = "slave_test";

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

impl TestDatabase {
    /// Picks a database name of its own on the test server; SurrealDB
    /// creates it on first use. 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 var =
            |key: &str, default: &str| std::env::var(key).unwrap_or_else(|_| default.to_owned());
        let config = DatabaseConfig {
            url: var(SERVER_URL_ENV, DEFAULT_SERVER_URL),
            namespace: NAMESPACE.to_owned(),
            database: format!("test_{}_{n}", std::process::id()),
            username: Some(var(USER_ENV, "root")),
            password: Some(var(PASSWORD_ENV, "root")),
            auth_level: AuthLevel::Root,
        };
        admin(
            &config,
            &format!("DEFINE DATABASE IF NOT EXISTS {}", config.database),
        )
        .unwrap_or_else(|err| {
            panic!(
                "cannot reach the test database server {SERVER_URL_ENV}={}: {err} \
                 (run `surreal start --user root --pass root` or set {SERVER_URL_ENV})",
                config.url
            )
        });
        Self { config }
    }

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

    /// `SURREAL_*` settings for processes under test.
    pub fn envs(&self) -> Vec<(&'static str, String)> {
        self.config.envs()
    }

    /// A connection to this database.
    pub async fn connect(&self) -> Db {
        crate::database::connect(&self.config)
            .await
            .expect("connect to the test database")
    }

    /// Applies the migrations in `dir` (usually `database/migrations`).
    pub async fn migrate(&self, dir: &std::path::Path) -> Db {
        let db = self.connect().await;
        crate::database::migrations::apply(&db, dir)
            .await
            .expect("apply migrations");
        db
    }
}

impl Drop for TestDatabase {
    fn drop(&mut self) {
        let statement = format!("REMOVE DATABASE IF EXISTS {}", self.config.database);
        if let Err(err) = admin(&self.config, &statement) {
            eprintln!(
                "warning: could not remove test database {}: {err}",
                self.config.database
            );
        }
    }
}

/// Runs `statement` in the test namespace, on a thread of its own so it
/// works from sync code and from inside a Tokio runtime alike. Retried when
/// it conflicts with another test doing the same.
fn admin(config: &DatabaseConfig, statement: &str) -> Result<(), String> {
    let config = config.clone();
    let statement = statement.to_owned();
    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 mut attempt = 1;
            loop {
                let result = async {
                    let db = crate::database::connect(&config).await?;
                    db.query(statement.as_str()).await?.check()?;
                    crate::Result::Ok(())
                }
                .await;
                match result {
                    Err(crate::Error::Database(err))
                        if is_transaction_conflict(&err) && attempt < 10 =>
                    {
                        attempt += 1;
                        tokio::time::sleep(std::time::Duration::from_millis(20 * attempt as u64))
                            .await;
                    }
                    result => return result.map_err(|err| err.to_string()),
                }
            }
        })
    })
    .join()
    .map_err(|_| "the database thread panicked".to_owned())?
}