fee_address has no column default, because an operator has to supply their own — and the payout pays the 30% commission to it, so build_payout_transaction cannot even be built without one. A fresh instance nonetheless opened rounds happily: each took bets, confirmed them, and only then discovered it was unpayable, wedging in "paying_out" and retrying every 60s with money already in the pool. One manual recovery per round, until somebody noticed. open_new_round_if_needed now checks rounds_can_open(config) alongside `paused`: no payout address, no round. Nothing has moved yet at that point, which is the whole difference. Same scope as pausing — a round already in progress still closes, draws and pays out, since clearing the address mid-round is exactly the operator slip that must not strand a live round. Surfaced rather than silent, in the two places that matter: lottery_configured on GET /rounds/current, which makes / show a *different* banner from the maintenance one (telling a player "come back later" would be false — nothing is coming until setup finishes), and a warning at the top of /admin's Parametri card, the one screen that can fix it. rounds_can_open is where any future would-make-a-round-unpayable prerequisite belongs, instead of being discovered at payout time. The test churn is the finding restated: 26 tests expected a round to open on an instance with no payout address. Their fixtures now seed one, so each goes back to testing what it says — several would otherwise have passed for the wrong reason, returning None because of the missing address rather than because of the cooldown or pause under test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
291 lines
12 KiB
Python
291 lines
12 KiB
Python
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
|