c942d73c66
Taken from a -W clippy::pedantic scan, no allow added: map_or/map_or_else, method references instead of closures, clone_from, write! instead of push_str(format!), borrowed Option<&str>, push_of without a needless Option, the envelope's entity moved instead of cloned, the scheduler's plan() no longer a needless async fn (its doc now says what it does on failure), sort_unstable, digit separators and inline format args in tests. Not taken: missing # Errors/# Panics docs, must_use, doc backticks, assert!(x.is_empty()) in tests, too_many_lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
885 lines
29 KiB
Rust
885 lines
29 KiB
Rust
//! 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", 15),
|
||
(PARTNER, "member", "Robin", 16),
|
||
] {
|
||
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();
|
||
}
|
||
// 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();
|
||
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");
|
||
// 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)
|
||
);
|
||
|
||
// 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, 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)
|
||
.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());
|
||
}
|
||
|
||
/// 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(&format!("expected {}", db::SCHEMA_VERSION)),
|
||
"{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]
|
||
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);
|
||
}
|