use std::io::BufRead;
use std::process::{Child, Command, Stdio};
use fints::protocol::*;
use fints::{Blz, UserId, Pin, ProductId, SegmentType};
fn spawn_mock_server() -> Option<(Child, u16)> {
let mock_script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("mock_server.py");
if !mock_script.exists() {
eprintln!("Mock server script not found at {:?}", mock_script);
return None;
}
let mut child = Command::new("python3")
.arg(&mock_script)
.arg("0")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
let stdout = child.stdout.take()?;
let mut reader = std::io::BufReader::new(stdout);
let mut line = String::new();
reader.read_line(&mut line).ok()?;
if !line.starts_with("READY:") {
eprintln!("Unexpected mock server output: {}", line);
child.kill().ok();
return None;
}
let port: u16 = line.trim().strip_prefix("READY:")?.parse().ok()?;
std::thread::sleep(std::time::Duration::from_millis(100));
Some((child, port))
}
struct MockServerGuard(Child);
impl Drop for MockServerGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn mock_url(port: u16) -> String {
format!("http://127.0.0.1:{}/", port)
}
fn mock_dialog(port: u16) -> Dialog<New> {
Dialog::new(
&mock_url(port),
&Blz::new("12345678"),
&UserId::new("test1"),
&Pin::new("1234"),
&ProductId::new("TEST-123"),
).unwrap()
}
#[tokio::test]
async fn test_sync_dialog() {
let (child, port) = match spawn_mock_server() {
Some(v) => v,
None => { eprintln!("Skipping: python3 not available"); return; }
};
let _guard = MockServerGuard(child);
let dialog = mock_dialog(port);
let (synced, response) = dialog.sync().await.expect("sync() failed");
assert!(synced.system_id().is_assigned(), "system_id should be assigned, got: {}", synced.system_id());
assert!(synced.bank_params().bpd_version > 0, "BPD version should be > 0");
assert!(!synced.bank_params().tan_methods.is_empty(), "Should have TAN methods");
assert!(!synced.bank_params().accounts_from_upd.is_empty(), "Should have accounts from UPD");
let has_test = synced.bank_params().accounts_from_upd.iter().any(|a| a.iban.as_str() == "DE111234567800000001");
assert!(has_test, "Should have test account DE111234567800000001");
assert!(!synced.bank_params().operation_tan_required.is_empty(), "HIPINS should be parsed");
assert_eq!(synced.bank_params().needs_tan(&SegmentType::new("HKSAL")), false, "HKSAL should not need TAN");
assert_eq!(synced.bank_params().needs_tan(&SegmentType::new("HKKAZ")), false, "HKKAZ should not need TAN");
assert!(response.all_codes().any(|c| c.is_success()), "Should have success code");
let (params, sys_id) = synced.end().await.expect("end() failed");
assert!(sys_id.is_assigned());
assert!(params.bpd_version > 0);
}
#[tokio::test]
async fn test_init_no_tan_then_business_ops() {
let (child, port) = match spawn_mock_server() {
Some(v) => v,
None => { eprintln!("Skipping: python3 not available"); return; }
};
let _guard = MockServerGuard(child);
let sync_dialog = mock_dialog(port);
let (synced, _) = sync_dialog.sync().await.expect("sync failed");
let (params, sys_id) = synced.end().await.expect("end failed");
let dialog = mock_dialog(port)
.with_system_id(&sys_id)
.with_params(¶ms);
let (mut open, _resp) = dialog.init_no_tan().await.expect("init_no_tan failed");
let account = Account::new("DE111234567800000001", "GENODE23X42").unwrap();
let result = open.balance(&account).await.expect("balance() failed");
match result {
BalanceResult::Success(balance) => {
assert_eq!(balance.amount.to_string(), "1523.42");
assert_eq!(balance.currency.as_str(), "EUR");
}
BalanceResult::NeedTan(_) => panic!("Unexpected TAN requirement"),
BalanceResult::Empty => panic!("No balance data"),
}
open.end().await.expect("end failed");
}
#[tokio::test]
async fn test_transactions_with_pagination() {
let (child, port) = match spawn_mock_server() {
Some(v) => v,
None => { eprintln!("Skipping: python3 not available"); return; }
};
let _guard = MockServerGuard(child);
let sync_dialog = mock_dialog(port);
let (synced, _) = sync_dialog.sync().await.expect("sync failed");
let (params, sys_id) = synced.end().await.expect("end failed");
let dialog = mock_dialog(port).with_system_id(&sys_id).with_params(¶ms);
let (mut open, _) = dialog.init_no_tan().await.expect("init failed");
let account = Account::new("DE111234567800000001", "GENODE23X42").unwrap();
let start = chrono::NaiveDate::from_ymd_opt(2015, 1, 1).unwrap();
let end_date = chrono::NaiveDate::from_ymd_opt(2015, 12, 31).unwrap();
let mut all_mt940_booked: Vec<u8> = Vec::new();
let mut touchdown: Option<fints::TouchdownPoint> = None;
loop {
let result = open.transactions(
&account, start, end_date, touchdown.as_ref(),
).await.expect("transactions() failed");
match result {
TransactionResult::NeedTan(_) => panic!("Unexpected TAN requirement"),
TransactionResult::Success(page) => {
if !page.booked.is_empty() {
if !all_mt940_booked.is_empty() { all_mt940_booked.extend_from_slice(b"\r\n"); }
all_mt940_booked.extend_from_slice(page.booked.as_bytes());
}
touchdown = page.touchdown;
if touchdown.is_none() { break; }
}
}
}
assert!(!all_mt940_booked.is_empty(), "Should have MT940 data");
let (cow, _, _) = encoding_rs::WINDOWS_1252.decode(&all_mt940_booked);
let cleaned: String = cow.lines()
.filter(|l| { let t = l.trim(); !t.is_empty() && t != "-" && t != "--" })
.collect::<Vec<_>>().join("\r\n") + "\r\n";
let sanitized = mt940::sanitizers::to_swift_charset(&cleaned);
let messages = mt940::parse_mt940(&sanitized).expect("MT940 parse failed");
let tx_count: usize = messages.iter().map(|m| m.statement_lines.len()).sum();
assert_eq!(tx_count, 3, "Expected 3 transactions across pagination, got {}", tx_count);
let first = &messages[0].statement_lines[0];
assert_eq!(first.amount.to_string(), "182.34");
open.end().await.ok();
}
#[tokio::test]
async fn test_wrong_pin() {
let (child, port) = match spawn_mock_server() {
Some(v) => v,
None => { eprintln!("Skipping: python3 not available"); return; }
};
let _guard = MockServerGuard(child);
let dialog = Dialog::new(
&mock_url(port),
&Blz::new("12345678"),
&UserId::new("test1"),
&Pin::new("wrong_pin"),
&ProductId::new("TEST-123"),
).unwrap();
let result = dialog.sync().await;
assert!(result.is_err(), "sync() should fail with wrong PIN");
let err = result.unwrap_err().to_string();
assert!(
err.contains("PIN") || err.contains("9910") || err.contains("Bank error"),
"Error should indicate PIN problem, got: {}", err
);
}
#[tokio::test]
async fn test_sepa_accounts_from_upd() {
let (child, port) = match spawn_mock_server() {
Some(v) => v,
None => { eprintln!("Skipping: python3 not available"); return; }
};
let _guard = MockServerGuard(child);
let dialog = mock_dialog(port);
let (synced, _) = dialog.sync().await.expect("sync failed");
let accounts = &synced.bank_params().accounts_from_upd;
assert!(!accounts.is_empty(), "Should have at least one account from UPD");
assert!(
accounts.iter().any(|a| a.iban.as_str() == "DE111234567800000001"),
"Should have test account, got: {:?}",
accounts.iter().map(|a| &a.iban).collect::<Vec<_>>()
);
synced.end().await.ok();
}
#[tokio::test]
async fn test_hipins_parsed() {
let (child, port) = match spawn_mock_server() {
Some(v) => v,
None => { eprintln!("Skipping: python3 not available"); return; }
};
let _guard = MockServerGuard(child);
let dialog = mock_dialog(port);
let (synced, _) = dialog.sync().await.expect("sync failed");
let params = synced.bank_params();
assert!(!params.operation_tan_required.is_empty(), "HIPINS should be parsed");
assert_eq!(params.needs_tan(&SegmentType::new("HKSAL")), false);
assert_eq!(params.needs_tan(&SegmentType::new("HKKAZ")), false);
assert_eq!(params.needs_tan(&SegmentType::new("HKSPA")), false);
assert_eq!(params.needs_tan(&SegmentType::new("HKCCS")), true);
synced.end().await.ok();
}
fn spawn_rust_mock_server() -> Option<(std::process::Child, u16)> {
let bin = assert_cmd::cargo::cargo_bin("fints-server");
if !bin.exists() {
return None;
}
let mut child = std::process::Command::new(&bin)
.args(["--port", "0", "--print-ready", "--tan-mode", "none"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
use std::io::BufRead;
let stdout = child.stdout.take()?;
let mut reader = std::io::BufReader::new(stdout);
let mut line = String::new();
reader.read_line(&mut line).ok()?;
let port: u16 = line.trim().strip_prefix("READY:")?.parse().ok()?;
std::thread::sleep(std::time::Duration::from_millis(100));
Some((child, port))
}
#[tokio::test]
async fn test_rust_server_sync_dialog() {
let (child, port) = match spawn_rust_mock_server() {
Some(v) => v,
None => {
eprintln!("Skipping: fints-server not built");
return;
}
};
let _guard = MockServerGuard(child);
let dialog = mock_dialog(port);
let (synced, _) = dialog.sync().await.expect("sync failed against Rust server");
assert!(synced.system_id().is_assigned());
assert!(synced.bank_params().bpd_version > 0);
synced.end().await.ok();
}
#[tokio::test]
async fn test_rust_server_balance() {
let (child, port) = match spawn_rust_mock_server() {
Some(v) => v,
None => {
eprintln!("Skipping: fints-server not built");
return;
}
};
let _guard = MockServerGuard(child);
let sync_dialog = mock_dialog(port);
let (synced, _) = sync_dialog.sync().await.expect("sync failed");
let (params, sys_id) = synced.end().await.expect("end failed");
let dialog = mock_dialog(port).with_system_id(&sys_id).with_params(¶ms);
let (mut open, _) = dialog.init_no_tan().await.expect("init failed");
let account = Account::new("DE111234567800000001", "GENODE23X42").unwrap();
let result = open.balance(&account).await.expect("balance failed");
match result {
BalanceResult::Success(balance) => {
assert_eq!(balance.amount.to_string(), "1523.42");
}
_ => panic!("Expected balance success"),
}
open.end().await.ok();
}