from datetime import datetime, timedelta, timezone import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.bets.service import BetError, place_bet from app.config import settings from app.db.base import Base from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent from app.rounds.service import open_new_round_if_needed from app.wallet.hd import derive_user_address class FakeElectrumClient: def __init__(self): self.broadcasted: list[str] = [] async def broadcast(self, raw_tx_hex: str) -> str: self.broadcasted.append(raw_tx_hex) return "fake-network-txid" @pytest.fixture async def session_factory(tmp_path, monkeypatch): monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) monkeypatch.setattr(settings, "xprv_encryption_key", __import__("cryptography.fernet", fromlist=["Fernet"]).Fernet.generate_key().decode()) from app.wallet import hd hd._account_key = None hd.generate_master_key() 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() hd._account_key = None async def _make_funded_user(session_factory, index: int, funded_sats: int) -> int: async with session_factory() as session: address = derive_user_address(index) user = User(username=f"user{index}", password_hash="x", derivation_index=index, address=address) session.add(user) await session.commit() session.add( UtxoEvent( user_id=user.id, txid=f"{index:02x}" * 32, vout=0, amount_sats=funded_sats, confirmed_height=100, ) ) await session.commit() return user.id async def test_place_bet_broadcasts_and_records_participant(session_factory): user_id = await _make_funded_user(session_factory, 0, 1_500_000_000) client = FakeElectrumClient() async with session_factory() as session: user = await session.get(User, user_id) participant = await place_bet(session, client, user) assert client.broadcasted # a raw tx was broadcast assert participant.status == "broadcast" assert participant.bet_txid async with session_factory() as session: utxo = (await session.scalars(select(UtxoEvent).where(UtxoEvent.user_id == user_id))).one() assert utxo.spent_txid == participant.bet_txid pending = (await session.scalars(select(PendingTransaction))).one() assert pending.kind == "bet" audit_events = (await session.scalars(select(AuditLog))).all() assert any(e.event_type == "bet_placed" for e in audit_events) assert pending.current_txid == participant.bet_txid async def test_place_bet_rejects_insufficient_balance(session_factory): user_id = await _make_funded_user(session_factory, 1, 1_000_000) # below bet_amount_sats client = FakeElectrumClient() async with session_factory() as session: user = await session.get(User, user_id) with pytest.raises(BetError, match="insufficient balance"): await place_bet(session, client, user) async def test_place_bet_rejects_second_bet_same_round(session_factory): user_id = await _make_funded_user(session_factory, 2, 3_000_000_000) client = FakeElectrumClient() async with session_factory() as session: user = await session.get(User, user_id) await place_bet(session, client, user) async with session_factory() as session: user = await session.get(User, user_id) with pytest.raises(BetError, match="already"): await place_bet(session, client, user) async with session_factory() as session: participants = (await session.scalars(select(RoundParticipant))).all() assert len(participants) == 1 async def test_place_bet_rejects_after_timer_expires_even_if_still_open(session_factory): """The scheduler only flips status "open" -> "closing" on its next tick (up to a few seconds late) — place_bet must independently refuse bets once the round's own deadline has passed, so no new player can sneak in during that gap (see rounds/service.round_accepts_bets).""" user_id = await _make_funded_user(session_factory, 3, 3_000_000_000) client = FakeElectrumClient() async with session_factory() as session: session.add(RoundConfig(fee_address="", round_duration_seconds=60)) round_ = await open_new_round_if_needed(session) round_.opened_at = datetime.now(timezone.utc) - timedelta(seconds=61) await session.commit() async with session_factory() as session: user = await session.get(User, user_id) with pytest.raises(BetError, match="closing"): await place_bet(session, client, user) async with session_factory() as session: participants = (await session.scalars(select(RoundParticipant))).all() assert len(participants) == 0 round_ = (await session.scalars(select(Round))).one() assert round_.status == "open" # scheduler hasn't ticked — status is unchanged, only the check is deadline-aware