Files
plm-lottery/tests/unit/test_admin.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

563 lines
25 KiB
Python

import pytest
from cryptography.fernet import Fernet
from httpx import ASGITransport, AsyncClient
from app.config import settings
# A real PLM bech32 address: PUT /admin/config now validates fee_address, since a
# foreign-chain address there would send every round's commission to a script
# nobody can spend (B-05).
_VALID_FEE_ADDRESS = "plm1q0xcqpzrky6eff2g52qdye53xkk9jxkvraxkkwd"
@pytest.fixture
async def client(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
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 create_async_engine
from app.db import base as db_base
db_base.engine = create_async_engine(settings.database_url)
from sqlalchemy.ext.asyncio import async_sessionmaker
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
# app.db.session did `from app.db.base import AsyncSessionLocal` at its own
# first import, which only copies the reference as it was at that moment —
# reassigning db_base.AsyncSessionLocal above doesn't reach it. get_session()
# looks up its module global at call time, so rebinding it here (every test)
# keeps it pointed at *this* test's engine instead of whichever ran first.
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 app.api.routes.admin import router as admin_router
from fastapi import FastAPI
app = FastAPI()
app.include_router(admin_router)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
await db_base.engine.dispose()
async def test_admin_requires_token(client):
resp = await client.get("/admin/config")
assert resp.status_code == 403
async def test_admin_rejects_wrong_token(client):
resp = await client.get("/admin/config", headers={"X-Admin-Token": "wrong"})
assert resp.status_code == 403
async def test_admin_rejects_non_ascii_token_with_403_not_500(client):
"""B-46: secrets.compare_digest raises TypeError on a non-ASCII str, which
used to bubble up as a 500 instead of the expected 403. httpx encodes str
header values as ASCII client-side, so the raw UTF-8 bytes are passed
directly to reproduce what a real non-ASCII header on the wire looks like."""
resp = await client.get("/admin/config", headers={"X-Admin-Token": "café".encode("utf-8")})
assert resp.status_code == 403
async def test_admin_reads_and_updates_config(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/config", headers=headers)
assert resp.status_code == 200
assert resp.json()["fee_address"] == ""
resp = await client.put(
"/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS, "bet_amount_sats": 500_000_000}
)
assert resp.status_code == 200
body = resp.json()
assert body["fee_address"] == _VALID_FEE_ADDRESS
assert body["bet_amount_sats"] == 500_000_000
resp = await client.get("/admin/config", headers=headers)
assert resp.json()["fee_address"] == _VALID_FEE_ADDRESS
async def test_admin_can_pause_and_resume_the_lottery(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/config", headers=headers)
assert resp.json()["paused"] is False
resp = await client.post("/admin/pause", headers=headers)
assert resp.status_code == 200
assert resp.json()["paused"] is True
resp = await client.get("/admin/config", headers=headers)
assert resp.json()["paused"] is True
resp = await client.post("/admin/resume", headers=headers)
assert resp.status_code == 200
assert resp.json()["paused"] is False
async def test_admin_pause_requires_token(client):
resp = await client.post("/admin/pause")
assert resp.status_code == 403
async def test_admin_lists_users(client):
from app.db import base as db_base
from app.db.models import User
from app.wallet.hd import derive_user_address
async with db_base.AsyncSessionLocal() as session:
session.add(
User(
username="alice",
password_hash="unused",
derivation_index=0,
address=derive_user_address(0),
cached_balance_sats=1_000_000_000,
)
)
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/users", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
assert body[0]["username"] == "alice"
assert body[0]["balance_sats"] == 1_000_000_000
async def test_admin_exports_user_privkey(client):
from embit.ec import PrivateKey
from app.db import base as db_base
from app.db.models import User
from app.wallet.hd import derive_user_address, derive_user_key
from app.wallet.plm_network import PLM_MAINNET
async with db_base.AsyncSessionLocal() as session:
user = User(
username="bob",
password_hash="unused",
derivation_index=1,
address=derive_user_address(1),
)
session.add(user)
await session.commit()
await session.refresh(user)
user_id = user.id
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get(f"/admin/users/{user_id}/privkey", headers=headers)
assert resp.status_code == 200
body = resp.json()
expected_wif = PrivateKey(derive_user_key(1).secret, compressed=True, network=PLM_MAINNET).wif(
network=PLM_MAINNET
)
assert body["wif"] == expected_wif
assert body["address"] == derive_user_address(1)
async def test_admin_privkey_404_for_unknown_user(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/users/999/privkey", headers=headers)
assert resp.status_code == 404
async def test_admin_resets_user_password(client):
from app.auth.security import hash_password, verify_password
from app.db import base as db_base
from app.db.models import User
from app.wallet.hd import derive_user_address
old_hash = hash_password("original-password")
async with db_base.AsyncSessionLocal() as session:
user = User(
username="carol",
password_hash=old_hash,
derivation_index=2,
address=derive_user_address(2),
)
session.add(user)
await session.commit()
await session.refresh(user)
user_id = user.id
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.post(f"/admin/users/{user_id}/reset-password", headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["username"] == "carol"
new_password = body["new_password"]
assert new_password and new_password != "original-password"
async with db_base.AsyncSessionLocal() as session:
refreshed = await session.get(User, user_id)
assert refreshed.password_hash != old_hash
assert verify_password(new_password, refreshed.password_hash)
assert not verify_password("original-password", refreshed.password_hash)
# B-34: the reset must bump token_version so a session opened before
# the reset (e.g. an attacker who had the old password) is evicted
# immediately rather than staying valid until the JWT naturally expires.
assert refreshed.token_version == 1
async def test_admin_reset_password_requires_token(client):
resp = await client.post("/admin/users/1/reset-password")
assert resp.status_code == 403
async def test_admin_reset_password_404_for_unknown_user(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.post("/admin/users/999/reset-password", headers=headers)
assert resp.status_code == 404
async def test_config_update_is_audit_logged(client):
"""B-10: /pause and /resume were logged but a config change wasn't, so the most
sensitive setting in the system — fee_address, where 30% of every pool goes —
could be changed without leaving any trace."""
headers = {"X-Admin-Token": "test-admin-token"}
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
resp = await client.get("/admin/audit-log", headers=headers)
entries = [e for e in resp.json() if e["event_type"] == "config_updated"]
assert len(entries) == 1
assert entries[0]["payload"]["fee_address"] == {"from": "", "to": _VALID_FEE_ADDRESS}
async def test_config_update_without_changes_logs_nothing(client):
headers = {"X-Admin-Token": "test-admin-token"}
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
await client.put("/admin/config", headers=headers, json={"fee_address": _VALID_FEE_ADDRESS})
resp = await client.get("/admin/audit-log", headers=headers)
assert len([e for e in resp.json() if e["event_type"] == "config_updated"]) == 1
@pytest.mark.parametrize(
"payload",
[
{"fee_address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"}, # valid bech32, wrong chain
{"fee_address": "plm1qbogus"}, # right HRP, broken checksum
{"fee_address": "garbage"},
{"fee_rate_sat_vb": 0}, # fee-less txs are never relayed: everything would stall
{"round_duration_seconds": 0}, # a round that expires the instant it opens
{"bet_amount_sats": -1},
{"rbf_timeout_seconds": 1},
{"draw_confirmations": 0}, # B-73: a draw with no confirmations is a draw on the tip
{"draw_confirmations": 21},
{"deposit_confirmations": 0}, # B-82: a deposit credited with no confirmation at all
{"deposit_confirmations": 101},
],
)
async def test_config_rejects_unusable_values(client, payload):
"""B-05: every one of these was accepted before. The bc1 case is the worst — it
parses as a valid witness program, so each round's commission would be broadcast
to a script nobody holds the key for."""
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.put("/admin/config", headers=headers, json=payload)
assert resp.status_code == 422
# and nothing was written
current = (await client.get("/admin/config", headers=headers)).json()
for field, value in payload.items():
assert current[field] != value
async def test_pause_cannot_be_toggled_through_the_config_endpoint(client):
"""B-10: `paused` used to be settable here, bypassing the audit-logged
pause/resume endpoints."""
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.put("/admin/config", headers=headers, json={"paused": True})
assert resp.status_code in (200, 422) # ignored or refused, but never applied
assert (await client.get("/admin/config", headers=headers)).json()["paused"] is False
@pytest.mark.parametrize("endpoint", ["/admin/rounds", "/admin/audit-log", "/admin/pending-transactions"])
@pytest.mark.parametrize("bad_limit", [0, -1, 501])
async def test_admin_list_endpoints_reject_out_of_range_limit(client, endpoint, bad_limit):
"""B-45: `limit` had no bounds — `-1` means "everything" on SQLite, so an
unvalidated limit could dump the entire table in one response."""
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get(endpoint, headers=headers, params={"limit": bad_limit})
assert resp.status_code == 422
async def test_admin_list_rounds_respects_limit(client):
from app.db import base as db_base
from app.db.models import Round
async with db_base.AsyncSessionLocal() as session:
session.add_all([Round(status="closed") for _ in range(3)])
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/rounds", headers=headers, params={"limit": 2})
assert resp.status_code == 200
assert len(resp.json()) == 2
async def test_admin_audit_log_respects_limit(client):
from app.db import base as db_base
from app.audit.log import write_audit_log
async with db_base.AsyncSessionLocal() as session:
for _ in range(3):
await write_audit_log(session, "test_event", {})
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/audit-log", headers=headers, params={"limit": 2})
assert resp.status_code == 200
assert len(resp.json()) == 2
async def _make_pending_transaction(session, *, kind="bet", status="pending"):
from app.db.models import PendingTransaction
import secrets as _secrets
tx = PendingTransaction(
kind=kind,
current_txid=_secrets.token_hex(32),
fee_rate_sat_vb=1,
raw_tx_hex="00",
status=status,
)
session.add(tx)
return tx
async def test_admin_pending_transactions_respects_limit(client):
from app.db import base as db_base
async with db_base.AsyncSessionLocal() as session:
for _ in range(3):
await _make_pending_transaction(session)
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get("/admin/pending-transactions", headers=headers, params={"limit": 2})
assert resp.status_code == 200
assert len(resp.json()) == 2
async def test_admin_pending_transactions_status_filter(client):
from app.db import base as db_base
async with db_base.AsyncSessionLocal() as session:
await _make_pending_transaction(session, status="pending")
await _make_pending_transaction(session, status="confirmed")
await _make_pending_transaction(session, status="failed")
await session.commit()
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.get(
"/admin/pending-transactions", headers=headers, params={"status": "confirmed"}
)
assert resp.status_code == 200
entries = resp.json()
assert len(entries) == 1
assert entries[0]["status"] == "confirmed"
async def test_admin_list_rounds_shows_each_rounds_pool_address(client):
"""API-36 / B-80: pool_address per round; null for a row without one."""
from app.db import base as db_base
from app.db.models import Round
from app.wallet.hd import derive_pool_address
async with db_base.AsyncSessionLocal() as session:
session.add(Round(id=1, status="closed", pool_address=derive_pool_address(1)))
session.add(Round(id=2, status="closed"))
await session.commit()
resp = await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"})
by_id = {row["id"]: row for row in resp.json()}
assert by_id[1]["pool_address"] == derive_pool_address(1)
assert by_id[2]["pool_address"] is None
async def test_draw_confirmations_default_to_3_and_are_editable_within_1_to_20(client):
"""B-73 (DAT-62, API-30, API-31): the number of blocks the draw waits for on its block."""
headers = {"X-Admin-Token": "test-admin-token"}
assert (await client.get("/admin/config", headers=headers)).json()["draw_confirmations"] == 3
resp = await client.put("/admin/config", headers=headers, json={"draw_confirmations": 6})
assert resp.status_code == 200 and resp.json()["draw_confirmations"] == 6
assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 1})).status_code == 200
assert (await client.put("/admin/config", headers=headers, json={"draw_confirmations": 20})).status_code == 200
async def test_admin_round_list_shows_the_commitment_but_never_the_secret(client):
"""B-73 (API-36): server_seed_hash and draw_height are listed, server_seed is not."""
from app.db import base as db_base
from app.db.models import Round
async with db_base.AsyncSessionLocal() as session:
session.add(Round(status="closed", server_seed_hash="ab" * 32, server_seed="cd" * 32, draw_height=77))
await session.commit()
rounds = (await client.get("/admin/rounds", headers={"X-Admin-Token": "test-admin-token"})).json()
assert rounds[0]["server_seed_hash"] == "ab" * 32 and rounds[0]["draw_height"] == 77
assert "server_seed" not in rounds[0] and "cd" * 32 not in str(rounds[0])
# --- B-79 (RND-40, API-31): a bet amount or fee rate that would make a round unpayable ---
@pytest.mark.parametrize(
"payload",
[
{"bet_amount_sats": 5_000}, # the platform's 30% cannot cover the reserve and dust
{"bet_amount_sats": 3_000}, # does not even pay for its own transaction
],
)
async def test_config_refuses_a_change_that_would_make_a_round_unpayable(client, payload):
headers = {"X-Admin-Token": "test-admin-token"}
before = (await client.get("/admin/config", headers=headers)).json()
resp = await client.put("/admin/config", headers=headers, json=payload)
assert resp.status_code == 422
detail = resp.json()["detail"]
assert detail["code"] == "config_not_payable" and detail["params"]["reason"]
assert (await client.get("/admin/config", headers=headers)).json() == before # nothing written
audit = (await client.get("/admin/audit-log", headers=headers)).json()
assert not [e for e in audit if e["event_type"] == "config_updated"]
async def test_a_fee_rate_raised_mid_round_cannot_make_the_round_unpayable(client):
"""The payout reads fee_rate_sat_vb when it is built, so the rate is the one live value
that can still break a round in progress — it is bounded by the same check, judged
together with the stored bet amount (and with a bet amount sent in the same request)."""
headers = {"X-Admin-Token": "test-admin-token"}
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
assert (await put({"bet_amount_sats": 50_000})).status_code == 200
assert (await put({"fee_rate_sat_vb": 10})).status_code == 200 # still payable: 1 bet nets 15_530
resp = await put({"fee_rate_sat_vb": 14}) # the 70% no longer covers the payout's network fee
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable"
assert (await client.get("/admin/config", headers=headers)).json()["fee_rate_sat_vb"] == 10
# merged with the same request: a larger bet makes the higher rate payable
resp = await put({"bet_amount_sats": 1_000_000, "fee_rate_sat_vb": 20})
assert resp.status_code == 200
# ... and the other way round, a bet that is fine alone is refused at the stored rate
assert (await put({"bet_amount_sats": 50_000})).status_code == 422
async def test_config_still_accepts_a_payable_change_and_unrelated_edits(client):
headers = {"X-Admin-Token": "test-admin-token"}
resp = await client.put(
"/admin/config", headers=headers, json={"bet_amount_sats": 100_000, "fee_rate_sat_vb": 2, "rbf_timeout_seconds": 120}
)
assert resp.status_code == 200
# an unrelated edit is never blocked by the payability check
assert (await client.put("/admin/config", headers=headers, json={"draw_animation_seconds": 5})).status_code == 200
async def test_deposit_confirmations_is_configurable_between_1_and_100(client): # B-82 (DAT-62, API-31)
headers = {"X-Admin-Token": "test-admin-token"}
assert (await client.get("/admin/config", headers=headers)).json()["deposit_confirmations"] == 6
for value in (1, 100, 12):
resp = await client.put("/admin/config", headers=headers, json={"deposit_confirmations": value})
assert resp.status_code == 200 and resp.json()["deposit_confirmations"] == value
audit = (await client.get("/admin/audit-log", headers=headers)).json()
changes = [e["payload"]["deposit_confirmations"] for e in audit if e["event_type"] == "config_updated"]
assert {"from": 100, "to": 12} in changes
async def test_resume_releases_a_payout_held_because_the_winners_bet_left_the_chain(client): # B-82 (DAT-68, API-32)
from datetime import datetime, timezone
from sqlalchemy import select
from app.db import base as db_base
from app.db.models import AuditLog, Round
headers = {"X-Admin-Token": "test-admin-token"}
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
await conn.run_sync(db_base.Base.metadata.create_all)
async with db_base.AsyncSessionLocal() as session:
session.add(Round(status="paying_out", payout_blocked_at=datetime.now(timezone.utc)))
session.add(Round(status="closed"))
await session.commit()
async with db_base.AsyncSessionLocal() as session:
from app.rounds.pause import pause_automatically
await pause_automatically(session, "payout_blocked")
await session.commit()
assert (await client.get("/admin/config", headers=headers)).json()["paused_reason"] == "payout_blocked"
listed = (await client.get("/admin/rounds", headers=headers)).json() # API-36: the mark is visible
assert sorted(r["payout_blocked_at"] is not None for r in listed) == [False, True]
resp = await client.post("/admin/resume", headers=headers)
assert resp.status_code == 200 and resp.json()["paused"] is False and resp.json()["paused_reason"] is None
async with db_base.AsyncSessionLocal() as session:
assert (await session.scalars(select(Round.payout_blocked_at))).all() == [None, None]
resumed = [e for e in (await session.scalars(select(AuditLog))).all() if e.event_type == "lottery_resumed"]
assert resumed and '"payout_blocked"' in resumed[0].payload_json
# --- B-95 (DAT-62, API-31): the cap on a round's pool ---------------------------------------------------------------
async def test_max_round_pool_sats_is_configurable_and_bounded(client):
headers = {"X-Admin-Token": "test-admin-token"}
assert (await client.get("/admin/config", headers=headers)).json()["max_round_pool_sats"] == 0
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
assert (await put({"max_round_pool_sats": 50_000_000_000})).json()["max_round_pool_sats"] == 50_000_000_000
assert (await put({"max_round_pool_sats": 0})).json()["max_round_pool_sats"] == 0 # 0 switches it off
assert (await put({"max_round_pool_sats": 4_000_000_000_000_000})).status_code == 200
assert (await put({"max_round_pool_sats": 4_000_000_000_000_001})).status_code == 422
assert (await put({"max_round_pool_sats": -1})).status_code == 422
async def test_a_cap_below_the_price_of_one_bet_is_refused_whichever_field_makes_it(client):
headers = {"X-Admin-Token": "test-admin-token"}
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
resp = await put({"max_round_pool_sats": 999_999_999}) # the bet costs 1_000_000_000
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
assert (await put({"max_round_pool_sats": 1_000_000_000})).status_code == 200 # exactly one bet fits
resp = await put({"bet_amount_sats": 2_000_000_000}) # now the bet would exceed the stored cap
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "max_round_pool_below_bet_amount"
assert (await put({"bet_amount_sats": 2_000_000_000, "max_round_pool_sats": 6_000_000_000})).status_code == 200
assert (await client.get("/admin/config", headers=headers)).json()["bet_amount_sats"] == 2_000_000_000
async def test_a_fee_rate_is_checked_against_the_bet_amount_of_the_round_in_progress(client):
"""B-95 with RND-40: the round in progress is paid with the cost it opened with, which can be
lower than the one configured now, so the fee rate has to leave that one payable too."""
from datetime import datetime, timezone
from app.db import base as db_base
from app.db.models import Round
headers = {"X-Admin-Token": "test-admin-token"}
put = lambda body: client.put("/admin/config", headers=headers, json=body) # noqa: E731
async with db_base.engine.begin() as conn: # the fixture creates the tables before the models are imported
await conn.run_sync(db_base.Base.metadata.create_all)
assert (await put({"bet_amount_sats": 100_000_000})).status_code == 200
async with db_base.AsyncSessionLocal() as session:
session.add(Round(status="open", opened_at=datetime.now(timezone.utc), bet_amount_sats=50_000)) # opened cheap
await session.commit()
# 14 sat/vB is fine for the configured 1 PLM (and for 100 PLM) but not for the 50_000 sat round
resp = await put({"fee_rate_sat_vb": 14})
assert resp.status_code == 422 and resp.json()["detail"]["code"] == "config_not_payable"
assert (await put({"fee_rate_sat_vb": 10})).status_code == 200