from datetime import datetime, timedelta, timezone 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 Round, RoundConfig from app.rounds.service import get_active_round, open_new_round_if_needed ROUND_COOLDOWN_SECONDS = 30 # matches RoundConfig.round_cooldown_seconds' column default _FEE_ADDRESS = "plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n" @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) # B-66: no fee address means no round may open at all, which would make most of # the assertions below pass for the wrong reason. Seeded once here so every test # in this file runs against an instance that could actually pay a winner, and the # ones that care about other config values edit this same single row. async with async_sessionmaker(engine, expire_on_commit=False)() as session: session.add(RoundConfig(fee_address=_FEE_ADDRESS)) await session.commit() yield async_sessionmaker(engine, expire_on_commit=False) await engine.dispose() async def test_opens_a_round_when_none_exists(session_factory): async with session_factory() as session: round_ = await open_new_round_if_needed(session) await session.commit() assert round_.status == "open" async def test_reuses_existing_open_round(session_factory): async with session_factory() as session: first = await open_new_round_if_needed(session) await session.commit() first_id = first.id async with session_factory() as session: second = await open_new_round_if_needed(session) assert second.id == first_id @pytest.mark.parametrize("status", ["closing", "drawing", "paying_out"]) async def test_does_not_open_new_round_while_previous_is_in_progress(session_factory, status): async with session_factory() as session: session.add(Round(status=status)) await session.commit() async with session_factory() as session: active = await get_active_round(session) assert active is not None assert active.status == status # open_new_round_if_needed must return the in-progress round, not open a new one returned = await open_new_round_if_needed(session) assert returned.status == status async def test_opens_new_round_after_previous_is_closed(session_factory): async with session_factory() as session: session.add(Round(status="closed")) await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) assert round_.status == "open" async def test_withholds_new_round_during_cooldown(session_factory): async with session_factory() as session: session.add(Round(status="closed", closed_at=datetime.now(timezone.utc))) await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) assert round_ is None async def test_opens_new_round_once_cooldown_elapses(session_factory): stale_close = datetime.now(timezone.utc) - timedelta(seconds=ROUND_COOLDOWN_SECONDS + 1) async with session_factory() as session: session.add(Round(status="closed", closed_at=stale_close)) await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) assert round_.status == "open" async def test_withholds_new_round_while_paused(session_factory): stale_close = datetime.now(timezone.utc) - timedelta(seconds=ROUND_COOLDOWN_SECONDS + 1) async with session_factory() as session: session.add(Round(status="closed", closed_at=stale_close)) (await session.scalars(select(RoundConfig))).one().paused = True await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) assert round_ is None async def test_pause_does_not_interrupt_a_round_in_progress(session_factory): async with session_factory() as session: session.add(Round(status="drawing")) (await session.scalars(select(RoundConfig))).one().paused = True await session.commit() async with session_factory() as session: returned = await open_new_round_if_needed(session) assert returned is not None assert returned.status == "drawing" async def test_losing_the_open_race_reuses_the_winning_round(session_factory, monkeypatch): """B-09: open_new_round_if_needed was a read-then-insert with no lock, called from both the scheduler and every place_bet, so two callers could both see "no active round" and insert one — and a second stuck "open" row blocks every future round, since get_active_round matches on status. The race is forced deterministically: the round already exists and is committed, but this caller's first look is made to miss it (exactly what the loser of the race sees). The insert then hits ix_rounds_single_active, and the caller must recover by using the winner's round instead of raising at its caller — a bet must not fail because a scheduler tick beat it by a millisecond. """ from app.rounds import service as service_module async with session_factory() as session: session.add(Round(status="open")) await session.commit() real_get_active_round = service_module.get_active_round calls = {"n": 0} async def blind_first_look(session): calls["n"] += 1 if calls["n"] == 1: return None # what the loser of the race sees return await real_get_active_round(session) monkeypatch.setattr(service_module, "get_active_round", blind_first_look) async with session_factory() as session: round_ = await service_module.open_new_round_if_needed(session) await session.commit() assert round_ is not None # recovered, didn't raise async with session_factory() as session: rounds = (await session.scalars(select(Round))).all() assert len(rounds) == 1, f"expected one round, got {[(r.id, r.status) for r in rounds]}" assert round_.id == rounds[0].id # the winner's round, not a second one async def test_the_database_refuses_a_second_active_round(session_factory): """The guarantee itself, independent of the application code path.""" from sqlalchemy.exc import IntegrityError async with session_factory() as session: session.add(Round(status="open")) await session.commit() async with session_factory() as session: session.add(Round(status="drawing")) with pytest.raises(IntegrityError): await session.commit() async def test_closed_rounds_can_coexist_with_an_active_one(session_factory): async with session_factory() as session: session.add(Round(status="closed")) session.add(Round(status="closed")) session.add(Round(status="open")) await session.commit() async with session_factory() as session: assert len((await session.scalars(select(Round))).all()) == 3 # --- B-66: no round opens on an instance that could not pay its winner ------------ async def test_withholds_new_round_while_no_fee_address_is_configured(session_factory): # B-66 """A fresh instance starts with no fee_address, and the payout pays the 30% commission to it — so a round opened without one takes bets, confirms them, and only then discovers it cannot be paid, wedging in "paying_out" with money already in the pool and needing manual recovery. Every round, until an operator notices.""" async with session_factory() as session: (await session.scalars(select(RoundConfig))).one().fee_address = "" await session.commit() async with session_factory() as session: assert await open_new_round_if_needed(session) is None async with session_factory() as session: assert (await session.scalars(select(Round))).all() == [] # nothing opened at all async def test_opens_a_round_as_soon_as_a_fee_address_is_set(session_factory): # B-66 async with session_factory() as session: (await session.scalars(select(RoundConfig))).one().fee_address = "" await session.commit() async with session_factory() as session: assert await open_new_round_if_needed(session) is None async with session_factory() as session: (await session.scalars(select(RoundConfig))).one().fee_address = _FEE_ADDRESS await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) await session.commit() assert round_ is not None and round_.status == "open" async def test_a_round_in_progress_survives_the_fee_address_being_cleared(session_factory): # B-66 """Same rule as pausing: an unmet prerequisite only stops the *next* round. The one in progress keeps its participants and still has to be drawn and paid — and clearing the address is exactly the mistake an operator might make mid-round.""" async with session_factory() as session: session.add(Round(status="open")) (await session.scalars(select(RoundConfig))).one().fee_address = "" await session.commit() async with session_factory() as session: returned = await open_new_round_if_needed(session) assert returned is not None and returned.status == "open" def test_rounds_can_open_ignores_a_whitespace_only_fee_address(): # B-66 from app.rounds.service import rounds_can_open assert rounds_can_open(RoundConfig(fee_address=_FEE_ADDRESS)) is True assert rounds_can_open(RoundConfig(fee_address="")) is False assert rounds_can_open(RoundConfig(fee_address=" ")) is False # --- B-61: a round runs by the timing it opened with, not by the live config ------ async def test_a_new_round_snapshots_the_current_config_timing(session_factory): async with session_factory() as session: config = (await session.scalars(select(RoundConfig))).one() config.round_duration_seconds = 120 config.round_cooldown_seconds = 45 await session.commit() async with session_factory() as session: round_ = await open_new_round_if_needed(session) await session.commit() assert round_.duration_seconds == 120 assert round_.cooldown_seconds == 45 async def test_round_accepts_bets_uses_the_rounds_own_duration(session_factory): from app.rounds.service import round_accepts_bets opened_at = datetime.now(timezone.utc) - timedelta(seconds=100) still_open = Round(status="open", opened_at=opened_at, duration_seconds=600) expired = Round(status="open", opened_at=opened_at, duration_seconds=60) assert round_accepts_bets(still_open) is True assert round_accepts_bets(expired) is False async def test_cooldown_comes_from_the_round_that_closed(session_factory): """The gap a closing round announced is the gap that's honoured: shortening round_cooldown_seconds afterwards must not open the next round early, nor lengthening it hold the lottery shut.""" async with session_factory() as session: (await session.scalars(select(RoundConfig))).one().round_cooldown_seconds = 0 # just lowered session.add( Round( status="closed", opened_at=datetime.now(timezone.utc) - timedelta(seconds=200), closed_at=datetime.now(timezone.utc) - timedelta(seconds=10), cooldown_seconds=300, # what that round ran with ) ) await session.commit() async with session_factory() as session: assert await open_new_round_if_needed(session) is None # still cooling down