Files
plm-lottery/tests/unit/test_rounds_route.py
T
davideandClaude Sonnet 5.5 8b182ac8b6 Snapshot the bet cost, fee address and pool cap onto the round at open, and cap the pool (B-95, RND-05, RND-06, RND-23, BET-20, BET-21, DAT-26, DAT-27, DAT-61, DAT-62, API-21, API-30, API-31, ADR-10, LIM-16)
Only the duration and the cooldown (and, since B-73, the draw confirmations) were copied onto
the round when it opened. The cost of a bet and the fee address were read live, so an edit from
/admin in the middle of a round gave the players of that round different stakes for the same
chance and redirected the commission of the round in progress.

- `rounds.bet_amount_sats`, `rounds.fee_address` and `rounds.max_round_pool_sats` are copied from
  round_config in the transaction that opens the round (Alembic a7d3e9f15b48: plain add_column,
  existing rounds backfilled with the current configuration, i.e. what they were using; the
  expression indexes untouched). `place_bet` charges the round's cost, `_trigger_payout` pays the
  round's fee address (an empty one on the row still stops the payout, as before),
  `GET /rounds/current` advertises the round's cost while one is active and the configured one
  otherwise. The minimum withdrawal and the fee rate stay live, by design (ADR-20, ADR-10).
- `round_config.max_round_pool_sats` (0 = none, otherwise >= the bet cost and <= 4e15, in
  /admin and PUT/GET /admin/config): a bet is refused with `round_full` (params also carry
  `max_round_pool_sats`) once the sum of the round's participant rows, in-flight ones included,
  has reached the round's cap, before anything moves; the pool can exceed it by one bet at most.
  A cap under the bet cost is a 422 `max_round_pool_below_bet_amount`.
- PUT /admin/config's payability check (RND-40) now also judges a fee-rate change against the
  bet cost the round in progress opened with, which can be lower than the configured one.
- `error.round_full` no longer says "maximum of N players" (it also fires for the pool cap): the
  seven translations are generic.

Ambiguity resolved: the spec said the cap is snapshotted (RND-05, BET-20, ARC) in some places and
"applies immediately, also to the round in progress" in the round_config table of modello-dati;
the snapshot wins, with a column on `rounds` (modello-dati/api/guide-admin brought in line).

