import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.db.base import Base from app.db.models import AuditLog, User, UtxoEvent from app.deposits.service import credit_confirmed_utxos, detect_external_spends @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() @pytest.fixture async def user_id(session_factory): async with session_factory() as session: user = User(username="alice", password_hash="x", derivation_index=0, address="plm1qxxx") session.add(user) await session.commit() return user.id async def test_credits_confirmed_utxo_and_updates_balance(session_factory, user_id): entries = [{"tx_hash": "aa" * 32, "tx_pos": 0, "height": 100, "value": 10_000_000}] async with session_factory() as session: credited = await credit_confirmed_utxos(session, user_id, entries) assert credited == 1 user = await session.get(User, user_id) assert user.cached_balance_sats == 10_000_000 async def test_unconfirmed_entry_is_ignored(session_factory, user_id): entries = [{"tx_hash": "bb" * 32, "tx_pos": 0, "height": 0, "value": 5_000_000}] async with session_factory() as session: credited = await credit_confirmed_utxos(session, user_id, entries) assert credited == 0 user = await session.get(User, user_id) assert user.cached_balance_sats == 0 async def test_idempotent_on_repeated_notification(session_factory, user_id): entries = [{"tx_hash": "cc" * 32, "tx_pos": 0, "height": 100, "value": 7_000_000}] async with session_factory() as session: first = await credit_confirmed_utxos(session, user_id, entries) async with session_factory() as session: second = await credit_confirmed_utxos(session, user_id, entries) user = await session.get(User, user_id) assert first == 1 assert second == 0 assert user.cached_balance_sats == 7_000_000 async def test_external_spend_marks_utxo_spent_and_corrects_balance(session_factory, user_id): entries = [{"tx_hash": "dd" * 32, "tx_pos": 0, "height": 100, "value": 20_000_000}] async with session_factory() as session: await credit_confirmed_utxos(session, user_id, entries) async with session_factory() as session: spent = await detect_external_spends(session, user_id, []) assert spent == 1 user = await session.get(User, user_id) assert user.cached_balance_sats == 0 utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one() assert utxo.spent_txid == "external-spend" audit_events = (await session.scalars(select(AuditLog))).all() assert any(e.event_type == "utxo_spent_externally" for e in audit_events) async def test_no_spend_detected_when_utxo_still_unspent(session_factory, user_id): entries = [{"tx_hash": "ee" * 32, "tx_pos": 0, "height": 100, "value": 3_000_000}] async with session_factory() as session: await credit_confirmed_utxos(session, user_id, entries) async with session_factory() as session: spent = await detect_external_spends(session, user_id, entries) assert spent == 0 user = await session.get(User, user_id) assert user.cached_balance_sats == 3_000_000