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:
2026-09-08 21:38:48 +02:00
parent 9452962c1c
commit 676e55eeb6
2 changed files with 126 additions and 0 deletions
+125
View File
@@ -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
View File
@@ -1,3 +1,4 @@
//! Domain logic for RustyRPN: ingest, invoices, Fortnox, car registry.
pub mod config;
pub mod db;