payslip-ingest/tests/test_sync_actualbudget.py

180 lines
6.3 KiB
Python
Raw Normal View History

"""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),
))