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