Files
plm-lottery/tests/unit/test_rounds_service.py
T

351 lines
14 KiB
Python
Raw Normal View History

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
# --- B-73 (RND-34, DAT-61, DAT-64, SEC-56): the commitment made when a round opens --------
async def test_a_new_round_commits_to_an_encrypted_secret_and_copies_draw_confirmations(session_factory):
"""The hash is published, the secret is stored only encrypted (and decrypts to the
preimage of the hash), the plaintext column stays empty, and the number of confirmations
is copied from the config at open — an edit applies from the next round."""
import hashlib
from app.wallet.keystore import decrypt_round_seed
async with session_factory() as session:
(await session.scalars(select(RoundConfig))).one().draw_confirmations = 5
await session.commit()
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
round_id = round_.id
async with session_factory() as session:
round_ = await session.get(Round, round_id)
assert round_.draw_confirmations == 5
assert round_.server_seed is None # DAT-64: no plaintext before the reveal
assert round_.server_seed_enc and round_.server_seed_hash
seed = decrypt_round_seed(round_.server_seed_enc)
assert len(bytes.fromhex(seed)) == 32
assert hashlib.sha256(bytes.fromhex(seed)).hexdigest() == round_.server_seed_hash
assert seed not in round_.server_seed_enc # an encrypted token, not the secret in disguise
(await session.scalars(select(RoundConfig))).one().draw_confirmations = 9 # edited mid-round
await session.commit()
assert (await session.get(Round, round_id)).draw_confirmations == 5
async def test_every_round_gets_its_own_secret(session_factory):
seeds = set()
for _ in range(3):
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
round_.status = "closed"
round_.closed_at = datetime.now(timezone.utc) - timedelta(days=1)
seeds.add(round_.server_seed_hash)
await session.commit()
assert len(seeds) == 3
async def test_the_secret_cannot_be_decrypted_with_another_key(session_factory, monkeypatch):
"""SEC-56: a copy of the database without XPRV_ENCRYPTION_KEY does not reveal it."""
from cryptography.fernet import Fernet, InvalidToken
from app.config import settings
from app.wallet.keystore import decrypt_round_seed
async with session_factory() as session:
round_ = await open_new_round_if_needed(session)
await session.commit()
token = round_.server_seed_enc
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
with pytest.raises(InvalidToken):
decrypt_round_seed(token)