diff --git a/contracts/examples/E-26-unarchive-category/response-default.json b/contracts/examples/E-26-unarchive-category/response-default.json new file mode 100644 index 0000000..13d9180 --- /dev/null +++ b/contracts/examples/E-26-unarchive-category/response-default.json @@ -0,0 +1,13 @@ +{ + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance", + "name": null, + "icon": "shield", + "color": "slate", + "sort_order": 45, + "created_at": "2026-10-18T00:00:00.000Z", + "updated_at": "2026-10-25T12:10:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 931 +} diff --git a/contracts/examples/sync/pull-page-categories-v2.json b/contracts/examples/sync/pull-page-categories-v2.json new file mode 100644 index 0000000..215eed2 --- /dev/null +++ b/contracts/examples/sync/pull-page-categories-v2.json @@ -0,0 +1,67 @@ +{ + "epoch": "30b85fb529aac0016dc91650841005b9", + "since": 921, + "next_since": 925, + "has_more": false, + "server_time": "2026-10-25T12:00:05.000Z", + "users": [], + "categories": [ + { + "id": "01920000-0000-7000-8000-000000000011", + "default_key": "housing", + "name": null, + "icon": "key", + "color": "indigo", + "sort_order": 25, + "created_at": "2026-10-18T00:00:00.000Z", + "updated_at": "2026-10-18T00:00:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 922 + }, + { + "id": "01920000-0000-7000-8000-000000000012", + "default_key": "insurance", + "name": null, + "icon": "shield", + "color": "slate", + "sort_order": 45, + "created_at": "2026-10-18T00:00:00.000Z", + "updated_at": "2026-10-18T00:00:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 923 + }, + { + "id": "01920000-0000-7000-8000-000000000013", + "default_key": "children", + "name": null, + "icon": "child_care", + "color": "lime", + "sort_order": 85, + "created_at": "2026-10-18T00:00:00.000Z", + "updated_at": "2026-10-18T00:00:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 924 + }, + { + "id": "01920000-0000-7000-8000-000000000014", + "default_key": "fees", + "name": null, + "icon": "account_balance", + "color": "amber", + "sort_order": 95, + "created_at": "2026-10-18T00:00:00.000Z", + "updated_at": "2026-10-18T00:00:00.000Z", + "archived_at": null, + "deleted_at": null, + "server_version": 925 + } + ], + "accounts": [], + "recurring_templates": [], + "expenses": [], + "settlements": [], + "audit_entries": [] +} diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index d729d48..25a222a 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -1576,7 +1576,7 @@ components: enum: [admin_setup, partner, device] DefaultCategoryKey: type: string - enum: [groceries, restaurants, home, utilities, transport, travel, entertainment, health, shopping, other] + enum: [groceries, restaurants, home, utilities, transport, travel, entertainment, health, shopping, other, housing, insurance, children, fees] SplitMode: type: string enum: [default, ratio, exact, full] diff --git a/server/crates/kvitt-import/data/splitwise_categories.csv b/server/crates/kvitt-import/data/splitwise_categories.csv index adc1748..0e3c384 100644 --- a/server/crates/kvitt-import/data/splitwise_categories.csv +++ b/server/crates/kvitt-import/data/splitwise_categories.csv @@ -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 diff --git a/server/crates/kvitt-import/src/categories.rs b/server/crates/kvitt-import/src/categories.rs index 165f7bc..97c4397 100644 --- a/server/crates/kvitt-import/src/categories.rs +++ b/server/crates/kvitt-import/src/categories.rs @@ -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"; diff --git a/server/crates/kvitt-import/tests/fixtures/english_finer.csv b/server/crates/kvitt-import/tests/fixtures/english_finer.csv new file mode 100644 index 0000000..535f793 --- /dev/null +++ b/server/crates/kvitt-import/tests/fixtures/english_finer.csv @@ -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 diff --git a/server/crates/kvitt-import/tests/fixtures/swedish_finer.csv b/server/crates/kvitt-import/tests/fixtures/swedish_finer.csv new file mode 100644 index 0000000..a8ece9a --- /dev/null +++ b/server/crates/kvitt-import/tests/fixtures/swedish_finer.csv @@ -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 diff --git a/server/crates/kvitt-import/tests/import.rs b/server/crates/kvitt-import/tests/import.rs index 73feced..2ab3775 100644 --- a/server/crates/kvitt-import/tests/import.rs +++ b/server/crates/kvitt-import/tests/import.rs @@ -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] diff --git a/server/crates/kvitt-server/migrations/0002_categories_v2.sql b/server/crates/kvitt-server/migrations/0002_categories_v2.sql new file mode 100644 index 0000000..e54bef2 --- /dev/null +++ b/server/crates/kvitt-server/migrations/0002_categories_v2.sql @@ -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'; diff --git a/server/crates/kvitt-server/src/api/validate.rs b/server/crates/kvitt-server/src/api/validate.rs index 8194777..c0a97e2 100644 --- a/server/crates/kvitt-server/src/api/validate.rs +++ b/server/crates/kvitt-server/src/api/validate.rs @@ -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"); + } } diff --git a/server/crates/kvitt-server/src/db/mod.rs b/server/crates/kvitt-server/src/db/mod.rs index 5398b3f..8c99ccd 100644 --- a/server/crates/kvitt-server/src/db/mod.rs +++ b/server/crates/kvitt-server/src/db/mod.rs @@ -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. diff --git a/server/crates/kvitt-server/src/wire/category.rs b/server/crates/kvitt-server/src/wire/category.rs index 5e598d2..1b5995e 100644 --- a/server/crates/kvitt-server/src/wire/category.rs +++ b/server/crates/kvitt-server/src/wire/category.rs @@ -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)] diff --git a/server/crates/kvitt-server/tests/core_api/common.rs b/server/crates/kvitt-server/tests/core_api/common.rs index f5f9d81..2155795 100644 --- a/server/crates/kvitt-server/tests/core_api/common.rs +++ b/server/crates/kvitt-server/tests/core_api/common.rs @@ -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, diff --git a/server/crates/kvitt-server/tests/core_api/invites.rs b/server/crates/kvitt-server/tests/core_api/invites.rs index 012ce63..13ad6ab 100644 --- a/server/crates/kvitt-server/tests/core_api/invites.rs +++ b/server/crates/kvitt-server/tests/core_api/invites.rs @@ -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"); } diff --git a/server/crates/kvitt-server/tests/core_api/me.rs b/server/crates/kvitt-server/tests/core_api/me.rs index bb42872..a6ca98a 100644 --- a/server/crates/kvitt-server/tests/core_api/me.rs +++ b/server/crates/kvitt-server/tests/core_api/me.rs @@ -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; diff --git a/server/crates/kvitt-server/tests/skeleton/migration.rs b/server/crates/kvitt-server/tests/skeleton/migration.rs index 3028877..53a79ae 100644 --- a/server/crates/kvitt-server/tests/skeleton/migration.rs +++ b/server/crates/kvitt-server/tests/skeleton/migration.rs @@ -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 { + 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] @@ -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 ); } diff --git a/server/crates/kvitt-server/tests/skeleton/routes.rs b/server/crates/kvitt-server/tests/skeleton/routes.rs index 9ec1df8..2d62d0b 100644 --- a/server/crates/kvitt-server/tests/skeleton/routes.rs +++ b/server/crates/kvitt-server/tests/skeleton/routes.rs @@ -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] diff --git a/server/crates/kvitt-server/tests/skeleton/support.rs b/server/crates/kvitt-server/tests/skeleton/support.rs index e33f50d..0126e13 100644 --- a/server/crates/kvitt-server/tests/skeleton/support.rs +++ b/server/crates/kvitt-server/tests/skeleton/support.rs @@ -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) -> 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) -> Reply { diff --git a/server/crates/kvitt-server/tests/skeleton/wire_examples.rs b/server/crates/kvitt-server/tests/skeleton/wire_examples.rs index cc06936..81ba13c 100644 --- a/server/crates/kvitt-server/tests/skeleton/wire_examples.rs +++ b/server/crates/kvitt-server/tests/skeleton/wire_examples.rs @@ -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, diff --git a/server/crates/kvitt-server/tests/sync_api/pull.rs b/server/crates/kvitt-server/tests/sync_api/pull.rs index 39a1b8d..0d67188 100644 --- a/server/crates/kvitt-server/tests/sync_api/pull.rs +++ b/server/crates/kvitt-server/tests/sync_api/pull.rs @@ -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 = 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); } diff --git a/tools/contracts-check.py b/tools/contracts-check.py index 00f5f70..a46ca4f 100755 --- a/tools/contracts-check.py +++ b/tools/contracts-check.py @@ -14,8 +14,8 @@ Checks, one output line each: 14 default categories with server_versions from the global sequence and schema_version 2; * contracts/category-keywords.csv (the keyword table of the category suggestion) is well-formed: header, match kinds, default keys, keywords already normalised, unique, sorted; - * contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2, and icon and - color stay plain strings (text check: the standard library has no YAML parser). + * contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2, and the + DefaultCategoryKey enum holds the 14 default keys (text check: the standard library has no YAML parser). Exit status 0 when every check passes, 1 otherwise. Usage: python3 tools/contracts-check.py [repository root] @@ -1084,6 +1084,7 @@ EXAMPLES = { "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", @@ -1097,6 +1098,7 @@ EXAMPLES = { "E-33-settlement-proposal/response.json": "ProposalResponse", "sync/pull-page.json": "SyncPullResponse", "sync/pull-empty-page.json": "SyncPullResponse", + "sync/pull-page-categories-v2.json": "SyncPullResponse", "sync/push-request.json": "SyncPushRequest", "sync/push-response.json": "SyncPushResponse", "sync/push-request-delete.json": "SyncPushRequest", @@ -1510,6 +1512,10 @@ def check_openapi(): m = re.search(r"^ RejectionReason:\n((?: .*\n|\s*\n)+)", text + "\n", re.M) reasons = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else [] report(reasons == REASON[1], "openapi RejectionReason enum is the exhaustive list of §14.4.6", "got %s" % reasons if reasons != REASON[1] else "") + m = re.search(r"^ DefaultCategoryKey:\n type: string\n enum: \[([^\]]*)\]$", text, re.M) + keys = [k.strip() for k in m.group(1).split(",")] if m else [] + report(keys == DEFAULT_KEYS, "openapi DefaultCategoryKey enum is the 14 default keys (AC-7.2, UX-8)", + "got %s" % keys if keys != DEFAULT_KEYS else "%d keys" % len(keys)) leaked = [t for t in ("ColorToken", "IconToken") if re.search(r"^ %s:" % t, text, re.M)] icon_plain = re.search(r"^ icon:\n type: string\n", text, re.M) is not None color_plain = re.search(r"^ color:\n type: string\n", text, re.M) is not None