"""Agent tool coverage: session binding, JSON-safe output (no Decimal leaks), and the batched queries introduced in Phase 2.""" import json from datetime import date, datetime from decimal import Decimal import pytest from app.agent import tools from app.agent.tools import reset_session, set_session from app.models.models import ( Account, AccountType, Bank, Currency, MunicipalReceipt, PensionSnapshot, Transaction, TransactionType, WaterMeterReading, ) @pytest.fixture() def bound_session(session): token = set_session(session) yield session reset_session(token) def make_snapshot(fund: Bank, start: date, end: date, saldo: str) -> PensionSnapshot: zero = Decimal("0") return PensionSnapshot( fund=fund, contract_number="C-1", period_start=start, period_end=end, saldo_anterior=zero, aportes=zero, rendimientos=zero, retiros=zero, traslados=zero, comision=zero, correccion=zero, bonificacion=zero, saldo_final=Decimal(saldo), source_filename="t.pdf", ) def make_receipt(period: str) -> MunicipalReceipt: zero = Decimal("0") return MunicipalReceipt( receipt_date=date(2026, 4, 1), due_date=date(2026, 4, 15), period=period, account="A1", finca="F1", holder_name="x", holder_cedula="x", holder_address="x", subtotal=Decimal("100"), interests=zero, iva=Decimal("13"), total=Decimal("113"), source_filename="r.pdf", ) def test_unbound_session_raises(session): with pytest.raises(RuntimeError, match="not bound to agent context"): tools.get_accounts() def test_accounts_output_is_json_safe(bound_session): bound_session.add( Account( bank=Bank.BAC, currency=Currency.CRC, label="Main", balance=Decimal("1234.56"), account_type=AccountType.BANK, ) ) bound_session.commit() out = tools.get_accounts() json.dumps(out) # would raise on Decimal leakage assert out[0]["balance"] == 1234.56 def test_pension_latest_only_per_fund(bound_session): bound_session.add( make_snapshot(Bank.FCL, date(2026, 1, 1), date(2026, 1, 31), "100") ) bound_session.add( make_snapshot(Bank.FCL, date(2026, 2, 1), date(2026, 2, 28), "200") ) bound_session.add( make_snapshot(Bank.ROP, date(2026, 1, 1), date(2026, 1, 31), "900") ) bound_session.commit() out = tools.get_pension_snapshots(latest_only=True) json.dumps(out) assert len(out) == 2 by_fund = {o["fund"]: o for o in out} assert by_fund["FCL"]["saldo_final"] == 200.0 # latest period wins assert by_fund["ROP"]["saldo_final"] == 900.0 full = tools.get_pension_snapshots(latest_only=False) assert len(full) == 3 def test_municipal_receipts_batched_consumption(bound_session): r1, r2 = make_receipt("2026-03"), make_receipt("2026-04") r2.account = "A2" bound_session.add(r1) bound_session.add(r2) bound_session.commit() bound_session.refresh(r1) bound_session.refresh(r2) for rid, m3 in ((r1.id, "10.5"), (r1.id, "4.5"), (r2.id, "7")): bound_session.add( WaterMeterReading( meter_id="7335", period=f"p{m3}", consumption_m3=Decimal(m3), receipt_id=rid, ) ) bound_session.commit() out = tools.get_municipal_receipts() json.dumps(out) by_account = {o["account"]: o for o in out} assert by_account["A1"]["water_consumption_m3"] == 15.0 assert by_account["A2"]["water_consumption_m3"] == 7.0 def test_recent_transactions_json_safe(bound_session): bound_session.add( Transaction( amount=Decimal("42.42"), merchant="M", date=datetime(2026, 4, 1), ) ) bound_session.commit() out = tools.get_recent_transactions() json.dumps(out) assert out[0]["amount"] == 42.42 def test_recent_transactions_exclude_future_cuotas(bound_session): from datetime import timedelta from app.timeutil import today_cr today = datetime.combine(today_cr(), datetime.min.time()) bound_session.add( Transaction(amount=Decimal("10"), merchant="BILLED", date=today) ) bound_session.add( Transaction( amount=Decimal("20"), merchant="FUTURE CUOTA:03/06", date=today + timedelta(days=45), ) ) bound_session.commit() merchants = [t["merchant"] for t in tools.get_recent_transactions()] assert "BILLED" in merchants assert "FUTURE CUOTA:03/06" not in merchants def test_salary_deposits_returns_individual_filtered_rows(bound_session): bound_session.add_all( [ Transaction( amount=Decimal("1500"), merchant="SALARY JULY", date=datetime(2026, 7, 14, 9), transaction_type=TransactionType.SALARY, reference="PAY-2026-07", ), Transaction( amount=Decimal("1400"), merchant="SALARY JUNE", date=datetime(2026, 6, 14, 9), transaction_type=TransactionType.SALARY, ), Transaction( amount=Decimal("999"), merchant="PURCHASE", date=datetime(2026, 7, 14, 10), ), ] ) bound_session.commit() out = tools.get_salary_deposits( start_date="2026-07-14", end_date="2026-07-15" ) json.dumps(out) assert out == [ { "id": out[0]["id"], "date": "2026-07-14T09:00:00", "amount": 1500.0, "currency": "CRC", "merchant": "SALARY JULY", "source": "CREDIT_CARD", "bank": "BAC", "transaction_type": "SALARY", "reference": "PAY-2026-07", "notes": None, } ] def test_get_current_date_is_costa_rica_and_cycle_consistent(): from app.timeutil import today_cr out = tools.get_current_date() today = today_cr() assert out["date"] == today.isoformat() # The active cycle must contain today: [start, end) start, end = out["cycle_range"] assert start <= out["date"] < end assert start.endswith("-18") and end.endswith("-18") def test_daily_spending_day_scoped(bound_session): from datetime import timedelta from app.timeutil import today_cr today = datetime.combine(today_cr(), datetime.min.time()) bound_session.add( Transaction(amount=Decimal("100"), merchant="TODAY-A", date=today.replace(hour=9)) ) bound_session.add( Transaction(amount=Decimal("50"), merchant="TODAY-B", date=today.replace(hour=15)) ) bound_session.add( Transaction( amount=Decimal("999"), merchant="FUTURE CUOTA", date=today + timedelta(days=40), ) ) bound_session.commit() out = tools.get_daily_spending( start_date=today.date().isoformat(), end_date=(today.date() + timedelta(days=60)).isoformat(), ) assert len(out) == 1 assert out[0]["date"] == today.date().isoformat() assert out[0]["total_crc"] == 150.0 assert out[0]["count"] == 2 def test_get_current_date_respects_client_timezone(): from datetime import datetime, timezone as tz from app.timeutil import ( client_tz, reset_client_timezone, set_client_timezone, ) token = set_client_timezone("Asia/Tokyo") try: out = tools.get_current_date() assert out["timezone"] == "Asia/Tokyo" assert out["date"] == datetime.now(client_tz()).date().isoformat() finally: reset_client_timezone(token) # Unknown names are ignored — CR fallback stays active. assert set_client_timezone("Not/AZone") is None assert set_client_timezone(None) is None assert tools.get_current_date()["timezone"] == "UTC-06:00"