Files
plm-lottery/tests/unit/test_rounds_route.py
T
davideandClaude Sonnet 5 7fa26df104 Make a stalled draw wait observable (B-36)
_wait_for_next_block had no timeout, no log, and no audit entry: a
connection that stopped advancing the tip left a round silently frozen
in "drawing" with nothing in /admin to explain why. Log progress
periodically, write a draw_stalled audit entry past a threshold (a few
block-time multiples), and surface the wait via a new Round.drawing_started_at
column, exposed as draw_waiting_since in GET /rounds/current.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:12:27 +02:00

194 lines
7.3 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_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