Files
FargoImport/test_import.py
T

370 lines
14 KiB
Python

"""Tests for the YNAB import helpers in main.py."""
import csv
import tempfile
from pathlib import Path
from types import SimpleNamespace
from category_suggester import CategoryHistory, normalize_payee
from main import (
_wf_to_ynab_millis, _build_import_ids, _convert_wf_to_ynab_transactions,
_ImportClassification, _apply_category_suggestions, _classify_for_import,
_execute_import, _format_amount_dollars, _load_category_history,
_preview_import,
_write_transactions_csv,
)
def test_wf_to_ynab_millis():
# Credit card charge (no debitCreditType) -> outflow (negative)
assert _wf_to_ynab_millis({"transactionAmount": 9.11}) == -9110
# Credit card refund (debitCreditType=CREDIT) -> inflow (positive)
assert _wf_to_ynab_millis({"transactionAmount": 1.64, "debitCreditType": "CREDIT"}) == 1640
# Debit card charge (debitCreditType=DEBIT) -> outflow (negative)
assert _wf_to_ynab_millis({"transactionAmount": 194.32, "debitCreditType": "DEBIT"}) == -194320
# Debit card credit (debitCreditType=CREDIT) -> inflow (positive)
assert _wf_to_ynab_millis({"transactionAmount": 225.0, "debitCreditType": "CREDIT"}) == 225000
# Pending (no debitCreditType) -> outflow (negative)
assert _wf_to_ynab_millis({"transactionAmount": 2.48}) == -2480
print("test_wf_to_ynab_millis: PASS")
def test_build_import_ids():
amounts = [-9110, -9110, -5000]
dates = ["2026-03-18", "2026-03-18", "2026-03-17"]
ids = _build_import_ids(amounts, dates)
assert ids == [
"YNAB:-9110:2026-03-18:1",
"YNAB:-9110:2026-03-18:2",
"YNAB:-5000:2026-03-17:1",
], f"Got {ids}"
print("test_build_import_ids: PASS")
def test_format_amount_dollars():
assert _format_amount_dollars(-9110) == "$-9.11"
assert _format_amount_dollars(225000) == "$+225.00"
assert _format_amount_dollars(-194320) == "$-194.32"
print("test_format_amount_dollars: PASS")
def test_convert_wf_to_ynab_transactions():
wf_txns = [
{
"transactionAmount": 9.11,
"transactionDate": 1773817200000,
"transactionDescription": "WALGREENS #10276 MORRISVILLE NC",
"status": "POSTED",
},
{
"transactionAmount": 2.48,
"transactionDate": 1774076400000,
"transactionDescription": "CULVERS",
"status": "PENDING",
},
]
result = _convert_wf_to_ynab_transactions(wf_txns, "acct-123")
assert result[0]["amount"] == -9110
assert result[0]["cleared"] == "cleared"
assert result[0]["flag_color"] == "blue"
assert result[0]["account_id"] == "acct-123"
assert result[0]["payee_name"] == "WALGREENS #10276 MORRISVILLE NC"
assert "YNAB:-9110:" in result[0]["import_id"]
assert result[1]["amount"] == -2480
assert result[1]["cleared"] == "uncleared"
assert result[1]["flag_color"] == "blue"
print("test_convert_wf_to_ynab_transactions: PASS")
def test_classify_for_import():
wf_txns = [
{
"transactionAmount": 9.11,
"transactionDate": 1773817200000,
"transactionDescription": "WALGREENS #10276",
"status": "POSTED",
},
{
"transactionAmount": 2.48,
"transactionDate": 1774076400000,
"transactionDescription": "CULVERS",
"status": "PENDING",
},
{
"transactionAmount": 50.00,
"transactionDate": 1773817200000,
"transactionDescription": "NEW TXN",
"status": "POSTED",
},
]
ynab_ready = _convert_wf_to_ynab_transactions(wf_txns, "acct-123")
# Derive dates to build matching YNAB transactions
from main import _wf_date_to_iso
date0 = _wf_date_to_iso(1773817200000)
date1 = _wf_date_to_iso(1774076400000)
ynab_txns = [
# Uncleared txn matching posted WALGREENS -> pending_to_posted
{
"id": "ynab-1", "date": date0, "payee_name": "WALGREENS",
"category_name": None, "amount_milliCurrency": -9110, "memo": None,
"account_id": "acct-123", "account_name": "Credit", "cleared": "uncleared",
"import_id": "YNAB:-9110:" + date0 + ":1",
},
# Cleared txn with same import_id as CULVERS -> duplicate
{
"id": "ynab-2", "date": date1, "payee_name": "CULVERS",
"category_name": None, "amount_milliCurrency": -2480, "memo": None,
"account_id": "acct-123", "account_name": "Credit", "cleared": "cleared",
"import_id": "YNAB:-2480:" + date1 + ":1",
},
]
classification = _classify_for_import(ynab_ready, ynab_txns)
assert len(classification.pending_to_posted) == 1
assert classification.pending_to_posted[0][1]["id"] == "ynab-1"
assert len(classification.duplicates) == 1
assert classification.duplicates[0]["flag_color"] == "red"
assert len(classification.new) == 1
assert classification.new[0]["payee_name"] == "NEW TXN"
print("test_classify_for_import: PASS")
def test_preview():
"""Visual test - just make sure it doesn't crash."""
wf_txns = [
{"transactionAmount": 9.11, "transactionDate": 1773817200000,
"transactionDescription": "WALGREENS #10276", "status": "POSTED"},
{"transactionAmount": 50.00, "transactionDate": 1773817200000,
"transactionDescription": "NEW TXN", "status": "POSTED"},
]
ynab_ready = _convert_wf_to_ynab_transactions(wf_txns, "acct-123")
classification = _classify_for_import(ynab_ready, [])
_preview_import(classification)
print("test_preview: PASS")
def _write_category_csv(path: Path, rows: list[tuple[str, str, str, str]]) -> None:
with path.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.writer(csv_file)
writer.writerow(["payee", "category", "inflow", "outflow"])
writer.writerows(rows)
def test_payee_normalization_and_amount_aware_categories():
amazon_a = "AMAZON MKTPLACE PMTS Amzn.com/billWA"
amazon_b = "AMAZON MKTPL*BY44297W1 Amzn.com/billWA"
normalized_a = normalize_payee(amazon_a)
normalized_b = normalize_payee(amazon_b)
assert "by44297w1" not in normalized_b
assert normalized_a.startswith("amazon")
assert normalized_b.startswith("amazon")
assert normalize_payee("SHEETZ #1234") == normalize_payee("SHEETZ #5678")
with tempfile.TemporaryDirectory() as temp_dir:
csv_path = Path(temp_dir) / "transactions.csv"
_write_category_csv(csv_path, [
(amazon_a, "Family", "", "50.00"),
(amazon_a, "Family", "", "80.00"),
("SPLIT SHOP", "Family", "", "50.00"),
("SPLIT SHOP", "Dining Out", "", "20.00"),
("SHEETZ", "Gas", "", "40.00"),
("SHEETZ", "Gas", "", "40.00"),
("SHEETZ", "Dining Out", "", "2.00"),
("SHEETZ", "Dining Out", "", "2.00"),
("PUBLIX", "Groceries", "", "40.00"),
("PUBLIX", "Dining Out", "", "2.00"),
("REFUND SHOP", "Refunds", "12.00", ""),
("REFUND SHOP", "Shopping", "", "12.00"),
])
history = CategoryHistory.from_csv(csv_path)
category_ids = {
"family": ["family-id"],
"gas": ["gas-id"],
"dining out": ["dining-id"],
"groceries": ["grocery-id"],
"refunds": ["refund-id"],
"shopping": ["shopping-id"],
}
wf_transactions = [
{"transactionAmount": 50.00, "transactionDate": 1773817200000,
"transactionDescription": amazon_b, "status": "POSTED"},
{"transactionAmount": 40.00, "transactionDate": 1773817200000,
"transactionDescription": "SHEETZ", "status": "POSTED"},
{"transactionAmount": 2.00, "transactionDate": 1773817200000,
"transactionDescription": "SHEETZ", "status": "POSTED"},
{"transactionAmount": 2.00, "transactionDate": 1773817200000,
"transactionDescription": "PUBLIX", "status": "POSTED"},
{"transactionAmount": 20.00, "transactionDate": 1773817200000,
"transactionDescription": "SHEETZ", "status": "POSTED"},
{"transactionAmount": 12.00, "transactionDate": 1773817200000,
"transactionDescription": "REFUND SHOP", "status": "POSTED",
"debitCreditType": "CREDIT"},
]
ready = _convert_wf_to_ynab_transactions(wf_transactions, "account-id")
_apply_category_suggestions(ready, history, category_ids)
assert ready[0]["category_id"] == "family-id"
assert ready[1]["category_id"] == "gas-id"
assert ready[2]["category_id"] == "dining-id"
assert ready[3]["category_id"] == "dining-id"
assert "category_id" not in ready[4]
assert ready[4]["_category_applied"] is False
assert ready[5]["category_id"] == "refund-id"
split_patterns = [
pattern for pattern in history.patterns.values()
if pattern.payee == "split shop"
]
assert {pattern.category for pattern in split_patterns} == {"Family", "Dining Out"}
assert {pattern.amount_cents for pattern in split_patterns} == {2000, 5000}
print("test_payee_normalization_and_amount_aware_categories: PASS")
def test_category_memory_retains_and_merges_patterns():
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
csv_path = root / "transactions.csv"
memory_path = CategoryHistory.memory_path_for_csv(csv_path)
_write_category_csv(csv_path, [
("OLD MERCHANT", "Family", "", "20.00"),
("OLD MERCHANT", "Family", "", "20.00"),
])
history = CategoryHistory.from_csv(csv_path)
history.save(memory_path)
_write_category_csv(csv_path, [
("NEW MERCHANT", "Dining Out", "", "8.00"),
])
history = CategoryHistory.load(memory_path)
latest_snapshot = CategoryHistory.from_csv(csv_path)
history.merge(latest_snapshot)
history.save(memory_path)
copied_memory = root / "second_machine" / "category_memory.json"
history.save(copied_memory)
copied_history = CategoryHistory.load(copied_memory)
patterns = copied_history.patterns
assert any(pattern.payee == "old merchant" for pattern in patterns.values())
assert any(pattern.payee == "new merchant" for pattern in patterns.values())
assert next(
pattern.count for pattern in patterns.values()
if pattern.payee == "old merchant"
) == 2
print("test_category_memory_retains_and_merges_patterns: PASS")
def test_split_export_becomes_separate_category_patterns():
with tempfile.TemporaryDirectory() as temp_dir:
csv_path = Path(temp_dir) / "transactions.csv"
rows_written = _write_transactions_csv([
{
"id": "transaction-id",
"date": "2026-03-18",
"payee_name": "SPLIT SHOP",
"category_name": "Split",
"amount_milliCurrency": -70000,
"memo": None,
"account_id": "account-id",
"account_name": "Credit Card",
"cleared": "cleared",
"import_id": None,
"subtransactions": [
{"category_name": "Family", "amount_milliCurrency": -50000},
{"category_name": "Dining Out", "amount_milliCurrency": -20000},
],
},
], csv_path)
with csv_path.open("r", encoding="utf-8", newline="") as csv_file:
rows = list(csv.DictReader(csv_file))
history = CategoryHistory.from_csv(csv_path)
assert rows_written == 2
assert [row["payee"] for row in rows] == ["SPLIT SHOP", "SPLIT SHOP"]
assert {row["category"] for row in rows} == {"Family", "Dining Out"}
assert {pattern.amount_cents for pattern in history.patterns.values()} == {2000, 5000}
print("test_split_export_becomes_separate_category_patterns: PASS")
def test_ambiguous_category_name_is_not_assigned():
with tempfile.TemporaryDirectory() as temp_dir:
csv_path = Path(temp_dir) / "transactions.csv"
_write_category_csv(csv_path, [("COFFEE SHOP", "Dining", "", "5.00")])
history = CategoryHistory.from_csv(csv_path)
suggestion = history.suggest(
"COFFEE SHOP",
-5000,
{"dining": ["category-one", "category-two"]},
)
assert suggestion is not None
assert suggestion.applied is False
assert suggestion.category_id is None
print("test_ambiguous_category_name_is_not_assigned: PASS")
def test_missing_category_history_falls_back_empty():
with tempfile.TemporaryDirectory() as temp_dir:
history, can_update_memory = _load_category_history(
Path(temp_dir) / "missing.csv",
report_empty=False,
)
assert not history.patterns
assert can_update_memory is True
print("test_missing_category_history_falls_back_empty: PASS")
def test_confident_category_is_sent_to_ynab():
account_id = "11111111-1111-4111-8111-111111111111"
category_id = "22222222-2222-4222-8222-222222222222"
classification = _ImportClassification()
classification.new.append({
"account_id": account_id,
"date": "2026-03-18",
"amount": -5000,
"payee_name": "COFFEE SHOP",
"category_id": category_id,
"cleared": "cleared",
"flag_color": "blue",
"import_id": "YNAB:-5000:2026-03-18:1",
"approved": False,
})
class FakeYNABClient:
def __init__(self):
self.transactions = []
def create_transactions(self, transactions):
self.transactions = transactions
return SimpleNamespace(transaction_ids=["created-id"], duplicate_import_ids=[])
client = FakeYNABClient()
_execute_import(classification, client)
assert str(client.transactions[0].category_id) == category_id
print("test_confident_category_is_sent_to_ynab: PASS")
if __name__ == "__main__":
test_wf_to_ynab_millis()
test_build_import_ids()
test_format_amount_dollars()
test_convert_wf_to_ynab_transactions()
test_classify_for_import()
test_preview()
test_payee_normalization_and_amount_aware_categories()
test_category_memory_retains_and_merges_patterns()
test_split_export_becomes_separate_category_patterns()
test_ambiguous_category_name_is_not_assigned()
test_missing_category_history_falls_back_empty()
test_confident_category_is_sent_to_ynab()
print("\nALL TESTS PASSED")