payslip-ingest/tests/test_sync_actualbudget.py
Viktor Barzin 08f28ad581 sync: ActualBudget Meta deposit overlay (Phase C)
Adds daily sync of Meta payroll deposits from ActualBudget into
payslip_ingest.external_meta_deposits, enabling the dashboard to overlay
bank deposits against payslip net_pay and surface parser drift on net.

- Migration 0007: new table external_meta_deposits, unique on
  actualbudget_tx_id, indexed on deposit_date.
- payslip_ingest.sync.actualbudget: narrow client for the
  jhonderson/actual-http-api sidecar (list accounts + transactions).
  Filters on payee regex (META|FACEBOOK, word-boundary). Idempotent
  upsert — ON CONFLICT DO NOTHING on actualbudget_tx_id. Surfaces
  clear error if the transactions endpoint is missing so the operator
  can switch to a SQLite-mount fallback.
- CLI command: `python -m payslip_ingest sync-meta-deposits` driven by
  4 env vars (ACTUALBUDGET_HTTP_API_URL, API_KEY, ENCRYPTION_PASSWORD,
  BUDGET_SYNC_ID).
- Tests: 5 — regex positive/negative, full sync insert, idempotency,
  404-endpoint failure mode.

Part of: code-860
2026-04-19 18:20:50 +00:00

179 lines
6.3 KiB
Python

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