db: add failing tests for connection URL, migration splitting, backup/restore
Tests define the db module API: - connection_url() builds the sqlx URL with percent-encoded credentials; with/without the database name - split_statements() splits SQL on semicolons outside strings and line comments (the embedded migration is applied statement by statement, since DDL cannot run in a rollbackable transaction) - the embedded migration is Documentation/schema.sql (version 1), carrying the seven domain tables only; schema_migrations is created by the application - backup_command() shells out to mariadb-dump (mysqldump fallback) with --single-transaction, password via MYSQL_PWD, never argv - restore_command() pipes the dump (which carries CREATE DATABASE / USE) into the mariadb client - backup_filename() is timestamped per cli.md
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
//! Database connection, embedded migrations, backup and restore.
|
||||||
|
//!
|
||||||
|
//! The v1 domain schema (Documentation/schema.sql) is embedded as
|
||||||
|
//! migration version 1 and applied by `db setup` behind the
|
||||||
|
//! `schema_migrations` table (readme.md). `db reset` drops the whole
|
||||||
|
//! database and re-runs setup. Backups and restores shell out to the
|
||||||
|
//! `mariadb-dump` / `mariadb` client tools; the password is passed via
|
||||||
|
//! `MYSQL_PWD`, never on the command line.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{BackupConfig, Config, DatabaseConfig, StateConfig, WebConfig};
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
fn test_config() -> Config {
|
||||||
|
Config {
|
||||||
|
database: DatabaseConfig {
|
||||||
|
host: "db.example".into(),
|
||||||
|
port: 3307,
|
||||||
|
user: "u".into(),
|
||||||
|
password: "pw".into(),
|
||||||
|
name: "rpn".into(),
|
||||||
|
},
|
||||||
|
web: WebConfig { port: 8080, fqdn: String::new() },
|
||||||
|
state: StateConfig { dir: "/tmp/rpn".into() },
|
||||||
|
backup: BackupConfig { dir: "/tmp/rpn/backups".into() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_url_without_database() {
|
||||||
|
let cfg = test_config();
|
||||||
|
assert_eq!(connection_url(&cfg, false), "mysql://u:***@db.example:3307");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_url_with_database() {
|
||||||
|
let cfg = test_config();
|
||||||
|
assert_eq!(connection_url(&cfg, true), "mysql://u:***@db.example:3307/rpn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_url_percent_encodes_credentials() {
|
||||||
|
let mut cfg = test_config();
|
||||||
|
cfg.database.user = "us@er".into();
|
||||||
|
cfg.database.password = "p:ass/word".into();
|
||||||
|
let url = connection_url(&cfg, true);
|
||||||
|
assert!(url.starts_with("mysql://us%40er:p%3Aass%2Fword@"), "{url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_statements_splits_on_semicolons() {
|
||||||
|
let sql = "SET x = 0; CREATE TABLE a (v INT); CREATE TABLE b (v INT);";
|
||||||
|
let stmts = split_statements(sql);
|
||||||
|
assert_eq!(stmts.len(), 3);
|
||||||
|
assert!(stmts[0].contains("SET"));
|
||||||
|
assert!(stmts[1].contains("a"));
|
||||||
|
assert!(stmts[2].contains("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_statements_ignores_semicolons_in_line_comments() {
|
||||||
|
let sql = "-- a; comment\nSELECT 1;\n-- another; one\nSELECT 2;";
|
||||||
|
let stmts = split_statements(sql);
|
||||||
|
assert_eq!(stmts.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_statements_ignores_semicolons_in_strings() {
|
||||||
|
let sql = "INSERT INTO t (v) VALUES ('a;b'); INSERT INTO t (v) VALUES (1);";
|
||||||
|
let stmts = split_statements(sql);
|
||||||
|
assert_eq!(stmts.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn split_statements_drops_blank_fragments() {
|
||||||
|
let sql = "SELECT 1; \n; SELECT 2;";
|
||||||
|
let stmts = split_statements(sql);
|
||||||
|
assert_eq!(stmts.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_migration_is_schema_v1() {
|
||||||
|
for table in ["files", "customers", "batches", "cards", "transactions", "invoices", "invoice_items"] {
|
||||||
|
assert!(MIGRATION_V1.contains(&format!("CREATE TABLE {table}")), "{table}");
|
||||||
|
}
|
||||||
|
// the migrations bookkeeping table is created by the app, not the file
|
||||||
|
assert!(!MIGRATION_V1.contains("CREATE TABLE schema_migrations"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_command_uses_dump_client() {
|
||||||
|
let (program, args, use_pwd) = backup_command(&test_config());
|
||||||
|
assert!(program == "mariadb-dump" || program == "mysqldump", "{program}");
|
||||||
|
assert!(args.iter().any(|a| a == "--host=db.example"), "{args:?}");
|
||||||
|
assert!(args.iter().any(|a| a == "--port=3307"), "{args:?}");
|
||||||
|
assert!(args.iter().any(|a| a == "--user=u"), "{args:?}");
|
||||||
|
assert!(!args.iter().any(|a| a.contains("pw")), "password on argv: {args:?}");
|
||||||
|
assert!(use_pwd, "MYSQL_PWD must be set for the dump client");
|
||||||
|
assert!(args.iter().any(|a| a == "--single-transaction"), "{args:?}");
|
||||||
|
assert!(args.iter().any(|a| a == "--databases"), "{args:?}");
|
||||||
|
assert!(args.iter().any(|a| a == "rpn"), "{args:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_command_pipes_dump_via_stdin() {
|
||||||
|
// The dump carries its own CREATE DATABASE / USE statements, so the
|
||||||
|
// restore connects without a default database; the file itself is
|
||||||
|
// piped through stdin, not passed as an argument (portable across
|
||||||
|
// mariadb / mysql client versions).
|
||||||
|
let (program, args, use_pwd) = restore_command(&test_config());
|
||||||
|
assert!(program == "mariadb" || program == "mysql", "{program}");
|
||||||
|
assert!(args.iter().any(|a| a == "--host=db.example"), "{args:?}");
|
||||||
|
assert!(args.iter().any(|a| a == "rpn"), "{args:?}");
|
||||||
|
assert!(!args.iter().any(|a| a.contains(".sql")), "file must not be on argv: {args:?}");
|
||||||
|
assert!(use_pwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_filename_is_timestamped() {
|
||||||
|
let ts = chrono::Utc.with_ymd_and_hms(2026, 9, 8, 14, 57, 3).unwrap();
|
||||||
|
assert_eq!(backup_filename("rpn", ts), "rpn-2026-09-08_145703.sql");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
//! Domain logic for RustyRPN: ingest, invoices, Fortnox, car registry.
|
//! Domain logic for RustyRPN: ingest, invoices, Fortnox, car registry.
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
|||||||
Reference in New Issue
Block a user