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
@@ -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);
}