M12b: category contract v2 on the server (schema 2, fourteen defaults, finer import mapping)
- migrations/0002_categories_v2.sql, byte-identical to contracts/schema/migrations/0002_categories_v2.sql (SPEC §14.1.1), with an identity test; SCHEMA_VERSION = 2 (E-1 reports it, the importer's check follows). - Seed test on a fresh database (the fourteen rows of AC-7.2, server_version 11 to 14) and an upgrade test of a v1 database in use (0001 applied through the migrator, then users, a custom, a renamed and an archived default, an account, a recurring template, expenses): every earlier row unchanged, the four rows above the old sequence, foreign_key_check empty, schema_version 2, a pull from the old cursor returns exactly the four rows. - DefaultCategoryKey with the fourteen keys (wire enum and contracts/openapi.yaml); icons 33 and colours 14 (§9.7) in validation. - The two category examples M12a held back (E-26 response-default, sync/pull-page-categories-v2) and its contracts-check patch for them and the enum. - Importer: splitwise_categories.csv per the §10.8 v0.8 table (D-053), DEFAULT_KEYS of fourteen, synthetic fixtures for the finer mapping (Insurance/Försäkringar to insurance among them), a schema-1 refusal test. - Test harnesses seed users at server_version 15/16 (after the fourteen seeds); pull counts 10 -> 14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -3,12 +3,12 @@ Groceries,en,groceries
|
||||
Food and drink - Other,en,groceries
|
||||
Liquor,en,groceries
|
||||
Dining out,en,restaurants
|
||||
Rent,en,housing
|
||||
Mortgage,en,housing
|
||||
Electronics,en,home
|
||||
Furniture,en,home
|
||||
Household supplies,en,home
|
||||
Maintenance,en,home
|
||||
Mortgage,en,home
|
||||
Rent,en,home
|
||||
Services,en,home
|
||||
Pets,en,home
|
||||
Home - Other,en,home
|
||||
@@ -19,6 +19,7 @@ Trash,en,utilities
|
||||
TV/Phone/Internet,en,utilities
|
||||
Cleaning,en,utilities
|
||||
Utilities - Other,en,utilities
|
||||
Insurance,en,insurance
|
||||
Bicycle,en,transport
|
||||
Bus/train,en,transport
|
||||
Car,en,transport
|
||||
@@ -34,30 +35,35 @@ Music,en,entertainment
|
||||
Sports,en,entertainment
|
||||
Entertainment - Other,en,entertainment
|
||||
Medical expenses,en,health
|
||||
Childcare,en,children
|
||||
Clothing,en,shopping
|
||||
Gifts,en,shopping
|
||||
Taxes,en,fees
|
||||
General,en,other
|
||||
Childcare,en,other
|
||||
Education,en,other
|
||||
Insurance,en,other
|
||||
Taxes,en,other
|
||||
Life - Other,en,other
|
||||
Other,en,other
|
||||
Livsmedel,sv,groceries
|
||||
Mat och dryck - Övrigt,sv,groceries
|
||||
Alkohol,sv,groceries
|
||||
Restaurangbesök,sv,restaurants
|
||||
Hyra,sv,housing
|
||||
Avbetalning/Amortering,sv,housing
|
||||
Elektronik,sv,home
|
||||
Möbler,sv,home
|
||||
Förbrukningsvaror,sv,home
|
||||
Skötsel/underhåll,sv,home
|
||||
Avbetalning/Amortering,sv,home
|
||||
Hyra,sv,home
|
||||
Tjänster,sv,home
|
||||
Husdjur,sv,home
|
||||
Hem - Övrigt,sv,home
|
||||
El,sv,utilities
|
||||
Värme/gas,sv,utilities
|
||||
Vatten,sv,utilities
|
||||
Sophämtning,sv,utilities
|
||||
TV/telefon/internet,sv,utilities
|
||||
Städning,sv,utilities
|
||||
Verktyg - Övrigt,sv,utilities
|
||||
Försäkringar,sv,insurance
|
||||
Cykel,sv,transport
|
||||
Buss/tåg,sv,transport
|
||||
Bil,sv,transport
|
||||
@@ -68,16 +74,15 @@ Transport - Övrigt,sv,transport
|
||||
Hotell,sv,travel
|
||||
Flyg,sv,travel
|
||||
Spel,sv,entertainment
|
||||
Musik,sv,entertainment
|
||||
Filmer,sv,entertainment
|
||||
Musik,sv,entertainment
|
||||
Sport,sv,entertainment
|
||||
Underhållning - Övrigt,sv,entertainment
|
||||
Sjukvård/medicin,sv,health
|
||||
Presenter,sv,shopping
|
||||
Barnomsorg,sv,children
|
||||
Kläder,sv,shopping
|
||||
Presenter,sv,shopping
|
||||
Skatter,sv,fees
|
||||
Allmänt,sv,other
|
||||
Barnomsorg,sv,other
|
||||
Utbildning,sv,other
|
||||
Försäkringar,sv,other
|
||||
Skatter,sv,other
|
||||
Livet - Övrigt,sv,other
|
||||
|
||||
|
@@ -7,8 +7,8 @@ use std::collections::BTreeMap;
|
||||
/// The committed table, embedded at build time from the crate's data file.
|
||||
pub const TABLE_CSV: &str = include_str!("../data/splitwise_categories.csv");
|
||||
|
||||
/// The Kvitt default category keys (SPEC §14.1 `categories.default_key`).
|
||||
pub const DEFAULT_KEYS: [&str; 10] = [
|
||||
/// The Kvitt default category keys (SPEC AC-7.2; the `categories.default_key` CHECK of migration 0002, §14.1.1).
|
||||
pub const DEFAULT_KEYS: [&str; 14] = [
|
||||
"groceries",
|
||||
"restaurants",
|
||||
"home",
|
||||
@@ -19,6 +19,10 @@ pub const DEFAULT_KEYS: [&str; 10] = [
|
||||
"health",
|
||||
"shopping",
|
||||
"other",
|
||||
"housing",
|
||||
"insurance",
|
||||
"children",
|
||||
"fees",
|
||||
];
|
||||
|
||||
/// The key unmapped names fall back to.
|
||||
@@ -137,6 +141,38 @@ mod tests {
|
||||
assert_eq!(table.lookup("Payment"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finer_mapping_of_d053() {
|
||||
let table = CategoryTable::committed().unwrap();
|
||||
for (name, key) in [
|
||||
("Rent", "housing"),
|
||||
("Mortgage", "housing"),
|
||||
("Hyra", "housing"),
|
||||
("Avbetalning/Amortering", "housing"),
|
||||
("Insurance", "insurance"),
|
||||
("försäkringar", "insurance"),
|
||||
("Childcare", "children"),
|
||||
("Barnomsorg", "children"),
|
||||
("Taxes", "fees"),
|
||||
("Skatter", "fees"),
|
||||
("El", "utilities"),
|
||||
("Värme/gas", "utilities"),
|
||||
("Vatten", "utilities"),
|
||||
("Sophämtning", "utilities"),
|
||||
("Husdjur", "home"),
|
||||
("Pets", "home"),
|
||||
] {
|
||||
assert_eq!(table.lookup(name), Some(key), "{name}");
|
||||
}
|
||||
// Every key the table uses is a default key, and every default key is reachable.
|
||||
for key in DEFAULT_KEYS {
|
||||
assert!(
|
||||
table.entries.iter().any(|e| e.default_key == key),
|
||||
"{key} has no Splitwise name"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_tables() {
|
||||
let header = "splitwise_name,language,default_key\n";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
|
||||
|
||||
2025-03-01,Home insurance,Insurance,600.00,SEK,300.00,-300.00
|
||||
2025-03-02,Preschool fee,Childcare,1500.00,SEK,-750.00,750.00
|
||||
2025-03-03,Car tax,Taxes,400.00,SEK,200.00,-200.00
|
||||
2025-03-04,Mortgage March,Mortgage,5000.00,SEK,2500.00,-2500.00
|
||||
|
||||
2025-03-05,Total balance, , ,SEK,2250.00,-2250.00
|
||||
|
@@ -0,0 +1,11 @@
|
||||
Datum,Beskrivning,Kategori,Kostnad,Valuta,Robin Sample,Alex Example
|
||||
|
||||
2025-04-01,Hemförsäkring,Försäkringar,240.00,SEK,-120.00,120.00
|
||||
2025-04-02,Förskola,Barnomsorg,1000.00,SEK,500.00,-500.00
|
||||
2025-04-03,Fastighetsskatt,Skatter,300.00,SEK,-150.00,150.00
|
||||
2025-04-04,Hyra april,Hyra,8000.00,SEK,4000.00,-4000.00
|
||||
2025-04-05,Amortering april,Avbetalning/Amortering,2000.00,SEK,-1000.00,1000.00
|
||||
2025-04-06,Elräkning,El,500.00,SEK,250.00,-250.00
|
||||
2025-04-07,Hundmat,Husdjur,100.00,SEK,-50.00,50.00
|
||||
|
||||
2025-04-08,Totalsumma, , ,SEK,3430.00,-3430.00
|
||||
|
@@ -52,8 +52,8 @@ impl TestDb {
|
||||
let pool = db::connect(&path).await.unwrap();
|
||||
db::migrate(&pool).await.unwrap();
|
||||
for (id, role, name, version) in [
|
||||
(OWNER, "admin", "Alex", 11),
|
||||
(PARTNER, "member", "Robin", 12),
|
||||
(OWNER, "admin", "Alex", 15),
|
||||
(PARTNER, "member", "Robin", 16),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \
|
||||
@@ -67,7 +67,8 @@ impl TestDb {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
sqlx::query("UPDATE sync_sequence SET value = 12")
|
||||
// After the fourteen seeded categories (server_version 1 to 14, SPEC §14.1.1) and the two users.
|
||||
sqlx::query("UPDATE sync_sequence SET value = 16")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -268,6 +269,8 @@ async fn english_export_imports_expenses_settlements_and_audit() {
|
||||
assert!(rows[7].7.starts_with(&rows[7].6));
|
||||
assert_eq!(rows[7].7.chars().count(), 127);
|
||||
assert_eq!(rows[8].6, "Rent, January");
|
||||
// Rent is housing since D-053.
|
||||
assert_eq!(rows[8].8, "housing");
|
||||
assert!(
|
||||
rows.iter()
|
||||
.all(|r| r.7.is_empty() || r.6.chars().count() == 100)
|
||||
@@ -367,7 +370,7 @@ async fn english_export_imports_expenses_settlements_and_audit() {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(max_version, 12 + 1 + 9 + 1 + 10);
|
||||
assert_eq!(max_version, 16 + 1 + 9 + 1 + 10);
|
||||
// No pushes: the importer has no push queue and touches no sync state.
|
||||
let mutations: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sync_mutations")
|
||||
.fetch_one(&pool)
|
||||
@@ -643,6 +646,80 @@ async fn missing_database_is_not_created() {
|
||||
assert!(!options.db.exists());
|
||||
}
|
||||
|
||||
/// The finer mapping of D-053: every new default key is reached from English and Swedish Splitwise names, with
|
||||
/// nothing unmapped.
|
||||
#[tokio::test]
|
||||
async fn finer_mapping_reaches_the_four_new_defaults() {
|
||||
for (file, keys) in [
|
||||
(
|
||||
"english_finer.csv",
|
||||
&["insurance", "children", "fees", "housing"][..],
|
||||
),
|
||||
(
|
||||
"swedish_finer.csv",
|
||||
&[
|
||||
"insurance",
|
||||
"children",
|
||||
"fees",
|
||||
"housing",
|
||||
"housing",
|
||||
"utilities",
|
||||
"home",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let db = TestDb::new().await;
|
||||
let report = db.import(&db.options(file, "Hemma")).await.unwrap();
|
||||
assert!(report.committed, "{file}");
|
||||
assert_eq!(report.unmapped_rows(), 0, "{file}");
|
||||
assert!(report.reconciliation.matched, "{file}");
|
||||
assert_eq!(report.expenses_created, keys.len(), "{file}");
|
||||
let rows = expenses(&db).await;
|
||||
let actual: Vec<&str> = rows.iter().map(|r| r.8.as_str()).collect();
|
||||
assert_eq!(actual, keys, "{file}");
|
||||
}
|
||||
// A Försäkringar / Insurance row lands in the insurance default (id …012), not in Övrigt.
|
||||
let db = TestDb::new().await;
|
||||
db.import(&db.options("swedish_finer.csv", "Hemma"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.scalar(
|
||||
"SELECT COUNT(*) FROM expenses WHERE description = 'Hemförsäkring' \
|
||||
AND category_id = '01920000-0000-7000-8000-000000000012'"
|
||||
)
|
||||
.await,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
/// The importer requires the schema version the server's migrations produce (SPEC §10.8, D-054): a database still
|
||||
/// at schema 1 is refused with exit 1 and nothing written, and imports once migrated.
|
||||
#[tokio::test]
|
||||
async fn a_schema_1_database_is_refused_until_migrated() {
|
||||
let dir = std::env::temp_dir().join(format!("kvitt-import-{}", Uuid::now_v7()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("kvitt.sqlite");
|
||||
let pool = db::connect(&path).await.unwrap();
|
||||
db::MIGRATOR.run_to(1, &pool).await.unwrap();
|
||||
pool.close().await;
|
||||
let refused = kvitt_import::open(&path).await;
|
||||
match refused {
|
||||
Err(ImportError::Io(message)) => {
|
||||
assert!(message.contains("expected 2"), "{message}");
|
||||
assert!(message.contains("kvitt-server migrate"), "{message}");
|
||||
}
|
||||
Err(other) => panic!("expected a database error, got {other:?}"),
|
||||
Ok(_) => panic!("a schema 1 database was accepted"),
|
||||
}
|
||||
let pool = db::connect(&path).await.unwrap();
|
||||
db::migrate(&pool).await.unwrap();
|
||||
pool.close().await;
|
||||
let opened = kvitt_import::open(&path).await.unwrap();
|
||||
opened.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Every category name of the SPEC §10.8 table is in the committed data file exactly once, with its key, and the
|
||||
/// file holds nothing else.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- Kvitt server schema v2: four new default categories (housing, insurance, children, fees). Do not edit; add a new
|
||||
-- migration and bump the schema version (SPEC §14.8). Applied on top of server-v1.sql (0001_init.sql).
|
||||
--
|
||||
-- The default_key CHECK of categories cannot be altered in place in SQLite, so the table is rebuilt: copy the rows
|
||||
-- aside, drop the table, recreate it with the new CHECK and copy the rows back, ids and server_version unchanged.
|
||||
-- The migration runs inside one transaction (sqlx wraps every migration in one) with foreign_keys = ON (SPEC §8.9).
|
||||
-- defer_foreign_keys = ON lets expenses and recurring_templates point at categories while the table is briefly
|
||||
-- absent; the deferred check at commit still fails the migration if any reference is left dangling. It resets
|
||||
-- itself when the transaction ends. No other table, index or trigger refers to categories by anything but name.
|
||||
PRAGMA defer_foreign_keys = ON;
|
||||
|
||||
CREATE TABLE categories_v1_copy AS SELECT * FROM categories;
|
||||
DROP TABLE categories;
|
||||
|
||||
CREATE TABLE categories (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN
|
||||
('groceries', 'restaurants', 'home', 'utilities', 'transport',
|
||||
'travel', 'entertainment', 'health', 'shopping', 'other',
|
||||
'housing', 'insurance', 'children', 'fees')),
|
||||
name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40),
|
||||
icon TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
archived_at TEXT,
|
||||
deleted_at TEXT,
|
||||
server_version INTEGER NOT NULL UNIQUE,
|
||||
CHECK (name IS NOT NULL OR default_key IS NOT NULL)
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
|
||||
SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version
|
||||
FROM categories_v1_copy;
|
||||
DROP TABLE categories_v1_copy;
|
||||
|
||||
-- Each new row takes the next server_version from the global sequence, as every server write does (SPEC §14.1), so
|
||||
-- phones receive the rows on their next pull whatever their cursor.
|
||||
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
|
||||
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
|
||||
SELECT '01920000-0000-7000-8000-000000000011', 'housing', NULL, 'key', 'indigo', 25,
|
||||
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
|
||||
FROM sync_sequence WHERE id = 1;
|
||||
|
||||
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
|
||||
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
|
||||
SELECT '01920000-0000-7000-8000-000000000012', 'insurance', NULL, 'shield', 'slate', 45,
|
||||
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
|
||||
FROM sync_sequence WHERE id = 1;
|
||||
|
||||
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
|
||||
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
|
||||
SELECT '01920000-0000-7000-8000-000000000013', 'children', NULL, 'child_care', 'lime', 85,
|
||||
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
|
||||
FROM sync_sequence WHERE id = 1;
|
||||
|
||||
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
|
||||
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
|
||||
SELECT '01920000-0000-7000-8000-000000000014', 'fees', NULL, 'account_balance', 'amber', 95,
|
||||
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
|
||||
FROM sync_sequence WHERE id = 1;
|
||||
|
||||
UPDATE meta SET value = '2' WHERE key = 'schema_version';
|
||||
@@ -9,8 +9,8 @@ use kvitt_domain::Period;
|
||||
use crate::error::AppError;
|
||||
use crate::time::STOCKHOLM;
|
||||
|
||||
/// Category icon tokens (SPEC §9.7, exhaustive for v1).
|
||||
pub const ICONS: [&str; 30] = [
|
||||
/// Category icon tokens (SPEC §9.7, exhaustive; `key`, `shield` and `account_balance` added by D-054).
|
||||
pub const ICONS: [&str; 33] = [
|
||||
"shopping_cart",
|
||||
"restaurant",
|
||||
"local_cafe",
|
||||
@@ -41,12 +41,15 @@ pub const ICONS: [&str; 30] = [
|
||||
"chair",
|
||||
"subscriptions",
|
||||
"category",
|
||||
"key",
|
||||
"shield",
|
||||
"account_balance",
|
||||
];
|
||||
|
||||
/// Colour tokens (SPEC §9.7, exhaustive for v1).
|
||||
pub const COLORS: [&str; 12] = [
|
||||
/// Colour tokens (SPEC §9.7, exhaustive; `slate` and `amber` added by D-054).
|
||||
pub const COLORS: [&str; 14] = [
|
||||
"green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey",
|
||||
"indigo", "lime",
|
||||
"indigo", "lime", "slate", "amber",
|
||||
];
|
||||
|
||||
/// Account name: 1 to 60 code points (SPEC §9.6).
|
||||
@@ -199,4 +202,24 @@ mod tests {
|
||||
assert!(color("lime").is_ok());
|
||||
assert!(color("black").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_added_by_schema_2() {
|
||||
for token in ["key", "shield", "account_balance"] {
|
||||
assert!(icon(token).is_ok(), "{token}");
|
||||
}
|
||||
for token in ["slate", "amber"] {
|
||||
assert!(color(token).is_ok(), "{token}");
|
||||
}
|
||||
assert_eq!(ICONS.len(), 33);
|
||||
assert_eq!(COLORS.len(), 14);
|
||||
let mut icons = ICONS.to_vec();
|
||||
icons.sort_unstable();
|
||||
icons.dedup();
|
||||
assert_eq!(icons.len(), ICONS.len(), "icon tokens are distinct");
|
||||
let mut colors = COLORS.to_vec();
|
||||
colors.sort_unstable();
|
||||
colors.dedup();
|
||||
assert_eq!(colors.len(), COLORS.len(), "colour tokens are distinct");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ pub use pool::{Db, begin_immediate, connect};
|
||||
pub use sequence::next_server_version;
|
||||
|
||||
/// The schema version the embedded migrations produce (`meta.schema_version`, SPEC §14.1).
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
pub const SCHEMA_VERSION: i64 = 2;
|
||||
|
||||
/// The embedded migrations of `migrations/` (`0001_init.sql` is byte-identical to `contracts/schema/server-v1.sql`).
|
||||
/// The embedded migrations of `migrations/` (`0001_init.sql` is byte-identical to `contracts/schema/server-v1.sql`,
|
||||
/// `0002_categories_v2.sql` to `contracts/schema/migrations/0002_categories_v2.sql`, SPEC §14.1.1).
|
||||
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
/// Runs every pending migration.
|
||||
|
||||
@@ -6,6 +6,7 @@ use uuid::Uuid;
|
||||
|
||||
use super::{ServerVersionSchema, TimestampSchema, UuidSchema};
|
||||
|
||||
/// The fourteen default category keys (SPEC AC-7.2): the ten of v1, then the four of migration 0002 (§14.1.1).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DefaultCategoryKey {
|
||||
@@ -19,6 +20,10 @@ pub enum DefaultCategoryKey {
|
||||
Health,
|
||||
Shopping,
|
||||
Other,
|
||||
Housing,
|
||||
Insurance,
|
||||
Children,
|
||||
Fees,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -171,7 +171,7 @@ impl TestApp {
|
||||
/// Only the admin (Jacob) with one device: the household is incomplete.
|
||||
pub async fn admin_only() -> Self {
|
||||
let app = Self::empty().await;
|
||||
app.insert_user(ADMIN_ID, "admin", "Jacob", 11).await;
|
||||
app.insert_user(ADMIN_ID, "admin", "Jacob", 15).await;
|
||||
app.insert_device(
|
||||
ADMIN_DEVICE_ID,
|
||||
ADMIN_ID,
|
||||
@@ -186,7 +186,7 @@ impl TestApp {
|
||||
/// Admin Jacob and member Anna, one device each, plus a revoked old device of the admin.
|
||||
pub async fn household() -> Self {
|
||||
let app = Self::admin_only().await;
|
||||
app.insert_user(MEMBER_ID, "member", "Anna", 12).await;
|
||||
app.insert_user(MEMBER_ID, "member", "Anna", 16).await;
|
||||
app.insert_device(
|
||||
MEMBER_DEVICE_ID,
|
||||
MEMBER_ID,
|
||||
|
||||
@@ -186,7 +186,7 @@ async fn e3_invalid_request_and_validation_failed() {
|
||||
async fn e3_household_full_when_two_users_exist() {
|
||||
let app = TestApp::admin_only().await;
|
||||
let code = new_invite(&app, InviteKind::Partner, None).await;
|
||||
app.insert_user(MEMBER_ID, "member", "Anna", 12).await;
|
||||
app.insert_user(MEMBER_ID, "member", "Anna", 16).await;
|
||||
let reply = accept(&app, accept_body(&code, Some("Bertil"))).await;
|
||||
assert_error(&reply, 409, "household_full");
|
||||
}
|
||||
|
||||
@@ -19,11 +19,16 @@ async fn e7_returns_user_device_household_and_public_url() {
|
||||
.insert("x-kvitt-app-version", "41".parse().unwrap());
|
||||
let reply = app.send(request).await;
|
||||
assert_eq!(reply.status, 200);
|
||||
// `server_version` is volatile here: the example's users have 11 and 12 (schema 1, after ten seeded
|
||||
// categories); since migration 0002 seeds fourteen, the harness's users have 15 and 16 (SPEC §14.1.1).
|
||||
assert_like_example(
|
||||
&reply.json,
|
||||
"E-7-me/response.json",
|
||||
&["created_at", "updated_at", "last_seen_at"],
|
||||
&["created_at", "updated_at", "last_seen_at", "server_version"],
|
||||
);
|
||||
assert_eq!(reply.json["user"]["server_version"], 15);
|
||||
assert_eq!(reply.json["household"][0]["server_version"], 15);
|
||||
assert_eq!(reply.json["household"][1]["server_version"], 16);
|
||||
assert_eq!(reply.json["device"]["id"], ADMIN_DEVICE_ID);
|
||||
assert_eq!(reply.json["device"]["is_current"], true);
|
||||
let member = app.call(Method::GET, "/me", &member_token(), None).await;
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
//! `0001_init.sql` is the contract schema byte for byte (SPEC §14.1), and connections carry the §8.9 pragmas.
|
||||
//! 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::TestApp;
|
||||
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();
|
||||
@@ -21,13 +54,371 @@ fn migration_0001_is_byte_identical_to_contract_schema() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_embedded_migrator_holds_exactly_that_migration() {
|
||||
let contract =
|
||||
std::fs::read_to_string(manifest("../../../contracts/schema/server-v1.sql")).unwrap();
|
||||
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(), 1);
|
||||
assert_eq!(migrations.len(), 2);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(migrations[0].sql.as_ref(), contract.as_str());
|
||||
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<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::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<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.
|
||||
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]
|
||||
@@ -48,7 +439,7 @@ async fn migrated_database_has_schema_version_seed_rows_and_pragmas() {
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(categories, 10);
|
||||
assert_eq!(categories, 14);
|
||||
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
let journal: String = sqlx::query_scalar("PRAGMA journal_mode")
|
||||
@@ -83,14 +474,14 @@ async fn migrating_twice_is_a_no_op() {
|
||||
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(), 11);
|
||||
assert_eq!(db::next_server_version(&mut tx).await.unwrap(), 12);
|
||||
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(),
|
||||
12
|
||||
16
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ async fn health_reports_ok_with_versions() {
|
||||
assert_eq!(reply.status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
reply.json,
|
||||
json!({"status": "ok", "server_version": env!("CARGO_PKG_VERSION"), "schema_version": 1, "db_ok": true})
|
||||
json!({"status": "ok", "server_version": env!("CARGO_PKG_VERSION"), "schema_version": 2, "db_ok": true})
|
||||
);
|
||||
assert_eq!(
|
||||
reply.headers["x-kvitt-server-version"],
|
||||
@@ -124,7 +124,7 @@ async fn health_is_503_degraded_when_the_database_is_gone() {
|
||||
assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(reply.json["status"], "degraded");
|
||||
assert_eq!(reply.json["db_ok"], false);
|
||||
assert_eq!(reply.json["schema_version"], 1);
|
||||
assert_eq!(reply.json["schema_version"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -62,13 +62,26 @@ pub struct TestApp {
|
||||
impl TestApp {
|
||||
/// A migrated, empty database (no users).
|
||||
pub async fn empty() -> Self {
|
||||
Self::migrated_to(None).await
|
||||
}
|
||||
|
||||
/// An empty database at schema version 1: only `0001_init.sql` applied, through the server's own migrator, so a
|
||||
/// later `db::migrate` upgrades it the way a deployed v1 database is upgraded.
|
||||
pub async fn at_v1() -> Self {
|
||||
Self::migrated_to(Some(1)).await
|
||||
}
|
||||
|
||||
async fn migrated_to(target: Option<i64>) -> Self {
|
||||
let dir = TempDir::new();
|
||||
let config = Config::from_lookup(|k| {
|
||||
(k == "KVITT_DATA_DIR").then(|| dir.0.to_string_lossy().into_owned())
|
||||
})
|
||||
.unwrap();
|
||||
let pool = db::connect(&config.database_path()).await.unwrap();
|
||||
db::migrate(&pool).await.unwrap();
|
||||
match target {
|
||||
Some(version) => db::MIGRATOR.run_to(version, &pool).await.unwrap(),
|
||||
None => db::migrate(&pool).await.unwrap(),
|
||||
}
|
||||
let clock = FixedClock::new(parse_ts(NOW).unwrap());
|
||||
let state = AppState::new(config, pool).with_clock(Arc::new(clock.clone()));
|
||||
Self {
|
||||
@@ -81,10 +94,17 @@ impl TestApp {
|
||||
/// Admin and member users, one device each, plus a revoked device of the member.
|
||||
pub async fn household() -> Self {
|
||||
let app = Self::empty().await;
|
||||
let pool = &app.state.db;
|
||||
app.seed_household().await;
|
||||
app
|
||||
}
|
||||
|
||||
/// Inserts the users and devices of [`TestApp::household`] (users at `server_version` 15 and 16, after the
|
||||
/// fourteen seeded categories; the sequence is not advanced).
|
||||
pub async fn seed_household(&self) {
|
||||
let pool = &self.state.db;
|
||||
for (id, role, name, version) in [
|
||||
(ADMIN_ID, "admin", "Jacob", 11),
|
||||
(MEMBER_ID, "member", "Partner", 12),
|
||||
(ADMIN_ID, "admin", "Jacob", 15),
|
||||
(MEMBER_ID, "member", "Partner", 16),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \
|
||||
@@ -122,7 +142,6 @@ impl TestApp {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
pub async fn send(&self, request: Request<Body>) -> Reply {
|
||||
|
||||
@@ -58,6 +58,7 @@ fn check(name: &str, example: &Value) -> bool {
|
||||
"E-24-patch-category/response.json" => Category,
|
||||
"E-25-archive-category/response.json" => Category,
|
||||
"E-26-unarchive-category/response.json" => Category,
|
||||
"E-26-unarchive-category/response-default.json" => Category,
|
||||
"E-27-delete-category/response.json" => Category,
|
||||
"E-28-create-recurring-template/request.json" => CreateRecurringTemplateRequest,
|
||||
"E-28-create-recurring-template/response.json" => RecurringTemplate,
|
||||
@@ -71,6 +72,7 @@ fn check(name: &str, example: &Value) -> bool {
|
||||
"E-33-settlement-proposal/response.json" => ProposalResponse,
|
||||
"sync/pull-empty-page.json" => SyncPullResponse,
|
||||
"sync/pull-page.json" => SyncPullResponse,
|
||||
"sync/pull-page-categories-v2.json" => SyncPullResponse,
|
||||
"sync/push-request.json" => SyncPushRequest,
|
||||
"sync/push-request-delete.json" => SyncPushRequest,
|
||||
"sync/push-request-rejections.json" => SyncPushRequest,
|
||||
|
||||
@@ -89,8 +89,8 @@ async fn the_first_page_has_everything_in_version_order_with_the_epoch_and_serve
|
||||
.iter()
|
||||
.map(|a| page[*a].as_array().unwrap().len())
|
||||
.collect();
|
||||
// 2 users, 10 default categories, 2 accounts, no templates, 1 expense, 1 settlement, 2 audit entries.
|
||||
assert_eq!(counts, [2, 10, 2, 0, 1, 1, 2]);
|
||||
// 2 users, 14 default categories, 2 accounts, no templates, 1 expense, 1 settlement, 2 audit entries.
|
||||
assert_eq!(counts, [2, 14, 2, 0, 1, 1, 2]);
|
||||
for a in ARRAYS {
|
||||
let v: Vec<i64> = page[a]
|
||||
.as_array()
|
||||
@@ -141,7 +141,7 @@ async fn paging_walks_every_row_once_parents_with_children_count_as_one() {
|
||||
for _ in 0..9 {
|
||||
push_one(&app, &admin_token(), exp(expense(&new_id(), T1))).await;
|
||||
}
|
||||
// 2 users + 10 categories + 2 accounts + 9 expenses + 9 audit entries = 32 rows.
|
||||
// 2 users + 14 categories + 2 accounts + 9 expenses + 9 audit entries = 36 rows.
|
||||
let mut since = 0;
|
||||
let mut seen = Vec::new();
|
||||
let mut pages = 0;
|
||||
@@ -160,8 +160,8 @@ async fn paging_walks_every_row_once_parents_with_children_count_as_one() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(pages, 7);
|
||||
assert_eq!(seen.len(), 32);
|
||||
assert_eq!(pages, 8);
|
||||
assert_eq!(seen.len(), 36);
|
||||
let mut dedup = seen.clone();
|
||||
dedup.dedup();
|
||||
assert_eq!(dedup, seen);
|
||||
@@ -170,9 +170,9 @@ async fn paging_walks_every_row_once_parents_with_children_count_as_one() {
|
||||
assert_eq!(reply.json["next_since"], since);
|
||||
assert_eq!(reply.json["has_more"], false);
|
||||
// has_more is false when the page ends exactly on the last row.
|
||||
let reply = pull(&app, &admin_token(), 0, Some(32)).await;
|
||||
let reply = pull(&app, &admin_token(), 0, Some(36)).await;
|
||||
assert_eq!(reply.json["has_more"], false);
|
||||
let reply = pull(&app, &admin_token(), 0, Some(31)).await;
|
||||
let reply = pull(&app, &admin_token(), 0, Some(35)).await;
|
||||
assert_eq!(reply.json["has_more"], true);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user