//! The migrations are the contract texts byte for byte (`0001_init.sql` = `server-v1.sql`, SPEC §14.1; //! `0002_categories_v2.sql` = `contracts/schema/migrations/0002_categories_v2.sql`, §14.1.1), connections carry the //! §8.9 pragmas, a fresh database holds the fourteen defaults of AC-7.2, and a v1 database in use upgrades to schema //! 2 without losing or changing a row. use std::path::PathBuf; use axum::http::{Method, StatusCode}; use kvitt_server::db; use sqlx::{AssertSqlSafe, SqlitePool}; use crate::support::{ADMIN_ID, MEMBER_ID, TestApp, admin_token, authed}; fn manifest(path: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path) } /// The fourteen default categories of AC-7.2 and §14.1.1: id suffix, key, icon, colour, sort order, created_at and /// the `server_version` on a fresh database (the ten v1 seeds 1 to 10, then migration 0002's rows 11 to 14). const DEFAULTS: [(&str, &str, &str, &str, i64, &str, i64); 14] = [ ("001", "groceries", "shopping_cart", "green", 10, V1_AT, 1), ("002", "restaurants", "restaurant", "orange", 20, V1_AT, 2), ("011", "housing", "key", "indigo", 25, V2_AT, 11), ("003", "home", "home", "brown", 30, V1_AT, 3), ("004", "utilities", "bolt", "yellow", 40, V1_AT, 4), ("012", "insurance", "shield", "slate", 45, V2_AT, 12), ("005", "transport", "directions_car", "blue", 50, V1_AT, 5), ("006", "travel", "flight", "teal", 60, V1_AT, 6), ("007", "entertainment", "movie", "purple", 70, V1_AT, 7), ("008", "health", "medical_services", "red", 80, V1_AT, 8), ("013", "children", "child_care", "lime", 85, V2_AT, 13), ("009", "shopping", "shopping_bag", "pink", 90, V1_AT, 9), ("014", "fees", "account_balance", "amber", 95, V2_AT, 14), ("00a", "other", "category", "grey", 100, V1_AT, 10), ]; const V1_AT: &str = "2026-10-17T00:00:00.000Z"; const V2_AT: &str = "2026-10-18T00:00:00.000Z"; const ID_PREFIX: &str = "01920000-0000-7000-8000-000000000"; const NEW_IDS: [&str; 4] = [ "01920000-0000-7000-8000-000000000011", "01920000-0000-7000-8000-000000000012", "01920000-0000-7000-8000-000000000013", "01920000-0000-7000-8000-000000000014", ]; #[test] fn migration_0001_is_byte_identical_to_contract_schema() { let migration = std::fs::read(manifest("migrations/0001_init.sql")).unwrap(); let contract = std::fs::read(manifest("../../../contracts/schema/server-v1.sql")).unwrap(); assert!( migration == contract, "migrations/0001_init.sql differs from contracts/schema/server-v1.sql" ); } #[test] fn migration_0002_is_byte_identical_to_contract_migration() { let migration = std::fs::read(manifest("migrations/0002_categories_v2.sql")).unwrap(); let contract = std::fs::read(manifest( "../../../contracts/schema/migrations/0002_categories_v2.sql", )) .unwrap(); assert!( migration == contract, "migrations/0002_categories_v2.sql differs from contracts/schema/migrations/0002_categories_v2.sql" ); } #[test] fn the_embedded_migrator_holds_exactly_those_migrations() { let v1 = std::fs::read_to_string(manifest("../../../contracts/schema/server-v1.sql")).unwrap(); let v2 = std::fs::read_to_string(manifest( "../../../contracts/schema/migrations/0002_categories_v2.sql", )) .unwrap(); let migrations: Vec<_> = db::MIGRATOR.iter().collect(); assert_eq!(migrations.len(), 2); assert_eq!(migrations[0].version, 1); assert_eq!(migrations[0].sql.as_ref(), v1.as_str()); assert_eq!(migrations[1].version, 2); assert_eq!(migrations[1].sql.as_ref(), v2.as_str()); // sqlx wraps each migration in one transaction unless it opts out (SPEC §14.1.1 relies on it). assert!(migrations.iter().all(|m| !m.no_tx)); assert_eq!(db::SCHEMA_VERSION, 2); } /// Every row of `table` in a stable order, each rendered with SQLite's `quote()` per column (type-exact). async fn dump(pool: &SqlitePool, table: &str, filter: &str) -> Vec { let columns: Vec = sqlx::query_scalar("SELECT name FROM pragma_table_info(?) ORDER BY cid") .bind(table) .fetch_all(pool) .await .unwrap(); assert!(!columns.is_empty(), "{table} has no columns"); let row = columns .iter() .map(|c| format!("quote({c})")) .collect::>() .join(" || '|' || "); let order = columns.join(", "); sqlx::query_scalar(AssertSqlSafe(format!( "SELECT {row} FROM {table} {filter} ORDER BY {order}" ))) .fetch_all(pool) .await .unwrap() } async fn user_tables(pool: &SqlitePool) -> Vec { sqlx::query_scalar( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \ AND name <> '_sqlx_migrations' ORDER BY name", ) .fetch_all(pool) .await .unwrap() } async fn scalar_i64(pool: &SqlitePool, sql: &'static str) -> i64 { sqlx::query_scalar(sql).fetch_one(pool).await.unwrap() } async fn foreign_key_violations(pool: &SqlitePool) -> usize { sqlx::query("PRAGMA foreign_key_check") .fetch_all(pool) .await .unwrap() .len() } type CategoryRow = ( String, Option, Option, String, String, i64, String, String, Option, Option, i64, ); async fn categories(pool: &SqlitePool, filter: &str) -> Vec { sqlx::query_as(AssertSqlSafe(format!( "SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, \ server_version FROM categories {filter} ORDER BY sort_order, id" ))) .fetch_all(pool) .await .unwrap() } #[tokio::test] async fn a_fresh_database_holds_exactly_the_fourteen_defaults() { let app = TestApp::empty().await; let pool = &app.state.db; let rows = categories(pool, "").await; let expected: Vec = DEFAULTS .iter() .map(|&(suffix, key, icon, color, sort, at, version)| { ( format!("{ID_PREFIX}{suffix}"), Some(key.to_owned()), None, icon.to_owned(), color.to_owned(), sort, at.to_owned(), at.to_owned(), None, None, version, ) }) .collect(); assert_eq!(rows, expected); assert_eq!( scalar_i64(pool, "SELECT value FROM sync_sequence WHERE id = 1").await, 14 ); assert_eq!(foreign_key_violations(pool).await, 0); // The rebuilt table keeps the v1 constraints and accepts exactly the fourteen keys. let insert = |key: &'static str, version: i64| { sqlx::query( "INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, \ server_version) VALUES (?, ?, NULL, 'home', 'grey', 1, ?, ?, ?)", ) .bind(format!("0192f000-0000-7000-8000-{version:012}")) .bind(key) .bind(V2_AT) .bind(V2_AT) .bind(version) .execute(pool) }; assert!(insert("pets", 100).await.is_err(), "unknown key rejected"); assert!( insert("fees", 101).await.is_err(), "default_key stays unique" ); let nameless_custom = sqlx::query( "INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, \ server_version) VALUES ('0192f000-0000-7000-8000-000000000102', NULL, NULL, 'home', 'grey', 1, ?, ?, 102)", ) .bind(V2_AT) .bind(V2_AT) .execute(pool) .await; assert!(nameless_custom.is_err(), "a custom category needs a name"); } /// A v1 database in use: the household, a custom category, a renamed and an archived default, an account, a /// recurring template and expenses referencing default and custom categories. Rows take versions 15 to 23 and the /// sequence ends at 23. async fn fill_v1(app: &TestApp) { let pool = &app.state.db; app.seed_household().await; let statements = [ "INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, \ archived_at, deleted_at, server_version) VALUES ('0192c000-0000-7000-8000-000000000001', NULL, 'Hemförsäkring', \ 'pets', 'lime', 110, '2026-10-17T09:00:00.000Z', '2026-10-17T09:00:00.000Z', NULL, NULL, 17)", "UPDATE categories SET name = 'Mat', updated_at = '2026-10-17T09:01:00.000Z', server_version = 18 \ WHERE default_key = 'groceries'", "UPDATE categories SET archived_at = '2026-10-17T09:02:00.000Z', updated_at = '2026-10-17T09:02:00.000Z', \ server_version = 19 WHERE default_key = 'other'", "INSERT INTO accounts (id, name, name_key, created_by_user_id, created_at, updated_at, server_version) \ VALUES ('0192b000-0000-7000-8000-000000000001', 'Vardag', 'vardag', '0192a000-0000-7000-8000-000000000001', \ '2026-10-17T09:03:00.000Z', '2026-10-17T09:03:00.000Z', 20)", "INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES \ ('0192b000-0000-7000-8000-000000000001', '0192a000-0000-7000-8000-000000000001', 5000), \ ('0192b000-0000-7000-8000-000000000001', '0192a000-0000-7000-8000-000000000002', 5000)", "INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, notes, payer_user_id, \ split_mode, day_of_month, start_period, end_period, paused, last_generated_period, created_by_user_id, \ updated_by_user_id, created_at, updated_at, deleted_at, server_version) VALUES \ ('0192d000-0000-7000-8000-000000000001', '0192b000-0000-7000-8000-000000000001', \ '01920000-0000-7000-8000-000000000003', 1250000, 'Hyra', '', '0192a000-0000-7000-8000-000000000001', \ 'default', 25, '2026-10', NULL, 0, '2026-10', '0192a000-0000-7000-8000-000000000001', \ '0192a000-0000-7000-8000-000000000001', '2026-10-17T09:04:00.000Z', '2026-10-17T09:04:00.000Z', NULL, 21)", "INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \ payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \ updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \ server_version) VALUES ('0192e000-0000-7000-8000-000000000001', '0192b000-0000-7000-8000-000000000001', \ '01920000-0000-7000-8000-000000000003', 1250000, 'Hyra', '', '2026-10-25', \ '0192a000-0000-7000-8000-000000000001', 'default', '0192d000-0000-7000-8000-000000000001', '2026-10', NULL, \ '0192a000-0000-7000-8000-000000000001', '0192a000-0000-7000-8000-000000000001', NULL, \ '2026-10-17T09:05:00.000Z', '2026-10-17T09:05:00.000Z', NULL, '2026-10-17T09:05:00.000Z', 22)", "INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \ payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \ updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \ server_version) VALUES ('0192e000-0000-7000-8000-000000000002', '0192b000-0000-7000-8000-000000000001', \ '0192c000-0000-7000-8000-000000000001', 34900, 'Försäkring', 'Kvartal', '2026-10-16', \ '0192a000-0000-7000-8000-000000000002', 'full', NULL, NULL, NULL, \ '0192a000-0000-7000-8000-000000000002', '0192a000-0000-7000-8000-000000000002', NULL, \ '2026-10-17T09:06:00.000Z', '2026-10-17T09:06:00.000Z', NULL, '2026-10-17T09:06:00.000Z', 23)", "INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES \ ('0192e000-0000-7000-8000-000000000001', '0192a000-0000-7000-8000-000000000001', 625000, 5000), \ ('0192e000-0000-7000-8000-000000000001', '0192a000-0000-7000-8000-000000000002', 625000, 5000), \ ('0192e000-0000-7000-8000-000000000002', '0192a000-0000-7000-8000-000000000001', 0, NULL), \ ('0192e000-0000-7000-8000-000000000002', '0192a000-0000-7000-8000-000000000002', 34900, NULL)", "UPDATE sync_sequence SET value = 23 WHERE id = 1", ]; for sql in statements { sqlx::query(sql) .execute(pool) .await .unwrap_or_else(|e| panic!("{e}: {sql}")); } } #[tokio::test] async fn a_v1_database_in_use_upgrades_to_schema_2_keeping_every_row() { let app = TestApp::at_v1().await; let pool = &app.state.db; assert_eq!( sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'schema_version'") .fetch_one(pool) .await .unwrap(), "1" ); assert_eq!( scalar_i64(pool, "SELECT COUNT(*) FROM categories").await, 10 ); fill_v1(&app).await; let old_sequence = scalar_i64(pool, "SELECT value FROM sync_sequence WHERE id = 1").await; assert_eq!(old_sequence, 23); let epoch: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'") .fetch_one(pool) .await .unwrap(); let tables = user_tables(pool).await; let mut before = Vec::new(); for table in &tables { if table != "meta" && table != "sync_sequence" { before.push((table.clone(), dump(pool, table, "").await)); } } db::migrate(pool).await.unwrap(); // The connection that ran the migration had foreign keys on (SPEC §8.9), and nothing dangles afterwards. let mut conn = pool.acquire().await.unwrap(); let fk: i64 = sqlx::query_scalar("PRAGMA foreign_keys") .fetch_one(&mut *conn) .await .unwrap(); assert_eq!(fk, 1); drop(conn); assert_eq!(foreign_key_violations(pool).await, 0); assert_eq!( user_tables(pool).await, tables, "no table added or left behind" ); assert_eq!( sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'schema_version'") .fetch_one(pool) .await .unwrap(), "2" ); assert_eq!( sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'sync_epoch'") .fetch_one(pool) .await .unwrap(), epoch ); assert_eq!( scalar_i64(pool, "SELECT value FROM sync_sequence WHERE id = 1").await, old_sequence + 4 ); // Every earlier row, categories included, is unchanged. let old_only = format!("WHERE id NOT IN ('{}')", NEW_IDS.join("', '")); for (table, rows) in &before { let filter = if table == "categories" { old_only.as_str() } else { "" }; assert_eq!(&dump(pool, table, filter).await, rows, "{table} changed"); } // The four new rows, versions after the old sequence value. let new_rows = categories(pool, &format!("WHERE id IN ('{}')", NEW_IDS.join("', '"))).await; let expected: Vec = DEFAULTS .iter() .filter(|d| d.5 == V2_AT) .zip(1..) .map(|(&(suffix, key, icon, color, sort, at, _), n)| { ( format!("{ID_PREFIX}{suffix}"), Some(key.to_owned()), None, icon.to_owned(), color.to_owned(), sort, at.to_owned(), at.to_owned(), None, None, old_sequence + n, ) }) .collect(); assert_eq!(new_rows, expected); assert_eq!( scalar_i64(pool, "SELECT COUNT(*) FROM categories").await, 15 ); // A phone at the old cursor pulls exactly the four new categories. let reply = app .send(authed( Method::GET, &format!("/sync?since={old_sequence}"), &admin_token(), )) .await; assert_eq!(reply.status, StatusCode::OK); let page = &reply.json; assert_eq!(page["since"], old_sequence); assert_eq!(page["next_since"], old_sequence + 4); assert_eq!(page["has_more"], false); let ids: Vec<&str> = page["categories"] .as_array() .unwrap() .iter() .map(|c| c["id"].as_str().unwrap()) .collect(); assert_eq!(ids, NEW_IDS); for array in [ "users", "accounts", "recurring_templates", "expenses", "settlements", "audit_entries", ] { assert_eq!(page[array], serde_json::json!([]), "{array}"); } assert_eq!(page["categories"][1]["default_key"], "insurance"); assert_eq!(page["categories"][1]["color"], "slate"); // The users the seeding referenced are still the household. let users: Vec = sqlx::query_scalar("SELECT id FROM users ORDER BY id") .fetch_all(pool) .await .unwrap(); assert_eq!(users, [ADMIN_ID, MEMBER_ID]); // A second run is a no-op. db::migrate(pool).await.unwrap(); assert_eq!( scalar_i64(pool, "SELECT COUNT(*) FROM categories").await, 15 ); } #[tokio::test] async fn migrated_database_has_schema_version_seed_rows_and_pragmas() { let app = TestApp::empty().await; let pool = &app.state.db; let version: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'schema_version'") .fetch_one(pool) .await .unwrap(); assert_eq!(version, db::SCHEMA_VERSION.to_string()); let epoch: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'") .fetch_one(pool) .await .unwrap(); assert_eq!(epoch.len(), 32); let categories: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM categories") .fetch_one(pool) .await .unwrap(); assert_eq!(categories, 14); let mut conn = pool.acquire().await.unwrap(); let journal: String = sqlx::query_scalar("PRAGMA journal_mode") .fetch_one(&mut *conn) .await .unwrap(); assert_eq!(journal, "wal"); let fk: i64 = sqlx::query_scalar("PRAGMA foreign_keys") .fetch_one(&mut *conn) .await .unwrap(); assert_eq!(fk, 1); let sync: i64 = sqlx::query_scalar("PRAGMA synchronous") .fetch_one(&mut *conn) .await .unwrap(); assert_eq!(sync, 1, "synchronous = NORMAL"); let busy: i64 = sqlx::query_scalar("PRAGMA busy_timeout") .fetch_one(&mut *conn) .await .unwrap(); assert_eq!(busy, 5000); } #[tokio::test] async fn migrating_twice_is_a_no_op() { let app = TestApp::empty().await; db::migrate(&app.state.db).await.unwrap(); } #[tokio::test] async fn server_version_sequence_starts_after_the_seed_and_increments() { let app = TestApp::empty().await; let mut tx = db::begin_immediate(&app.state.db).await.unwrap(); assert_eq!(db::next_server_version(&mut tx).await.unwrap(), 15); assert_eq!(db::next_server_version(&mut tx).await.unwrap(), 16); tx.commit().await.unwrap(); let mut conn = app.state.db.acquire().await.unwrap(); assert_eq!( db::sequence::current_server_version(&mut conn) .await .unwrap(), 16 ); }