"""Unit tests for the ActualBudget sync client.""" from collections.abc import AsyncIterator from datetime import UTC, date, datetime from decimal import Decimal from typing import Any import httpx import pytest import respx from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from payslip_ingest.db import Base, ExternalMetaDeposit from payslip_ingest.sync.actualbudget import ( META_PAYEE_RE, ActualBudgetClient, ActualBudgetError, sync_meta_deposits, ) BASE_URL = "http://budget-http-api-viktor.actualbudget.svc.cluster.local" BUDGET_ID = "sync-id-viktor" def test_meta_payee_regex_matches_canonical() -> None: assert META_PAYEE_RE.search("META PLATFORMS IRELAND") assert META_PAYEE_RE.search("Facebook UK Ltd") assert META_PAYEE_RE.search("meta") def test_meta_payee_regex_rejects_other() -> None: assert not META_PAYEE_RE.search("Amazon") assert not META_PAYEE_RE.search("Metamask") # requires word boundary assert not META_PAYEE_RE.search("facebookish") def _register_sqlite_now(dbapi_conn: Any, _: Any) -> None: """SQLite doesn't have now() — map it to datetime('now') so the Postgres `server_default=text("now()")` on the ORM models works in tests.""" dbapi_conn.create_function("now", 0, lambda: datetime.now(UTC).isoformat(" ")) @pytest.fixture async def session_factory() -> AsyncIterator[async_sessionmaker[Any]]: engine: AsyncEngine = create_async_engine("sqlite+aiosqlite:///:memory:") from sqlalchemy import event event.listen(engine.sync_engine, "connect", _register_sqlite_now) async with engine.begin() as conn: await conn.exec_driver_sql("ATTACH DATABASE ':memory:' AS payslip_ingest") await conn.run_sync(Base.metadata.create_all) yield async_sessionmaker(engine, expire_on_commit=False) await engine.dispose() def _mock_accounts() -> dict[str, Any]: return {"data": [{"id": "account-1", "name": "Barclays Personal"}]} def _mock_transactions() -> dict[str, Any]: return { "data": [ { "id": "tx-001", "date": "2026-03-28", "amount": 650000, # 6500.00 GBP in cents "payee_name": "META PLATFORMS IRELAND", "notes": "Salary March", }, { "id": "tx-002", "date": "2026-02-28", "amount": 620000, "payee_name": "Facebook UK Ltd", "notes": None, }, { "id": "tx-003", "date": "2026-03-15", "amount": 12000, "payee_name": "Tesco", "notes": "groceries", }, { "id": "tx-004", "date": "2026-03-01", "amount": -5000, # outgoing "payee_name": "META (refund out)", "notes": None, }, ] } @respx.mock async def test_sync_meta_deposits_inserts_matches( session_factory: async_sessionmaker[Any], ) -> None: respx.get(f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts").mock( return_value=httpx.Response(200, json=_mock_accounts())) respx.get( f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts/account-1/transactions").mock( return_value=httpx.Response(200, json=_mock_transactions())) async with ActualBudgetClient(BASE_URL, "k", "pwd", BUDGET_ID) as client: result = await sync_meta_deposits(client, session_factory) assert result.accounts_scanned == 1 assert result.transactions_fetched == 4 assert result.meta_deposits_matched == 2 assert result.inserted == 2 async with session_factory() as session: rows = (await session.execute( select(ExternalMetaDeposit).order_by( ExternalMetaDeposit.deposit_date))).scalars().all() assert len(rows) == 2 assert rows[0].actualbudget_tx_id == "tx-002" assert rows[0].deposit_date == date(2026, 2, 28) assert rows[0].amount == Decimal("6200.00") assert rows[0].payee == "Facebook UK Ltd" assert rows[1].actualbudget_tx_id == "tx-001" assert rows[1].amount == Decimal("6500.00") @respx.mock async def test_sync_meta_deposits_is_idempotent( session_factory: async_sessionmaker[Any], ) -> None: """Running twice inserts each tx only once.""" respx.get(f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts").mock( return_value=httpx.Response(200, json=_mock_accounts())) respx.get( f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts/account-1/transactions").mock( return_value=httpx.Response(200, json=_mock_transactions())) async with ActualBudgetClient(BASE_URL, "k", "pwd", BUDGET_ID) as client: first = await sync_meta_deposits(client, session_factory) second = await sync_meta_deposits(client, session_factory) assert first.inserted == 2 assert second.inserted == 0 assert second.skipped_existing == 2 async with session_factory() as session: count = len((await session.execute( select(ExternalMetaDeposit.id))).scalars().all()) assert count == 2 @respx.mock async def test_sync_surfaces_missing_endpoint_error( session_factory: async_sessionmaker[Any], ) -> None: """404 on transactions endpoint must raise — triggers SQLite fallback.""" respx.get(f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts").mock( return_value=httpx.Response(200, json=_mock_accounts())) respx.get( f"{BASE_URL}/v1/budgets/{BUDGET_ID}/accounts/account-1/transactions").mock( return_value=httpx.Response(404)) async with ActualBudgetClient(BASE_URL, "k", "pwd", BUDGET_ID) as client: with pytest.raises(ActualBudgetError, match="endpoint not found"): await sync_meta_deposits(client, session_factory) async def _insert_existing_deposit( session_factory: async_sessionmaker[Any], tx_id: str, ) -> None: async with session_factory() as session, session.begin(): session.add( ExternalMetaDeposit( actualbudget_tx_id=tx_id, deposit_date=date(2026, 3, 28), amount=Decimal("6500.00"), payee="META", memo=None, synced_at=datetime.now(UTC), ))