Tests changed because the spec changed: the payout fixtures seed the round's own fee address, and
the old "fee address missing" payout test now empties the round's address (clearing it in the
configuration mid-round must not stop the payout). New test_round_snapshot.py: the copy at open,
edits reaching only the next round, every bet of a round paying the same stake whether the cost
is raised or lowered meanwhile, the payout following the round's address (and surviving it being
cleared in the configuration), the cap (exactly at the cap is full, in-flight bets count, no cap,
a mid-round edit of the cap does not apply, the refused player's coins untouched), plus the route,
the admin bounds and the two 422s, the fee-rate check against the round's own cost, and the
migration backfill. Mutation-checked: charging the live cost, paying the live address, not
copying the cap and `>` instead of `>=` each fail tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 15:12:00 +02:00

343 lines
14 KiB
Python

import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
import app.wallet.hd as hd
hd._account_key = None
hd.generate_master_key()
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import base as db_base
import app.db.models # noqa: F401
db_base.engine = create_async_engine(settings.database_url)
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
from app.db import session as db_session
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
async with db_base.engine.begin() as conn:
await conn.run_sync(db_base.Base.metadata.create_all)
from fastapi import FastAPI
from app.api.routes.rounds import router as rounds_router
from app.auth.routes import router as auth_router
from app.electrum.listener import ElectrumListener
app = FastAPI()
app.include_router(auth_router)
app.include_router(rounds_router)
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac, db_base.AsyncSessionLocal
await db_base.engine.dispose()
async def _register(ac, username):
resp = await ac.post("/auth/register", json={"username": username, "password": "hunter2hunter"})
assert resp.status_code == 201
data = resp.json()
return data["access_token"], data["user_id"] if "user_id" in data else None
async def test_user_played_true_only_for_participants(client):
ac, session_factory = client
from app.db.models import Round, RoundConfig, RoundParticipant, User
player_token, _ = await _register(ac, "player")
spectator_token, _ = await _register(ac, "spectator")
async with session_factory() as session:
from sqlalchemy import select
session.add(RoundConfig(fee_address="pool-fee-address"))
player = (await session.scalars(select(User).where(User.username == "player"))).one()
round_ = Round(status="paying_out", winner_user_id=player.id, winner_amount_sats=123)
session.add(round_)
await session.flush()
session.add(
RoundParticipant(
round_id=round_.id,
user_id=player.id,
bet_amount_sats=1_000_000_000,
bet_txid="a" * 64,
)
)
await session.commit()
resp = await ac.get("/rounds/current", headers={"Authorization": f"Bearer {player_token}"})
assert resp.status_code == 200
assert resp.json()["user_played"] is True
resp = await ac.get("/rounds/current", headers={"Authorization": f"Bearer {spectator_token}"})
assert resp.status_code == 200
assert resp.json()["user_played"] is False
resp = await ac.get("/rounds/current") # no auth at all — logged-out chain-only view
assert resp.status_code == 200
assert resp.json()["user_played"] is False
async def test_jackpot_comes_from_the_participants_actual_bets(client):
"""B-11: the jackpot was participant_count * the *current* bet_amount_sats, which
overstated it (each stored bet is already net of that bet's network fee) and
silently rewrote the advertised jackpot of a round in progress whenever an
operator edited the bet amount."""
from sqlalchemy import select
from app.db.models import Round, RoundConfig, RoundParticipant
ac, session_factory = client
async with session_factory() as session:
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
session.add(Round(id=50, status="open"))
await session.flush()
# Two bets that actually paid 999_800_000 each (fee deducted), not 1_000_000_000.
session.add(
RoundParticipant(round_id=50, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
)
session.add(
RoundParticipant(round_id=50, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="confirmed")
)
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["participant_count"] == 2
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
# Changing the configured bet amount must not move a running round's jackpot.
async with session_factory() as session:
config = (await session.scalars(select(RoundConfig))).one()
config.bet_amount_sats = 5_000_000_000
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["jackpot_sats"] == (999_800_000 * 2) * 70 // 100
async def test_advertised_jackpot_covers_only_the_bets_that_will_be_paid(client): # B-65
"""The draw picks from confirmed participants and the payout spends only their
sats, so counting every participant row advertised a jackpot bigger than the one
that would actually be paid — and let a player appear in the count and then
vanish again if their bet was abandoned. The confirmed figures are the headline
ones; what's in flight is reported alongside, never folded in."""
from app.db.models import Round, RoundConfig, RoundParticipant
ac, session_factory = client
async with session_factory() as session:
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
session.add(Round(id=60, status="open"))
await session.flush()
session.add(
RoundParticipant(round_id=60, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
)
# One mid-broadcast and one written but not yet broadcast: both in flight,
# neither drawn from nor spent by the payout as things stand.
session.add(
RoundParticipant(round_id=60, user_id=2, bet_amount_sats=999_800_000, bet_txid="b", status="broadcast")
)
session.add(
RoundParticipant(round_id=60, user_id=3, bet_amount_sats=999_800_000, bet_txid="c", status="building")
)
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["participant_count"] == 1
assert body["jackpot_sats"] == 999_800_000 * 70 // 100
# Inclusive, like pending_balance_sats — not a delta.
assert body["pending_participant_count"] == 3
assert body["pending_jackpot_sats"] == (999_800_000 * 3) * 70 // 100
assert body["has_pending_bets"] is True
async def test_no_pending_bets_reported_once_every_bet_has_confirmed(client): # B-65
from app.db.models import Round, RoundConfig, RoundParticipant
ac, session_factory = client
async with session_factory() as session:
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
session.add(Round(id=61, status="open"))
await session.flush()
session.add(
RoundParticipant(round_id=61, user_id=1, bet_amount_sats=999_800_000, bet_txid="a", status="confirmed")
)
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["has_pending_bets"] is False
assert body["pending_participant_count"] == body["participant_count"] == 1
assert body["pending_jackpot_sats"] == body["jackpot_sats"]
async def test_lottery_configured_flags_a_missing_fee_address(client): # B-66
"""The frontend has to tell "the next round is coming" apart from "nothing is
coming until the operator finishes setting this up" — the banner says different
things, and only one of them is worth waiting for."""
from sqlalchemy import select
from app.db.models import RoundConfig
ac, session_factory = client
async with session_factory() as session:
session.add(RoundConfig(fee_address=""))
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["lottery_configured"] is False
assert body["lottery_paused"] is False # not a pause: a prerequisite that isn't met
assert body["round_id"] is None # and indeed no round was opened
async with session_factory() as session:
(await session.scalars(select(RoundConfig))).one().fee_address = (
"plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n"
)
await session.commit()
assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True
async def test_draw_waiting_since_is_exposed_only_while_drawing(client):
"""B-36: the "drawing" wait on a future block has no timeout, so the frontend
needs draw_waiting_since to show "still waiting" instead of implying a bounded
countdown. It must not leak for any other status, where it's meaningless."""
from datetime import datetime, timezone
from app.db.models import Round, RoundConfig
ac, session_factory = client
started_at = datetime(2026, 7, 27, 10, 0, 0)
async with session_factory() as session:
session.add(RoundConfig(fee_address=""))
session.add(Round(id=60, status="drawing", drawing_started_at=started_at))
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["draw_waiting_since"] == "2026-07-27T10:00:00+00:00"
async with session_factory() as session:
from sqlalchemy import select
round_ = (await session.scalars(select(Round).where(Round.id == 60))).one()
round_.status = "paying_out"
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["draw_waiting_since"] is None
async def test_unhandled_errors_use_the_structured_detail_shape(client):
"""B-24: the catch-all handler answered with a bare-string `detail`, while
app/api/errors.py documents detail as {"code", "message", "params"}. Clients then
had to special-case exactly the responses they understand least."""
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.main import log_unhandled_exception
app = FastAPI()
app.add_exception_handler(Exception, log_unhandled_exception)
@app.get("/boom")
async def boom():
raise RuntimeError("secret internal detail")
transport = ASGITransport(app=app, raise_app_exceptions=False)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
resp = await ac.get("/boom")
assert resp.status_code == 500
detail = resp.json()["detail"]
assert detail["code"] == "internal_error"
assert detail["message"] == "internal server error"
assert detail["params"] == {}
# The exception text belongs in logs/app.log, never in the response body.
assert "secret internal detail" not in resp.text
async def test_current_round_exposes_its_own_pool_address(client):
"""API-23 / RND-35 / B-80: GET /rounds/current carries the round's pool address, so
anyone can verify its money on an explorer; null when there is no round."""
from app.db.models import Round
from app.wallet.hd import derive_pool_address
ac, session_factory = client
resp = await ac.get("/rounds/current")
assert resp.json()["pool_address"] is None
async with session_factory() as session:
session.add(Round(id=5, status="open", pool_address=derive_pool_address(5)))
await session.commit()
resp = await ac.get("/rounds/current")
assert resp.json()["pool_address"] == derive_pool_address(5)
async def test_lottery_configured_is_false_when_the_amounts_could_not_pay_a_round(client): # B-79 (RND-40)
from sqlalchemy import select
from app.db.models import RoundConfig
ac, session_factory = client
async with session_factory() as session:
session.add(
RoundConfig(fee_address="plm1q5x25wd6q463mfhckjraaedgjg0lyu73qfcj43n", bet_amount_sats=5_000)
)
await session.commit()
body = (await ac.get("/rounds/current")).json()
assert body["lottery_configured"] is False and body["round_id"] is None
async with session_factory() as session:
(await session.scalars(select(RoundConfig))).one().bet_amount_sats = 1_000_000_000
await session.commit()
assert (await ac.get("/rounds/current")).json()["lottery_configured"] is True
async def test_current_round_shows_the_round_cost_while_active_and_the_configured_one_otherwise(client): # B-95 (API-21)
from datetime import datetime, timezone
from sqlalchemy import select
from app.db.models import Round, RoundConfig
ac, session_factory = client
async with session_factory() as session:
session.add(RoundConfig(fee_address="", bet_amount_sats=1_000_000_000))
session.add(Round(id=70, status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=1_000_000_000))
await session.commit()
async with session_factory() as session:
(await session.scalars(select(RoundConfig))).one().bet_amount_sats = 4_000_000_000 # edited mid-round
await session.commit()
assert (await ac.get("/rounds/current")).json()["bet_amount_sats"] == 1_000_000_000 # the round's own
async with session_factory() as session:
(await session.get(Round, 70)).status = "closed"
await session.commit()
assert (await ac.get("/rounds/current")).json()["bet_amount_sats"] == 4_000_000_000 # what the next one will cost