Files
plm-lottery/tests/unit/test_rounds_service.py
T
davideandClaude Opus 5 37cc5eeeb5 Snapshot a round's timing when it opens (B-61)
round_duration_seconds was read live on every scheduler tick and every bet
check, with the deadline computed as opened_at + duration. Lowering it from 600
to 60 while a round was 300s in closed that round instantly; raising it moved
the closes_at clients were already counting down to. round_cooldown_seconds had
the same property for the gap after a close. B-11 fixed this class of problem
for the advertised jackpot; the timing fields were left live.

Round now carries duration_seconds and cooldown_seconds, set from the config
when it opens. round_deadline() is the single place the deadline is computed —
the scheduler, place_bet's two checks and /rounds/current's closes_at all go
through it — and the cooldown is read off the round that just closed, so the gap
a round announced is the gap that's honoured. The config row becomes what the
*next* round opens with.

The migration backfills from the live config rather than leaving the column
defaults: an instance running 300s rounds would otherwise see the round
currently in progress jump to 600s the moment this lands, which is precisely the
retroactive change being fixed. Verified against a scratch DB with a non-default
config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:25:56 +02:00

221 lines
8.5 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
@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 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))
session.add(RoundConfig(fee_address="", 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"))
session.add(RoundConfig(fee_address="", 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-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:
session.add(RoundConfig(fee_address="", round_duration_seconds=120, 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:
session.add(RoundConfig(fee_address="", round_cooldown_seconds=0)) # just lowered to 0
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