use std::fmt;
use sqlx::Transaction;
use crate::database::dialect::{as_text, placeholder};
use crate::database::{Connection, Driver, Pool};
pub const TEST_DB_URL_VAR: &str = "ARCATURE_TEST_DB_URL";
pub const TEST_DB_PREFIX: &str = "arcature_test_";
pub const REQUIRE_TEST_DB_VAR: &str = "ARCATURE_REQUIRE_TEST_DB";
#[derive(Debug)]
pub enum TestDatabaseError {
NotConfigured,
NoDatabaseName {
url: String,
},
UnsafeDatabaseName {
name: String,
},
Connect(String),
Query(String),
}
impl fmt::Display for TestDatabaseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotConfigured => write!(
f,
"{TEST_DB_URL_VAR} is not set; database tests need a database, and skipping them silently would report success for work that never ran"
),
Self::NoDatabaseName { url } => {
write!(f, "{TEST_DB_URL_VAR} names no database: {url}")
}
Self::UnsafeDatabaseName { name } => write!(
f,
"refusing to run against database `{name}`: the name must start with `{TEST_DB_PREFIX}`, because these tests write to it"
),
Self::Connect(error) => write!(f, "could not connect to the test database: {error}"),
Self::Query(error) => write!(f, "test database query failed: {error}"),
}
}
}
impl std::error::Error for TestDatabaseError {}
fn database_name(url: &str) -> Option<&str> {
if is_sqlite_url(url) {
return sqlite_database_name(url);
}
let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
let path = after_scheme.split_once('/')?.1;
let name = path
.split(['?', '#'])
.next()
.unwrap_or_default()
.trim_end_matches('/');
if name.is_empty() { None } else { Some(name) }
}
fn is_sqlite_url(url: &str) -> bool {
let scheme = url.split_once(':').map_or("", |(scheme, _)| scheme);
scheme.eq_ignore_ascii_case("sqlite")
}
fn sqlite_database_name(url: &str) -> Option<&str> {
let after_scheme = url.split_once("://").map_or_else(
|| url.split_once(':').map_or(url, |(_, rest)| rest),
|(_, rest)| rest,
);
let path = after_scheme.split(['?', '#']).next().unwrap_or_default();
if path.trim_end_matches('/') == ":memory:" || path.is_empty() {
return Some(TEST_DB_PREFIX);
}
let file = path.rsplit(['/', '\\']).next().unwrap_or(path);
let stem = file.split_once('.').map_or(file, |(stem, _)| stem);
if stem.is_empty() { None } else { Some(stem) }
}
fn redact(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_owned();
};
let Some((credentials, host)) = rest.split_once('@') else {
return url.to_owned();
};
let user = credentials.split_once(':').map_or(credentials, |(u, _)| u);
format!("{scheme}://{user}:***@{host}")
}
pub fn check_url(url: &str) -> Result<(), TestDatabaseError> {
let Some(name) = database_name(url) else {
return Err(TestDatabaseError::NoDatabaseName { url: redact(url) });
};
if name.starts_with(TEST_DB_PREFIX) {
Ok(())
} else {
Err(TestDatabaseError::UnsafeDatabaseName {
name: name.to_owned(),
})
}
}
pub fn test_database_url() -> Result<String, TestDatabaseError> {
let url = std::env::var(TEST_DB_URL_VAR).map_err(|_| TestDatabaseError::NotConfigured)?;
check_url(&url)?;
Ok(url)
}
#[must_use]
pub fn test_database_required() -> bool {
std::env::var(REQUIRE_TEST_DB_VAR).is_ok_and(|value| !value.is_empty() && value != "0")
}
#[derive(Debug, Clone)]
pub struct TestDatabase {
pool: Pool,
}
impl TestDatabase {
pub async fn connect() -> Result<Self, TestDatabaseError> {
let url = test_database_url()?;
let pool = Pool::connect(&url)
.await
.map_err(|error| TestDatabaseError::Connect(error.to_string()))?;
Ok(Self { pool })
}
pub async fn optional() -> Option<Self> {
match Self::connect().await {
Ok(database) => Some(database),
Err(error @ TestDatabaseError::NotConfigured) => {
assert!(
!test_database_required(),
"{REQUIRE_TEST_DB_VAR} is set, so {TEST_DB_URL_VAR} has to be too: {error}"
);
None
}
Err(error) => panic!("{error}"),
}
}
pub fn from_pool(pool: Pool, url: &str) -> Result<Self, TestDatabaseError> {
check_url(url)?;
Ok(Self { pool })
}
#[must_use]
pub fn pool(&self) -> &Pool {
&self.pool
}
#[must_use]
pub fn db(&self) -> crate::database::Db {
crate::database::Db::from_pool(self.pool.clone())
}
pub async fn begin(&self) -> Result<TestTransaction, TestDatabaseError> {
let transaction = self
.pool
.begin()
.await
.map_err(|error| TestDatabaseError::Query(error.to_string()))?;
Ok(TestTransaction { transaction })
}
}
#[derive(Debug)]
pub struct TestTransaction {
transaction: Transaction<'static, Driver>,
}
impl TestTransaction {
pub fn connection(&mut self) -> &mut Connection {
&mut self.transaction
}
pub async fn rollback(self) -> Result<(), TestDatabaseError> {
self.transaction
.rollback()
.await
.map_err(|error| TestDatabaseError::Query(error.to_string()))
}
}
pub async fn assert_database_has(
connection: &mut Connection,
table: &str,
conditions: &[(&str, &str)],
) {
assert!(
!conditions.is_empty(),
"assert_database_has needs at least one condition; `any row at all` is not an assertion"
);
let table = checked_table(table);
let matched = count(&mut *connection, &table, conditions)
.await
.unwrap_or_else(|error| panic!("assert_database_has could not query `{table}`: {error}"));
if matched > 0 {
return;
}
panic!(
"no row in `{table}` matches {}\n{}",
describe(conditions),
diagnose(connection, &table, conditions).await
);
}
fn checked_identifier(name: &str) -> &str {
let valid = !name.is_empty()
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
assert!(
valid,
"`{name}` is not a plain SQL identifier; assert_database_has interpolates table and column names, so it accepts only [A-Za-z_][A-Za-z0-9_]*"
);
name
}
fn checked_table(table: &str) -> String {
match table.split_once('.') {
Some((schema, name)) => {
format!(
"{}.{}",
checked_identifier(schema),
checked_identifier(name)
)
}
None => checked_identifier(table).to_owned(),
}
}
fn describe(conditions: &[(&str, &str)]) -> String {
let rendered: Vec<String> = conditions
.iter()
.map(|(column, value)| format!("{column} = `{value}`"))
.collect();
rendered.join(" and ")
}
async fn diagnose(connection: &mut Connection, table: &str, conditions: &[(&str, &str)]) -> String {
let mut lines = Vec::with_capacity(conditions.len() + 1);
match count(&mut *connection, table, &[]).await {
Ok(total) => lines.push(format!(" `{table}` holds {total} rows")),
Err(error) => lines.push(format!(" `{table}` could not be counted: {error}")),
}
for condition in conditions {
let (column, value) = *condition;
match count(&mut *connection, table, std::slice::from_ref(condition)).await {
Ok(matched) => {
lines.push(format!(" {column} = `{value}` matches {matched} rows"));
}
Err(error) => lines.push(format!(
" {column} = `{value}` could not be counted: {error}"
)),
}
}
lines.join("\n")
}
async fn count(
connection: &mut Connection,
table: &str,
conditions: &[(&str, &str)],
) -> Result<i64, sqlx::Error> {
let mut sql = format!("SELECT count(*) FROM {table}");
if !conditions.is_empty() {
let clauses: Vec<String> = conditions
.iter()
.enumerate()
.map(|(index, (column, _))| {
format!(
"{} = {}",
as_text(checked_identifier(column)),
placeholder(index + 1)
)
})
.collect();
sql.push_str(" WHERE ");
sql.push_str(&clauses.join(" AND "));
}
let mut query = sqlx::query_scalar::<Driver, i64>(sqlx::AssertSqlSafe(sql));
for (_, value) in conditions {
query = query.bind(*value);
}
query.fetch_one(connection).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_test_database_name_is_accepted() {
assert!(check_url("postgres://user:pw@localhost:5432/arcature_test_app").is_ok());
}
#[test]
fn a_production_looking_database_name_is_refused() {
let error = check_url("postgres://user:pw@localhost/app_production")
.expect_err("a non-test database must be refused");
assert!(matches!(
error,
TestDatabaseError::UnsafeDatabaseName { .. }
));
assert!(error.to_string().contains("app_production"));
}
#[test]
fn a_url_without_a_database_name_is_refused() {
let error =
check_url("postgres://user:pw@localhost:5432").expect_err("no database name is fatal");
assert!(matches!(error, TestDatabaseError::NoDatabaseName { .. }));
}
#[test]
fn query_parameters_are_not_part_of_the_database_name() {
assert_eq!(
database_name("postgres://localhost/arcature_test_app?sslmode=require"),
Some("arcature_test_app")
);
}
#[test]
fn a_failure_message_never_carries_the_password() {
let error =
check_url("postgres://user:hunter2@localhost").expect_err("no database name is fatal");
let message = error.to_string();
assert!(!message.contains("hunter2"), "message leaked: {message}");
assert!(message.contains("***"), "message: {message}");
}
#[test]
fn an_identifier_with_a_quote_is_refused() {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
checked_identifier("users\"; drop table users--")
}));
assert!(
outcome.is_err(),
"a hostile identifier must not be accepted"
);
}
#[test]
fn a_schema_qualified_table_keeps_both_parts() {
assert_eq!(checked_table("public.users"), "public.users");
}
#[test]
fn a_mysql_url_is_read_the_same_way_as_a_postgres_one() {
assert_eq!(
database_name("mysql://user:pw@localhost:3306/arcature_test_app"),
Some("arcature_test_app")
);
}
#[test]
fn a_sqlite_file_is_named_by_its_stem() {
for url in [
"sqlite://arcature_test_app.db",
"sqlite:arcature_test_app.db",
"sqlite://./tmp/arcature_test_app.db",
"sqlite://arcature_test_app.db?mode=rwc",
] {
assert_eq!(database_name(url), Some("arcature_test_app"), "{url}");
assert!(check_url(url).is_ok(), "{url}");
}
}
#[test]
fn a_sqlite_file_that_is_not_a_test_database_is_refused() {
let error = check_url("sqlite://./data/production.db")
.expect_err("a non-test SQLite file must be refused");
assert!(matches!(
error,
TestDatabaseError::UnsafeDatabaseName { .. }
));
assert!(error.to_string().contains("production"));
}
#[test]
fn an_in_memory_sqlite_database_needs_no_name() {
for url in ["sqlite::memory:", "sqlite://:memory:", "sqlite://"] {
assert!(check_url(url).is_ok(), "{url}");
}
}
}