@@ -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 ) ;
}