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:
2026-10-18 08:23:37 +02:00
parent b09f3220a1
commit 6eaccec7ac
21 changed files with 790 additions and 57 deletions
+81 -4
View File
@@ -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]