Files
kvitt/server/crates/kvitt-import/tests/import.rs
T
jacobh2 c942d73c66 Server overhaul: clear clippy::pedantic wins
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
2026-10-18 22:43:54 +02:00

885 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}