"""Tests for src.cli_reconcile — Typer CLI for two-source reconciliation. The reconciliation engine itself is covered by ``test_reconcile.py``; this file exercises the CLI surface around it: argument parsing (comma-separated keys, optional dates), preview vs. apply modes, the four output files, and error paths for bad inputs. """ from __future__ import annotations import sys from pathlib import Path import pandas as pd import pytest from typer.testing import CliRunner from src.cli_reconcile import app runner = CliRunner() def _write_bank(path: Path) -> None: """Bank-feed-shaped CSV with two transactions.""" path.write_text( "date,amount,desc\n" "2026-01-05,100.00,ACME\n" "2026-01-06,250.00,WIDGET CO\n" ) def _write_ledger(path: Path) -> None: """Ledger-shaped CSV with the same two transactions under different column names — exercises the rename-on-match path.""" path.write_text( "posted,amt,memo\n" "2026-01-05,100.00,Acme Inc\n" "2026-01-06,250.00,Widget\n" ) class TestPreviewMode: """Default mode (no ``--apply``): print stats only, write nothing.""" def test_basic_preview_succeeds(self, tmp_path): bank = tmp_path / "bank.csv" ledger = tmp_path / "ledger.csv" _write_bank(bank) _write_ledger(ledger) result = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", ]) assert result.exit_code == 0, result.stdout assert "Matched:" in result.stdout assert "Unmatched left:" in result.stdout # Two-of-two match in the fixture. assert "Matched: 2" in result.stdout # The reminder banner is part of the preview UX. assert "Add --apply" in result.stdout def test_preview_does_not_write_files(self, tmp_path): bank = tmp_path / "bank.csv" ledger = tmp_path / "ledger.csv" _write_bank(bank) _write_ledger(ledger) runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", ]) # None of the four output suffixes should land beside the input. for suffix in ("matched", "unmatched_left", "unmatched_right", "review"): assert not (tmp_path / f"bank_{suffix}.csv").exists() class TestApplyMode: """``--apply``: write the four output files beside the LEFT input.""" def test_apply_writes_four_files(self, tmp_path): bank = tmp_path / "bank.csv" ledger = tmp_path / "ledger.csv" _write_bank(bank) _write_ledger(ledger) result = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", "--apply", ]) assert result.exit_code == 0, result.stdout # All four output files land beside the left input, sharing # its stem. for suffix in ("matched", "unmatched_left", "unmatched_right", "review"): out = tmp_path / f"bank_{suffix}.csv" assert out.exists(), f"missing {out.name}" # Matched.csv carries the two pairs. matched = pd.read_csv(tmp_path / "bank_matched.csv") assert len(matched) == 2 def test_apply_with_unmatched_rows(self, tmp_path): bank = tmp_path / "bank.csv" ledger = tmp_path / "ledger.csv" bank.write_text( "date,amount,desc\n" "2026-01-05,100.00,ACME\n" "2026-01-07,99.99,LEFT-ONLY\n" ) ledger.write_text( "posted,amt,memo\n" "2026-01-05,100.00,Acme\n" "2026-01-08,500.00,RIGHT-ONLY\n" ) result = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", "--apply", ]) assert result.exit_code == 0 unmatched_l = pd.read_csv(tmp_path / "bank_unmatched_left.csv") unmatched_r = pd.read_csv(tmp_path / "bank_unmatched_right.csv") assert "LEFT-ONLY" in unmatched_l["desc"].tolist() assert "RIGHT-ONLY" in unmatched_r["memo"].tolist() class TestToleranceFlags: def test_amount_tolerance_absorbs_rounding(self, tmp_path): bank = tmp_path / "bank.csv" ledger = tmp_path / "ledger.csv" bank.write_text( "date,amount,desc\n" "2026-01-05,100.00,ACME\n" ) ledger.write_text( "posted,amt,memo\n" "2026-01-05,100.02,Acme\n" ) # Without tolerance: no match. result_no_tol = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", ]) assert "Matched: 0" in result_no_tol.stdout # With tolerance: one match. result_with_tol = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", "--amount-tolerance", "0.05", ]) assert "Matched: 1" in result_with_tol.stdout def test_date_tolerance_allows_drift(self, tmp_path): bank = tmp_path / "bank.csv" ledger = tmp_path / "ledger.csv" bank.write_text( "date,amount,desc\n" "2026-01-05,100.00,ACME\n" ) ledger.write_text( "posted,amt,memo\n" "2026-01-07,100.00,Acme\n" # 2-day drift ) result = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", "--date-tolerance", "3", ]) assert "Matched: 1" in result.stdout class TestSignInversion: def test_invert_right_sign(self, tmp_path): bank = tmp_path / "bank.csv" ledger = tmp_path / "ledger.csv" bank.write_text( "date,amount,desc\n" "2026-01-05,100.00,ACME\n" ) ledger.write_text( "posted,amt,memo\n" "2026-01-05,-100.00,Acme\n" # sign convention flipped ) result = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", "--invert-right-sign", ]) assert "Matched: 1" in result.stdout class TestKeyFlags: def test_comma_separated_keys_pair_off(self, tmp_path): # Same check number, mismatched posting dates — the date-only # pass would miss but the key match catches. bank = tmp_path / "bank.csv" ledger = tmp_path / "ledger.csv" bank.write_text( "date,amount,desc,check_no\n" "2026-01-05,100.00,ACME,1042\n" ) ledger.write_text( "posted,amt,memo,ref\n" "2026-01-12,100.00,Acme,1042\n" # 7-day drift ) result = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", "--left-keys", "check_no", "--right-keys", "ref", ]) assert "Matched: 1" in result.stdout class TestErrorPaths: def test_missing_left_file(self, tmp_path): ledger = tmp_path / "ledger.csv" _write_ledger(ledger) result = runner.invoke(app, [ str(tmp_path / "nope.csv"), str(ledger), "--left-amount", "amount", "--right-amount", "amt", ]) assert result.exit_code != 0 assert "not found" in result.stdout.lower() or "not found" in (result.stderr or "").lower() def test_missing_right_file(self, tmp_path): bank = tmp_path / "bank.csv" _write_bank(bank) result = runner.invoke(app, [ str(bank), str(tmp_path / "nope.csv"), "--left-amount", "amount", "--right-amount", "amt", ]) assert result.exit_code != 0 def test_unknown_amount_column_surfaces_value_error(self, tmp_path): # The reconcile engine raises ValueError on unknown column names; # the CLI catches it and exits 1 with a readable banner. bank = tmp_path / "bank.csv" ledger = tmp_path / "ledger.csv" _write_bank(bank) _write_ledger(ledger) result = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "NOT_A_COLUMN", "--right-amount", "amt", ]) assert result.exit_code == 1 # Banner format: "Error: " assert "Error" in result.stdout or "Error" in (result.stderr or "") def test_help_renders(self): # ``--help`` must work — examples in docstrings reference it. result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 assert "reconcile" in result.stdout.lower() class TestExcelInput: """Input may be CSV, TSV, or Excel — read_file dispatches by suffix.""" def test_excel_left_file_reads(self, tmp_path): bank = tmp_path / "bank.xlsx" df = pd.DataFrame({ "date": ["2026-01-05"], "amount": [100.00], "desc": ["ACME"], }) df.to_excel(bank, index=False) ledger = tmp_path / "ledger.csv" _write_ledger(ledger) result = runner.invoke(app, [ str(bank), str(ledger), "--left-amount", "amount", "--right-amount", "amt", "--left-date", "date", "--right-date", "posted", ]) assert result.exit_code == 0, result.stdout # 1 of 1 left rows matched against the 2-row right ledger. assert "Matched: 1" in result.stdout