"""Regression tests for B-30: a periodic sweep must catch a deposit whose scripthash notification was silently lost, independent of whatever the notification-driven path (electrum/listener.py:refresh_user) is doing.""" import pytest from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base from app.db.models import User from app.deposits.reconcile import DepositReconciler @pytest.fixture async def session_factory(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield async_sessionmaker(engine, expire_on_commit=False) await engine.dispose() async def _seed_users(session_factory, addresses: list[str]) -> list[int]: async with session_factory() as session: ids = [] for i, address in enumerate(addresses): user = User(username=f"user{i}", password_hash="x", derivation_index=i, address=address) session.add(user) await session.flush() ids.append(user.id) await session.commit() return ids # Real, decodable PLM bech32 addresses (address_to_scripthash actually parses # them) — arbitrary otherwise. _ADDRESSES = [ "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd", "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n", "plm1qqph9qup2mp7w7g5nlsdhdc9m2pp44ampzw0ctx", ] class FakeListener: def __init__(self, *, fail_for: set[int] | None = None, disconnect_after: int | None = None): self.client = object() # truthy: "connected" self.refreshed: list[int] = [] self._fail_for = fail_for or set() self._disconnect_after = disconnect_after async def refresh_user(self, user_id: int, scripthash: str) -> None: self.refreshed.append(user_id) if self._disconnect_after is not None and len(self.refreshed) >= self._disconnect_after: self.client = None if user_id in self._fail_for: raise RuntimeError(f"listunspent failed for user {user_id}") async def test_sweep_once_refreshes_every_user(session_factory): user_ids = await _seed_users(session_factory, _ADDRESSES) listener = FakeListener() reconciler = DepositReconciler(session_factory, listener) await reconciler._sweep_once() assert listener.refreshed == user_ids async def test_sweep_once_continues_past_a_failing_user(session_factory): """One user's refresh failing (a transient network hiccup) must not stop the sweep from reaching the rest — mirrors poll_once's per-item isolation.""" user_ids = await _seed_users(session_factory, _ADDRESSES) listener = FakeListener(fail_for={user_ids[1]}) reconciler = DepositReconciler(session_factory, listener) await reconciler._sweep_once() assert listener.refreshed == user_ids async def test_sweep_once_stops_when_the_connection_drops_mid_sweep(session_factory): """No point continuing once the connection is gone — the next reconnect's own _subscribe_all_users will cover everyone anyway.""" user_ids = await _seed_users(session_factory, _ADDRESSES) listener = FakeListener(disconnect_after=1) reconciler = DepositReconciler(session_factory, listener) await reconciler._sweep_once() assert listener.refreshed == user_ids[:1] async def test_sweep_once_does_nothing_with_no_users(session_factory): listener = FakeListener() reconciler = DepositReconciler(session_factory, listener) await reconciler._sweep_once() # must not raise assert listener.refreshed == []