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]
|
||||
|
||||
Reference in New Issue
Block a user