//! 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 { 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 { 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 { 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, 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, ); async fn expenses(db: &TestDb) -> Vec { let pool = db.pool().await; let rows: Vec = 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 = 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("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] 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 { 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); }