use std::process::Command;
use crate::migrate;
pub fn run(reset: bool, cargo_args: &[String]) -> ! {
eprintln!("\u{1F342} autumn test\n");
let Some(base_url) = migrate::resolve_primary_url(Some("test")) else {
eprintln!("\u{2717} No test database URL found.");
eprintln!(
" Set database.primary_url (or database.url) in autumn.toml (optionally under \
[profile.test.database]), or set AUTUMN_DATABASE__PRIMARY_URL / \
AUTUMN_DATABASE__URL / DATABASE_URL."
);
std::process::exit(1);
};
let (test_url, db_name) = match derive_test_url(&base_url) {
Ok(derived) => derived,
Err(e) => {
eprintln!("\u{2717} {e}");
std::process::exit(1);
}
};
eprintln!(" Test database: {db_name:?}");
eprintln!(" Profile: test (AUTUMN_ENV=test)\n");
if reset {
run_step("drop", &["db", "drop", "--force"], &test_url);
}
run_step("create", &["db", "create"], &test_url);
run_step("migrate", &["migrate"], &test_url);
eprintln!("\u{2500}\u{2500} cargo test \u{2500}\u{2500}");
let status = Command::new("cargo")
.args(cargo_test_args(cargo_args))
.env("AUTUMN_ENV", "test")
.env("DATABASE_URL", &test_url)
.env("AUTUMN_DATABASE__PRIMARY_URL", &test_url)
.status();
match status {
Ok(status) => {
std::process::exit(status.code().unwrap_or(1));
}
Err(e) => {
eprintln!("\u{2717} Failed to run `cargo test`: {e}");
std::process::exit(1);
}
}
}
fn cargo_test_args(user_args: &[String]) -> Vec<String> {
let mut args = vec!["test".to_owned()];
if !user_args.is_empty() {
args.push("--".to_owned());
args.extend(user_args.iter().cloned());
}
args
}
fn run_step(name: &str, args: &[&str], test_url: &str) {
eprintln!("\u{2500}\u{2500} {name} \u{2500}\u{2500}");
let exe = std::env::current_exe().unwrap_or_else(|e| {
eprintln!("\u{2717} Could not locate the autumn executable: {e}");
std::process::exit(1);
});
let status = Command::new(exe)
.args(args)
.env("AUTUMN_ENV", "test")
.env("AUTUMN_DATABASE__PRIMARY_URL", test_url)
.env("DATABASE_URL", test_url)
.status();
match status {
Ok(status) if status.success() => {}
Ok(status) => {
eprintln!(
"\u{2717} `autumn {}` failed ({}).",
args.join(" "),
status.code().map_or_else(
|| "terminated by signal".to_owned(),
|c| format!("exit {c}")
),
);
std::process::exit(1);
}
Err(e) => {
eprintln!("\u{2717} Failed to spawn `autumn {}`: {e}", args.join(" "));
std::process::exit(1);
}
}
}
fn is_test_db_name(name: &str) -> bool {
name == "test" || name.ends_with("_test")
}
fn ensure_test_db_name(name: &str) -> Result<(), String> {
if is_test_db_name(name) {
Ok(())
} else {
Err(format!(
"Refusing to run tests against the non-test database {name:?}.\n \
`autumn test` only operates on a database named `test` or ending in `_test`."
))
}
}
fn derive_test_url(base_url: &str) -> Result<(String, String), String> {
let mut parsed = url::Url::parse(base_url)
.map_err(|_| "The resolved test database URL could not be parsed.".to_owned())?;
let name = parsed
.path_segments()
.and_then(|mut segments| segments.next())
.map(decode_percent)
.filter(|name| !name.is_empty())
.ok_or_else(|| {
"The resolved test database URL does not name a database (nothing to make test-safe)."
.to_owned()
})?;
let test_name = if is_test_db_name(&name) {
name
} else {
format!("{name}_test")
};
ensure_test_db_name(&test_name)?;
parsed.set_path(&format!("/{test_name}"));
Ok((parsed.to_string(), test_name))
}
fn decode_percent(segment: &str) -> String {
let bytes = segment.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo)
&& let Ok(byte) = u8::try_from(hi * 16 + lo)
{
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cargo_test_args_empty_input_is_plain_test() {
assert_eq!(cargo_test_args(&[]), vec!["test".to_owned()]);
}
#[test]
fn cargo_test_args_forwards_harness_args_after_separator() {
let user = vec!["--nocapture".to_owned(), "some_test".to_owned()];
assert_eq!(
cargo_test_args(&user),
vec!["test", "--", "--nocapture", "some_test"]
);
}
#[test]
fn cargo_test_args_forwards_single_filter_after_separator() {
let user = vec!["my_filter".to_owned()];
assert_eq!(cargo_test_args(&user), vec!["test", "--", "my_filter"]);
}
#[test]
fn decode_percent_matches_db_decode_percent() {
for segment in [
"my_app",
"my%20app",
"already_test",
"%6d%79_app",
"%5F_leading_underscore",
"MIXED%5fCase%2Fname",
"trailing%2",
"trailing%",
"100%25done",
"",
"%zz-not-hex",
] {
assert_eq!(
decode_percent(segment),
crate::db::decode_percent(segment),
"decode_percent and db::decode_percent disagree on {segment:?}"
);
}
}
#[test]
fn is_test_db_name_accepts_test_and_underscore_test() {
assert!(is_test_db_name("test"));
assert!(is_test_db_name("myapp_test"));
assert!(is_test_db_name("a_b_c_test"));
}
#[test]
fn is_test_db_name_rejects_non_test_names() {
assert!(!is_test_db_name("myapp"));
assert!(!is_test_db_name("production"));
assert!(!is_test_db_name("testing")); assert!(!is_test_db_name("test_db"));
}
#[test]
fn ensure_test_db_name_refuses_non_test_name() {
let err = ensure_test_db_name("production").unwrap_err();
assert!(err.contains("Refusing"), "message: {err}");
assert!(err.contains("production"), "message: {err}");
assert!(err.contains("_test"), "message: {err}");
}
#[test]
fn ensure_test_db_name_allows_test_shaped_names() {
assert!(ensure_test_db_name("test").is_ok());
assert!(ensure_test_db_name("myapp_test").is_ok());
}
#[test]
fn derive_appends_test_suffix_to_bare_name() {
let (url, name) = derive_test_url("postgres://user:pw@db.example.com:6543/myapp").unwrap();
assert_eq!(name, "myapp_test");
assert!(url.ends_with("/myapp_test"), "url: {url}");
assert!(url.contains("db.example.com:6543"), "url: {url}");
}
#[test]
fn derive_keeps_already_test_shaped_names() {
let (url, name) = derive_test_url("postgres://localhost/myapp_test").unwrap();
assert_eq!(name, "myapp_test");
assert!(url.ends_with("/myapp_test"), "url: {url}");
let (url, name) = derive_test_url("postgres://localhost/test").unwrap();
assert_eq!(name, "test");
assert!(url.ends_with("/test"), "url: {url}");
}
#[test]
fn derive_decodes_percent_encoded_name_before_suffixing() {
let (_url, name) = derive_test_url("postgres://localhost/my%5Fapp").unwrap();
assert_eq!(name, "my_app_test");
}
#[test]
fn derive_refuses_url_without_database_name() {
let err = derive_test_url("postgres://localhost/").unwrap_err();
assert!(err.contains("does not name a database"), "message: {err}");
}
#[test]
fn derive_refuses_unparsable_url() {
let err = derive_test_url("not a url").unwrap_err();
assert!(err.contains("could not be parsed"), "message: {err}");
}
}