8c607153c0
Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite; X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3); OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
586 lines
22 KiB
Rust
586 lines
22 KiB
Rust
//! The migrations are the contract texts byte for byte (`0001_init.sql` = `server-v1.sql`, SPEC §14.1;
|
|
//! `0002_categories_v2.sql` and `0003_device_version_name.sql` = their copies in `contracts/schema/migrations/`,
|
|
//! §14.1.1), connections carry the §8.9 pragmas, a fresh database holds the fourteen defaults of AC-7.2, a v1
|
|
//! database in use upgrades to schema 2 and a v2 database to schema 3 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 migration_0003_is_byte_identical_to_contract_migration() {
|
|
let migration = std::fs::read(manifest("migrations/0003_device_version_name.sql")).unwrap();
|
|
let contract = std::fs::read(manifest(
|
|
"../../../contracts/schema/migrations/0003_device_version_name.sql",
|
|
))
|
|
.unwrap();
|
|
assert!(
|
|
migration == contract,
|
|
"migrations/0003_device_version_name.sql differs from contracts/schema/migrations/0003_device_version_name.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 v3 = std::fs::read_to_string(manifest(
|
|
"../../../contracts/schema/migrations/0003_device_version_name.sql",
|
|
))
|
|
.unwrap();
|
|
let migrations: Vec<_> = db::MIGRATOR.iter().collect();
|
|
assert_eq!(migrations.len(), 3);
|
|
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());
|
|
assert_eq!(migrations[2].version, 3);
|
|
assert_eq!(migrations[2].sql.as_ref(), v3.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, 3);
|
|
}
|
|
|
|
/// 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<String> {
|
|
let columns: Vec<String> =
|
|
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::<Vec<_>>()
|
|
.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<String> {
|
|
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<String>,
|
|
Option<String>,
|
|
String,
|
|
String,
|
|
i64,
|
|
String,
|
|
String,
|
|
Option<String>,
|
|
Option<String>,
|
|
i64,
|
|
);
|
|
|
|
async fn categories(pool: &SqlitePool, filter: &str) -> Vec<CategoryRow> {
|
|
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<CategoryRow> = 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::MIGRATOR.run_to(2, 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<CategoryRow> = 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 (the server runs at the current schema, so
|
|
// the later migrations, which write no synced row, are applied first).
|
|
db::migrate(pool).await.unwrap();
|
|
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<String> = 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 a_v2_database_in_use_upgrades_to_schema_3_keeping_every_row() {
|
|
let app = TestApp::at_v2().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(),
|
|
"2"
|
|
);
|
|
app.seed_household().await;
|
|
sqlx::query("UPDATE devices SET app_version_code = 41, fcm_token = 'fcm-1' WHERE id = ?")
|
|
.bind(crate::support::ADMIN_DEVICE_ID)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
let old_sequence = scalar_i64(pool, "SELECT value FROM sync_sequence WHERE id = 1").await;
|
|
let tables = user_tables(pool).await;
|
|
let mut before = Vec::new();
|
|
for table in &tables {
|
|
if table != "meta" {
|
|
before.push((table.clone(), dump(pool, table, "").await));
|
|
}
|
|
}
|
|
|
|
db::migrate(pool).await.unwrap();
|
|
|
|
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(),
|
|
"3"
|
|
);
|
|
assert_eq!(
|
|
scalar_i64(pool, "SELECT value FROM sync_sequence WHERE id = 1").await,
|
|
old_sequence,
|
|
"0003 writes no synced row"
|
|
);
|
|
// Every row is unchanged; devices gain only the trailing NULL column.
|
|
for (table, rows) in &before {
|
|
let after = dump(pool, table, "").await;
|
|
if table == "devices" {
|
|
let expected: Vec<String> = rows.iter().map(|r| format!("{r}|NULL")).collect();
|
|
assert_eq!(after, expected, "devices changed");
|
|
} else {
|
|
assert_eq!(&after, rows, "{table} changed");
|
|
}
|
|
}
|
|
// The new column takes a name of 1 to 32 characters or NULL.
|
|
let set = |value: Option<&'static str>| {
|
|
sqlx::query("UPDATE devices SET app_version_name = ? WHERE id = ?")
|
|
.bind(value)
|
|
.bind(crate::support::ADMIN_DEVICE_ID)
|
|
.execute(pool)
|
|
};
|
|
assert!(set(Some("1.1")).await.is_ok());
|
|
assert!(set(None).await.is_ok());
|
|
assert!(set(Some("")).await.is_err(), "empty rejected");
|
|
assert!(
|
|
set(Some("123456789012345678901234567890123"))
|
|
.await
|
|
.is_err(),
|
|
"33 characters rejected"
|
|
);
|
|
|
|
// A second run is a no-op.
|
|
db::migrate(pool).await.unwrap();
|
|
}
|
|
|
|
#[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
|
|
);
|
|
}
|