diff --git a/Application/src/core/src/db.rs b/Application/src/core/src/db.rs new file mode 100644 index 0000000..9e5582a --- /dev/null +++ b/Application/src/core/src/db.rs @@ -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"); + } +} diff --git a/Application/src/core/src/lib.rs b/Application/src/core/src/lib.rs index 117feac..717ce11 100644 --- a/Application/src/core/src/lib.rs +++ b/Application/src/core/src/lib.rs @@ -1,3 +1,4 @@ //! Domain logic for RustyRPN: ingest, invoices, Fortnox, car registry. pub mod config; +pub mod db;