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
@@ -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
1 splitwise_name language default_key
3 Food and drink - Other en groceries
4 Liquor en groceries
5 Dining out en restaurants
6 Rent en housing
7 Mortgage en housing
8 Electronics en home
9 Furniture en home
10 Household supplies en home
11 Maintenance en home
Mortgage en home
Rent en home
12 Services en home
13 Pets en home
14 Home - Other en home
19 TV/Phone/Internet en utilities
20 Cleaning en utilities
21 Utilities - Other en utilities
22 Insurance en insurance
23 Bicycle en transport
24 Bus/train en transport
25 Car en transport
35 Sports en entertainment
36 Entertainment - Other en entertainment
37 Medical expenses en health
38 Childcare en children
39 Clothing en shopping
40 Gifts en shopping
41 Taxes en fees
42 General en other
Childcare en other
43 Education en other
Insurance en other
Taxes en other
44 Life - Other en other
45 Other en other
46 Livsmedel sv groceries
47 Mat och dryck - Övrigt sv groceries
48 Alkohol sv groceries
49 Restaurangbesök sv restaurants
50 Hyra sv housing
51 Avbetalning/Amortering sv housing
52 Elektronik sv home
53 Möbler sv home
54 Förbrukningsvaror sv home
55 Skötsel/underhåll sv home
Avbetalning/Amortering sv home
Hyra sv home
56 Tjänster sv home
57 Husdjur sv home
58 Hem - Övrigt sv home
59 El sv utilities
60 Värme/gas sv utilities
61 Vatten sv utilities
62 Sophämtning sv utilities
63 TV/telefon/internet sv utilities
64 Städning sv utilities
65 Verktyg - Övrigt sv utilities
66 Försäkringar sv insurance
67 Cykel sv transport
68 Buss/tåg sv transport
69 Bil sv transport
74 Hotell sv travel
75 Flyg sv travel
76 Spel sv entertainment
Musik sv entertainment
77 Filmer sv entertainment
78 Musik sv entertainment
79 Sport sv entertainment
80 Underhållning - Övrigt sv entertainment
81 Sjukvård/medicin sv health
82 Presenter Barnomsorg sv shopping children
83 Kläder sv shopping
84 Presenter sv shopping
85 Skatter sv fees
86 Allmänt sv other
Barnomsorg sv other
87 Utbildning sv other
Försäkringar sv other
Skatter sv other
88 Livet - Övrigt sv other
+38 -2
View File
@@ -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
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-03-01 Home insurance Insurance 600.00 SEK 300.00 -300.00
3 2025-03-02 Preschool fee Childcare 1500.00 SEK -750.00 750.00
4 2025-03-03 Car tax Taxes 400.00 SEK 200.00 -200.00
5 2025-03-04 Mortgage March Mortgage 5000.00 SEK 2500.00 -2500.00
6 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
1 Datum Beskrivning Kategori Kostnad Valuta Robin Sample Alex Example
2 2025-04-01 Hemförsäkring Försäkringar 240.00 SEK -120.00 120.00
3 2025-04-02 Förskola Barnomsorg 1000.00 SEK 500.00 -500.00
4 2025-04-03 Fastighetsskatt Skatter 300.00 SEK -150.00 150.00
5 2025-04-04 Hyra april Hyra 8000.00 SEK 4000.00 -4000.00
6 2025-04-05 Amortering april Avbetalning/Amortering 2000.00 SEK -1000.00 1000.00
7 2025-04-06 Elräkning El 500.00 SEK 250.00 -250.00
8 2025-04-07 Hundmat Husdjur 100.00 SEK -50.00 50.00
9 2025-04-08 Totalsumma SEK 3430.00 -3430.00
+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]
@@ -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';
+28 -5
View File
@@ -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");
}
}
+3 -2
View File
@@ -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);
}