M9: kvitt-import Splitwise importer (SPEC §10.8, FR-17)

CLI per §10.8 with --help, --dry-run and a summary; English and Swedish
headers with member columns matched by name; CSV quoting; payments as
settlements with one allocation; trailing totals row used to reconcile
the account balance (mismatch rolls back, exit 4); non-SEK rows abort
unless --fx; committed category table data/splitwise_categories.csv;
idempotent re-runs through import_key; rows validated with the server's
push checks, audited with source import, no pushes; one BEGIN IMMEDIATE
transaction. Tests over synthetic fixtures only.

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 07:45:32 +02:00
parent d9dd5848fe
commit 50339aef6d
21 changed files with 3097 additions and 13 deletions
@@ -0,0 +1,2 @@
Date,Description,Category,Amount,Currency,Alex Example,Robin Sample
2025-05-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
1 Date Description Category Amount Currency Alex Example Robin Sample
2 2025-05-01 Fine row Groceries 10.00 SEK 5.00 -5.00
+15
View File
@@ -0,0 +1,15 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-01-05,Weekly shop,Groceries,300.00,SEK,150.00,-150.00
2025-01-06,"Paint, brushes and tape",Household supplies,120.50,SEK,-60.25,60.25
2025-01-07,Cinema,Movies,200.00,SEK,0.00,0.00
2025-01-08,Bus card,Bus/train,100.00,SEK,-100.00,100.00
2025-01-09,Mystery box,Widgets,50.00,SEK,25.00,-25.00
2025-01-10,Coffee,Dining out,40.00,SEK,20.00,-20.00
2025-01-10,Coffee,Dining out,40.00,SEK,20.00,-20.00
2025-01-11,An extremely long synthetic description written only to exercise the truncation rule of the importer at one hundred code points,General,10.00,SEK,5.00,-5.00
2025-01-12,Robin paid Alex,Payment,100.00,SEK,-100.00,100.00
2025-01-13,"Rent, January",Rent,1000.00,SEK,500.00,-500.00
2025-01-14,Total balance, , ,SEK,459.75,-459.75
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-01-05 Weekly shop Groceries 300.00 SEK 150.00 -150.00
3 2025-01-06 Paint, brushes and tape Household supplies 120.50 SEK -60.25 60.25
4 2025-01-07 Cinema Movies 200.00 SEK 0.00 0.00
5 2025-01-08 Bus card Bus/train 100.00 SEK -100.00 100.00
6 2025-01-09 Mystery box Widgets 50.00 SEK 25.00 -25.00
7 2025-01-10 Coffee Dining out 40.00 SEK 20.00 -20.00
8 2025-01-10 Coffee Dining out 40.00 SEK 20.00 -20.00
9 2025-01-11 An extremely long synthetic description written only to exercise the truncation rule of the importer at one hundred code points General 10.00 SEK 5.00 -5.00
10 2025-01-12 Robin paid Alex Payment 100.00 SEK -100.00 100.00
11 2025-01-13 Rent, January Rent 1000.00 SEK 500.00 -500.00
12 2025-01-14 Total balance SEK 459.75 -459.75
@@ -0,0 +1,7 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-04-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
2025-13-01,Bad date,Groceries,10.00,SEK,5.00,-5.00
2025-04-03,Total balance, , ,SEK,10.00,-10.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-04-01 Fine row Groceries 10.00 SEK 5.00 -5.00
3 2025-13-01 Bad date Groceries 10.00 SEK 5.00 -5.00
4 2025-04-03 Total balance SEK 10.00 -10.00
@@ -0,0 +1,7 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-04-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
2025-04-02,Broken row,Groceries,10.00,SEK,5.00,-4.00
2025-04-03,Total balance, , ,SEK,10.00,-9.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-04-01 Fine row Groceries 10.00 SEK 5.00 -5.00
3 2025-04-02 Broken row Groceries 10.00 SEK 5.00 -4.00
4 2025-04-03 Total balance SEK 10.00 -9.00
@@ -0,0 +1,6 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-05-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
2025-05-02,Total balance, , ,SEK,7.00,-7.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-05-01 Fine row Groceries 10.00 SEK 5.00 -5.00
3 2025-05-02 Total balance SEK 7.00 -7.00
@@ -0,0 +1,2 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-05-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-05-01 Fine row Groceries 10.00 SEK 5.00 -5.00
+8
View File
@@ -0,0 +1,8 @@
Date,Description,Category,Cost,Currency,Alex Example,Robin Sample
2025-03-01,Market,Groceries,100.00,SEK,50.00,-50.00
2025-03-02,Hotel abroad,Hotel,10.00,EUR,5.00,-5.00
2025-03-03,Total balance, , ,SEK,50.00,-50.00
2025-03-03,Total balance, , ,EUR,5.00,-5.00
1 Date Description Category Cost Currency Alex Example Robin Sample
2 2025-03-01 Market Groceries 100.00 SEK 50.00 -50.00
3 2025-03-02 Hotel abroad Hotel 10.00 EUR 5.00 -5.00
4 2025-03-03 Total balance SEK 50.00 -50.00
5 2025-03-03 Total balance EUR 5.00 -5.00
+10
View File
@@ -0,0 +1,10 @@
Datum,Beskrivning,Kategori,Kostnad,Valuta,Robin Sample,Alex Example
2025-02-01,Matkasse,Livsmedel,200.00,SEK,-100.00,100.00
2025-02-02,"Lampa, golv",Möbler,80.00,SEK,40.00,-40.00
2025-02-03,Okänd sak,Påhittad kategori,10.00,SEK,5.00,-5.00
2025-02-04,Betalning till Alex,Betalning,55.00,SEK,55.00,-55.00
2025-02-05,Biljett,Buss/tåg,30.00,SEK,-15.00,15.00
2025-02-06,Totalsumma, , ,SEK,-15.00,15.00
1 Datum Beskrivning Kategori Kostnad Valuta Robin Sample Alex Example
2 2025-02-01 Matkasse Livsmedel 200.00 SEK -100.00 100.00
3 2025-02-02 Lampa, golv Möbler 80.00 SEK 40.00 -40.00
4 2025-02-03 Okänd sak Påhittad kategori 10.00 SEK 5.00 -5.00
5 2025-02-04 Betalning till Alex Betalning 55.00 SEK 55.00 -55.00
6 2025-02-05 Biljett Buss/tåg 30.00 SEK -15.00 15.00
7 2025-02-06 Totalsumma SEK -15.00 15.00
@@ -0,0 +1,4 @@
Date,Description,Category,Cost,Currency,Alex Example,Sam Other
2025-05-01,Fine row,Groceries,10.00,SEK,5.00,-5.00
2025-05-02,Total balance, , ,SEK,5.00,-5.00
1 Date Description Category Cost Currency Alex Example Sam Other
2 2025-05-01 Fine row Groceries 10.00 SEK 5.00 -5.00
3 2025-05-02 Total balance SEK 5.00 -5.00
+804
View File
@@ -0,0 +1,804 @@
//! Integration tests of `kvitt-import` (SPEC §10.8, FR-17 AC-17.1 to AC-17.6) over synthetic fixtures only, against
//! a temporary database built with the server's migrations. Member names, descriptions and amounts are made up.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use chrono::{DateTime, Utc};
use kvitt_import::amount::Rate;
use kvitt_import::categories::CategoryTable;
use kvitt_import::{ImportError, Options, Report, UserArg};
use kvitt_server::audit::SqlAuditWriter;
use kvitt_server::auth::sha256_hex;
use kvitt_server::db;
use kvitt_server::time::parse_ts;
use sqlx::SqlitePool;
use uuid::Uuid;
const OWNER: &str = "0192c000-0000-7000-8000-000000000001";
const PARTNER: &str = "0192c000-0000-7000-8000-000000000002";
const OWNER_NAME: &str = "Alex Example";
const PARTNER_NAME: &str = "Robin Sample";
const NOW: &str = "2026-10-17T12:00:00.000Z";
fn now() -> DateTime<Utc> {
parse_ts(NOW).unwrap()
}
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
/// A migrated database with the two synthetic users, removed on drop.
struct TestDb {
dir: PathBuf,
path: PathBuf,
}
impl Drop for TestDb {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
impl TestDb {
async fn new() -> Self {
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::migrate(&pool).await.unwrap();
for (id, role, name, version) in [
(OWNER, "admin", "Alex", 11),
(PARTNER, "member", "Robin", 12),
] {
sqlx::query(
"INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \
VALUES (?, ?, ?, NULL, '2026-10-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z', ?)",
)
.bind(id)
.bind(role)
.bind(name)
.bind(version)
.execute(&pool)
.await
.unwrap();
}
sqlx::query("UPDATE sync_sequence SET value = 12")
.execute(&pool)
.await
.unwrap();
pool.close().await;
Self { dir, path }
}
async fn pool(&self) -> SqlitePool {
db::connect(&self.path).await.unwrap()
}
async fn scalar(&self, sql: &str) -> i64 {
let pool = self.pool().await;
let value: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(sql.to_owned()))
.fetch_one(&pool)
.await
.unwrap();
pool.close().await;
value
}
/// Row counts of every table the importer may write, plus the sequence value.
async fn snapshot(&self) -> Vec<i64> {
let mut out = Vec::new();
for table in [
"accounts",
"account_default_shares",
"expenses",
"expense_splits",
"settlements",
"settlement_allocations",
"audit_log",
"sync_mutations",
] {
out.push(self.scalar(&format!("SELECT COUNT(*) FROM {table}")).await);
}
out.push(self.scalar("SELECT value FROM sync_sequence").await);
out
}
fn options(&self, file: &str, account: &str) -> Options {
Options {
db: self.path.clone(),
file: fixture(file),
account: account.to_owned(),
users: vec![
UserArg {
splitwise_name: OWNER_NAME.into(),
kvitt_user: OWNER.into(),
},
UserArg {
splitwise_name: PARTNER_NAME.into(),
kvitt_user: PARTNER.into(),
},
],
fx: BTreeMap::new(),
dry_run: false,
}
}
async fn import(&self, options: &Options) -> Result<Report, ImportError> {
kvitt_import::run(options, now(), &SqlAuditWriter).await
}
}
/// `(payer, split_mode, amount, owner share, partner share, owner bp, description, notes, category key, key)` of
/// every expense, by expense date and id.
type ExpenseLine = (
String,
String,
i64,
i64,
i64,
Option<i64>,
String,
String,
String,
String,
);
/// `(entity_type, action, source, actor_user_id, actor_device_id, client_at, server_at, changes)`.
type AuditLine = (
String,
String,
String,
String,
Option<String>,
String,
String,
String,
);
async fn expenses(db: &TestDb) -> Vec<ExpenseLine> {
let pool = db.pool().await;
let rows: Vec<ExpenseLine> = sqlx::query_as(
"SELECT e.payer_user_id, e.split_mode, e.amount_ore, so.share_ore, sp.share_ore, so.share_bp, \
e.description, e.notes, c.default_key, e.import_key FROM expenses e \
JOIN expense_splits so ON so.expense_id = e.id AND so.user_id = ?1 \
JOIN expense_splits sp ON sp.expense_id = e.id AND sp.user_id = ?2 \
JOIN categories c ON c.id = e.category_id ORDER BY e.expense_date, e.id",
)
.bind(OWNER)
.bind(PARTNER)
.fetch_all(&pool)
.await
.unwrap();
pool.close().await;
rows
}
#[tokio::test]
async fn english_export_imports_expenses_settlements_and_audit() {
let db = TestDb::new().await;
let report = db
.import(&db.options("english.csv", "Shared"))
.await
.unwrap();
assert!(report.committed);
assert!(report.account_created);
assert_eq!(report.rows_read, 10);
assert_eq!(report.totals_rows, 1);
assert_eq!(report.expenses_created, 9);
assert_eq!(report.settlements_created, 1);
assert_eq!(report.skipped_duplicates, 0);
assert_eq!(report.non_sek_rows, 0);
assert_eq!(
report.unmapped_categories,
BTreeMap::from([("Widgets".to_owned(), 1)])
);
assert_eq!(report.unmapped_rows(), 1);
assert!(report.reconciliation.matched);
assert_eq!(report.exit_code(), 0);
let summary = report.to_string();
assert!(summary.contains("Widgets: 1 rows"), "{summary}");
assert!(
summary.contains("WARNING: 1 rows with an unmapped category"),
"{summary}"
);
assert!(summary.contains("result: match"), "{summary}");
// The account: created by the owner with the default 50/50 ratio.
assert_eq!(
db.scalar("SELECT COUNT(*) FROM accounts WHERE name = 'Shared' AND created_by_user_id = '0192c000-0000-7000-8000-000000000001'")
.await,
1
);
assert_eq!(
db.scalar("SELECT COUNT(*) FROM account_default_shares WHERE share_bp = 5000")
.await,
2
);
let rows = expenses(&db).await;
assert_eq!(rows.len(), 9);
// Owner paid, exact split from the nets.
assert_eq!(
(
&rows[0].0[..],
&rows[0].1[..],
rows[0].2,
rows[0].3,
rows[0].4,
rows[0].5
),
(OWNER, "exact", 30000, 15000, 15000, None)
);
assert_eq!(rows[0].8, "groceries");
// Quoted description with a comma; partner paid.
assert_eq!(rows[1].6, "Paint, brushes and tape");
assert_eq!(
(&rows[1].0[..], rows[1].3, rows[1].4),
(PARTNER, 6025, 6025)
);
assert_eq!(rows[1].8, "home");
// Both nets 0: full on the owner.
assert_eq!(
(
&rows[2].0[..],
&rows[2].1[..],
rows[2].3,
rows[2].4,
rows[2].5
),
(OWNER, "full", 20000, 0, Some(10_000))
);
assert_eq!(rows[2].8, "entertainment");
// Partner paid all of it for the owner: payer share 0.
assert_eq!((&rows[3].0[..], rows[3].3, rows[3].4), (PARTNER, 10000, 0));
assert_eq!(rows[3].8, "transport");
// Unknown category to other.
assert_eq!(rows[4].8, "other");
// Two identical lines both imported, with different keys.
assert_eq!(rows[5].6, "Coffee");
assert_eq!(rows[6].6, "Coffee");
assert_ne!(rows[5].9, rows[6].9);
// Truncated description: 100 code points, the full text in notes.
assert_eq!(rows[7].6.chars().count(), 100);
assert!(rows[7].7.starts_with(&rows[7].6));
assert_eq!(rows[7].7.chars().count(), 127);
assert_eq!(rows[8].6, "Rent, January");
assert!(
rows.iter()
.all(|r| r.7.is_empty() || r.6.chars().count() == 100)
);
// The import key formula of SPEC §10.8, for the first row and for the second of the two identical lines.
let key = |line: &str, n: usize| {
let fields: Vec<&str> = line.split(',').collect();
sha256_hex(&format!("splitwise|v1|Shared|{}|{n}", fields.join("|")))
};
assert_eq!(
rows[0].9,
key(
"2025-01-05,Weekly shop,Groceries,300.00,SEK,150.00,-150.00",
0
)
);
assert_eq!(
rows[6].9,
key("2025-01-10,Coffee,Dining out,40.00,SEK,20.00,-20.00", 1)
);
// The payment: a settlement from the positive-net member, one allocation to the account.
let pool = db.pool().await;
let settlement: (String, String, i64, String, String, i64, i64) = sqlx::query_as(
"SELECT s.from_user_id, s.to_user_id, s.amount_ore, s.note, s.settlement_date, \
(SELECT COUNT(*) FROM settlement_allocations a WHERE a.settlement_id = s.id), \
(SELECT SUM(a.amount_ore) FROM settlement_allocations a JOIN accounts ac ON ac.id = a.account_id \
WHERE a.settlement_id = s.id AND ac.name = 'Shared') FROM settlements s",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
settlement,
(
PARTNER.to_owned(),
OWNER.to_owned(),
10000,
"Robin paid Alex".to_owned(),
"2025-01-12".to_owned(),
1,
10000
)
);
// Created rows: owner as creator and updater, no device, import time, server_received_at set.
for table in ["expenses", "settlements"] {
let odd: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
"SELECT COUNT(*) FROM {table} WHERE created_by_user_id <> ?1 OR updated_by_user_id <> ?1 \
OR updated_by_device_id IS NOT NULL OR created_at <> ?2 OR updated_at <> ?2 OR deleted_at IS NOT NULL \
OR import_key IS NULL OR length(import_key) <> 64"
)))
.bind(OWNER)
.bind(NOW)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(odd, 0, "{table}");
}
// One audit create per row, source import, actor the owner, no device, both times the import time, with
// the create diff of SPEC §14.4.7.
let audits: Vec<AuditLine> = sqlx::query_as(
"SELECT entity_type, action, source, actor_user_id, actor_device_id, client_at, server_at, changes \
FROM audit_log ORDER BY server_version",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(audits.len(), 10);
assert_eq!(audits.iter().filter(|a| a.0 == "expense").count(), 9);
assert_eq!(audits.iter().filter(|a| a.0 == "settlement").count(), 1);
for a in &audits {
assert_eq!((&a.1[..], &a.2[..], &a.3[..]), ("create", "import", OWNER));
assert_eq!(a.4, None);
assert_eq!((&a.5[..], &a.6[..]), (NOW, NOW));
let changes: serde_json::Value = serde_json::from_str(&a.7).unwrap();
assert!(
changes
.as_array()
.unwrap()
.iter()
.all(|c| c["old"].is_null())
);
}
let audited: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM audit_log a WHERE a.entity_id IN (SELECT id FROM expenses UNION SELECT id FROM settlements)",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(audited, 10);
// New server_versions from the global sequence: account + 9 expenses + 1 settlement + 10 audit entries.
let max_version: i64 = sqlx::query_scalar("SELECT value FROM sync_sequence")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(max_version, 12 + 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)
.await
.unwrap();
assert_eq!(mutations, 0);
pool.close().await;
}
#[tokio::test]
async fn swedish_export_with_reversed_member_columns() {
let db = TestDb::new().await;
let report = db
.import(&db.options("swedish.csv", "Vardagen"))
.await
.unwrap();
assert!(report.committed);
assert_eq!(report.language, kvitt_import::parse::Language::Swedish);
assert_eq!(report.rows_read, 5);
assert_eq!(report.expenses_created, 4);
assert_eq!(report.settlements_created, 1);
assert_eq!(
report.unmapped_categories,
BTreeMap::from([("Påhittad kategori".to_owned(), 1)])
);
assert!(report.reconciliation.matched);
// Members are matched by name: the second column is the owner.
assert_eq!(
report.reconciliation.members[0].splitwise_name,
PARTNER_NAME
);
assert_eq!(report.reconciliation.members[0].kvitt_ore, -1500);
assert_eq!(report.reconciliation.members[1].kvitt_ore, 1500);
let rows = expenses(&db).await;
assert_eq!(
(&rows[0].0[..], rows[0].3, rows[0].4),
(OWNER, 10000, 10000)
);
assert_eq!(rows[0].8, "groceries");
assert_eq!(rows[1].6, "Lampa, golv");
assert_eq!(
(&rows[1].0[..], rows[1].3, rows[1].4),
(PARTNER, 4000, 4000)
);
assert_eq!(rows[1].8, "home");
assert_eq!(rows[2].8, "other");
assert_eq!(rows[3].8, "transport");
let (from, to, amount): (String, String, i64) =
sqlx::query_as("SELECT from_user_id, to_user_id, amount_ore FROM settlements")
.fetch_one(&db.pool().await)
.await
.unwrap();
assert_eq!((&from[..], &to[..], amount), (PARTNER, OWNER, 5500));
}
#[tokio::test]
async fn rerun_creates_nothing_and_existing_account_is_reused() {
let db = TestDb::new().await;
let first = db
.import(&db.options("english.csv", "Shared"))
.await
.unwrap();
assert!(first.committed);
let before = db.snapshot().await;
// Same file, account name in another case: the existing account, every row skipped.
let second = db
.import(&db.options("english.csv", "SHARED"))
.await
.unwrap();
assert!(second.committed);
assert_eq!(second.account_name, "Shared");
assert!(!second.account_created);
assert_eq!(second.account_id, first.account_id);
assert_eq!(second.expenses_created, 0);
assert_eq!(second.settlements_created, 0);
assert_eq!(second.skipped_duplicates, 10);
assert!(second.reconciliation.matched);
assert_eq!(db.snapshot().await, before);
// A second file goes into a second account; the account name is part of the key.
let other = db
.import(&db.options("swedish.csv", "Other group"))
.await
.unwrap();
assert!(other.account_created);
assert_eq!(other.expenses_created, 4);
}
#[tokio::test]
async fn existing_account_matched_case_insensitively() {
let db = TestDb::new().await;
let first = db
.import(&db.options("swedish.csv", "Vardag"))
.await
.unwrap();
let second = db
.import(&db.options("english.csv", " vardag "))
.await
.unwrap();
assert!(!second.account_created);
assert_eq!(second.account_id, first.account_id);
// The balance now holds both files, so it no longer equals the second file's totals: rolled back.
assert!(!second.reconciliation.matched);
assert!(!second.committed);
assert_eq!(second.exit_code(), kvitt_import::EXIT_MISMATCH);
assert_eq!(db.scalar("SELECT COUNT(*) FROM expenses").await, 4);
}
#[tokio::test]
async fn dry_run_writes_nothing_and_reports_the_same_counts() {
let db = TestDb::new().await;
let before = db.snapshot().await;
let mut options = db.options("english.csv", "Shared");
options.dry_run = true;
let report = db.import(&options).await.unwrap();
assert!(report.dry_run);
assert!(!report.committed);
assert_eq!(report.expenses_created, 9);
assert_eq!(report.settlements_created, 1);
assert_eq!(report.unmapped_rows(), 1);
assert!(report.reconciliation.matched);
assert_eq!(report.exit_code(), 0);
assert!(report.to_string().contains("dry run, nothing written"));
assert_eq!(db.snapshot().await, before);
// After a real import, a dry run reports every row as a duplicate.
options.dry_run = false;
db.import(&options).await.unwrap();
options.dry_run = true;
let again = db.import(&options).await.unwrap();
assert_eq!(again.skipped_duplicates, 10);
assert_eq!(again.expenses_created, 0);
}
#[tokio::test]
async fn invalid_row_aborts_with_nothing_written() {
let db = TestDb::new().await;
let before = db.snapshot().await;
match db.import(&db.options("invalid_nets.csv", "Shared")).await {
Err(ImportError::InvalidRows(problems)) => {
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].line, 4);
assert!(
problems[0].reason.contains("sum to 0"),
"{}",
problems[0].reason
);
}
other => panic!("{other:?}"),
}
// A row the server's checks reject (an impossible date) also aborts the whole file.
match db.import(&db.options("invalid_date.csv", "Shared")).await {
Err(e @ ImportError::InvalidRows(_)) => {
assert_eq!(e.exit_code(), kvitt_import::EXIT_INVALID);
let ImportError::InvalidRows(problems) = e else {
unreachable!()
};
assert_eq!(problems.len(), 1);
assert_eq!(problems[0].line, 4);
assert!(
problems[0].reason.starts_with("validation_failed"),
"{}",
problems[0].reason
);
}
other => panic!("{other:?}"),
}
assert_eq!(db.snapshot().await, before);
}
#[tokio::test]
async fn header_failures_write_nothing() {
let db = TestDb::new().await;
let before = db.snapshot().await;
for file in ["bad_header.csv", "wrong_member.csv"] {
match db.import(&db.options(file, "Shared")).await {
Err(e @ ImportError::Header(_)) => {
assert_eq!(e.exit_code(), kvitt_import::EXIT_INVALID)
}
other => panic!("{file}: {other:?}"),
}
}
assert_eq!(db.snapshot().await, before);
}
#[tokio::test]
async fn non_sek_aborts_without_fx_and_converts_with_it() {
let db = TestDb::new().await;
let before = db.snapshot().await;
let mut options = db.options("non_sek.csv", "Trip");
match db.import(&options).await {
Err(ImportError::MissingFx(currencies)) => {
// One data row and one totals row in EUR.
assert_eq!(currencies, BTreeMap::from([("EUR".to_owned(), 2)]));
}
other => panic!("{other:?}"),
}
assert_eq!(db.snapshot().await, before);
options
.fx
.insert("EUR".into(), Rate::parse("11.5").unwrap());
let report = db.import(&options).await.unwrap();
assert!(report.committed);
assert_eq!(report.non_sek_rows, 1);
assert_eq!(report.expenses_created, 2);
assert!(report.reconciliation.matched);
let rows = expenses(&db).await;
// 10.00 EUR × 11.5 = 115.00 SEK; the partner's share 5.00 EUR = 57.50 SEK; the payer's share is the rest.
assert_eq!((rows[1].2, rows[1].3, rows[1].4), (11500, 5750, 5750));
}
#[tokio::test]
async fn reconciliation_mismatch_or_missing_totals_rolls_back() {
let db = TestDb::new().await;
let before = db.snapshot().await;
let report = db
.import(&db.options("mismatch.csv", "Shared"))
.await
.unwrap();
assert!(!report.reconciliation.matched);
assert!(!report.committed);
assert_eq!(report.exit_code(), kvitt_import::EXIT_MISMATCH);
let summary = report.to_string();
assert!(summary.contains("splitwise 7.00, kvitt 5.00"), "{summary}");
assert!(summary.contains("result: MISMATCH"), "{summary}");
assert_eq!(db.snapshot().await, before);
let report = db
.import(&db.options("no_totals.csv", "Shared"))
.await
.unwrap();
assert!(!report.reconciliation.has_totals);
assert!(!report.committed);
assert_eq!(report.exit_code(), kvitt_import::EXIT_MISMATCH);
assert_eq!(db.snapshot().await, before);
}
#[tokio::test]
async fn users_resolve_by_display_name_and_must_be_the_household() {
let db = TestDb::new().await;
let mut options = db.options("english.csv", "Shared");
options.users[0].kvitt_user = "Alex".into();
options.users[1].kvitt_user = "Robin".into();
let report = db.import(&options).await.unwrap();
assert!(report.committed);
assert_eq!(report.reconciliation.members[0].user_id, OWNER);
let mut both_same = db.options("english.csv", "Other");
both_same.users[1].kvitt_user = OWNER.into();
assert!(matches!(
db.import(&both_same).await,
Err(ImportError::Usage(_))
));
let mut unknown = db.options("english.csv", "Other");
unknown.users[1].kvitt_user = "Nobody".into();
assert!(matches!(
db.import(&unknown).await,
Err(ImportError::Usage(_))
));
let mut one = db.options("english.csv", "Other");
one.users.pop();
assert!(matches!(db.import(&one).await, Err(ImportError::Usage(_))));
}
#[tokio::test]
async fn missing_database_is_not_created() {
let db = TestDb::new().await;
let mut options = db.options("english.csv", "Shared");
options.db = db.dir.join("absent.sqlite");
assert!(matches!(db.import(&options).await, Err(ImportError::Io(_))));
assert!(!options.db.exists());
}
/// 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]
fn category_table_matches_the_spec() {
let spec =
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../SPEC.md"))
.unwrap();
let section = spec.split("### 10.8 Splitwise import").nth(1).unwrap();
let section = section.split("\n### ").next().unwrap();
let mut expected: Vec<(String, String, String)> = Vec::new();
for line in section.lines() {
let cells: Vec<&str> = line
.trim()
.trim_matches('|')
.split('|')
.map(str::trim)
.collect();
if cells.len() != 3 || !cells[2].starts_with('`') || cells[0].starts_with("any other") {
continue;
}
let key = cells[2].trim_matches('`').to_owned();
for (column, language) in [(cells[0], "en"), (cells[1], "sv")] {
for name in column.split(", ") {
expected.push((name.to_owned(), language.to_owned(), key.clone()));
}
}
}
assert!(
expected.len() > 60,
"parsed {} names from the spec",
expected.len()
);
let table = CategoryTable::committed().unwrap();
let mut actual: Vec<(String, String, String)> = table
.entries
.iter()
.map(|e| {
(
e.splitwise_name.clone(),
e.language.clone(),
e.default_key.clone(),
)
})
.collect();
expected.sort();
actual.sort();
assert_eq!(actual, expected);
}
fn binary() -> Command {
Command::new(env!("CARGO_BIN_EXE_kvitt-import"))
}
fn cli_args(db: &TestDb, file: &str, account: &str) -> Vec<String> {
vec![
"--db".into(),
db.path.to_string_lossy().into_owned(),
"--file".into(),
fixture(file).to_string_lossy().into_owned(),
"--account".into(),
account.into(),
"--user".into(),
format!("{OWNER_NAME}={OWNER}"),
"--user".into(),
format!("{PARTNER_NAME}=Robin"),
]
}
#[test]
fn cli_help_and_usage_errors() {
let help = binary().arg("--help").output().unwrap();
assert!(help.status.success());
let text = String::from_utf8_lossy(&help.stdout);
for option in [
"--db",
"--file",
"--account",
"--user",
"--fx",
"--dry-run",
"Exit status",
] {
assert!(text.contains(option), "{option}: {text}");
}
let missing = binary().args(["--db", "x"]).output().unwrap();
assert_eq!(missing.status.code(), Some(2));
let bad_fx = binary()
.args([
"--db",
"x",
"--file",
"y",
"--account",
"A",
"--user",
"a=b",
"--user",
"c=d",
"--fx",
"EUR",
])
.output()
.unwrap();
assert_eq!(bad_fx.status.code(), Some(2));
}
#[tokio::test]
async fn cli_end_to_end_exit_codes() {
let db = TestDb::new().await;
let mut dry = cli_args(&db, "swedish.csv", "Vardag");
dry.push("--dry-run".into());
let out = binary().args(&dry).output().unwrap();
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8_lossy(&out.stdout).contains("dry run"));
assert_eq!(db.scalar("SELECT COUNT(*) FROM expenses").await, 0);
let out = binary()
.args(cli_args(&db, "swedish.csv", "Vardag"))
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("expenses created: 4"), "{stdout}");
assert!(stdout.contains("result: match"), "{stdout}");
assert_eq!(db.scalar("SELECT COUNT(*) FROM expenses").await, 4);
assert_eq!(
db.scalar("SELECT COUNT(*) FROM audit_log WHERE source = 'import'")
.await,
5
);
let out = binary()
.args(cli_args(&db, "invalid_nets.csv", "Other"))
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
assert!(String::from_utf8_lossy(&out.stderr).contains("line 4"));
let out = binary()
.args(cli_args(&db, "mismatch.csv", "Other"))
.output()
.unwrap();
assert_eq!(out.status.code(), Some(4));
let out = binary()
.args(cli_args(&db, "bad_header.csv", "Other"))
.output()
.unwrap();
assert_eq!(out.status.code(), Some(3));
assert_eq!(db.scalar("SELECT COUNT(*) FROM accounts").await, 1);
}