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>
343 lines
14 KiB
Python
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
|