Publish an SSE update from the bet/withdrawal rollback paths (B-49)

_release_failed_bet and _release_failed_withdrawal restored the balance, freed
the reserved UTXOs and (for a bet) removed the participant without calling
broadcaster.publish(), so every dashboard kept showing the phantom bet and the
reduced balance until its next poll — while the success path and the
reconciler's own abandon path both published.

The two regression tests pre-open the round before subscribing: place_bet opens
one itself, and that publish() would otherwise satisfy the assertion whether or
not the rollback published anything.
This commit is contained in:
2026-07-27 23:35:10 +02:00
parent 4c80c1c5bf
commit e7f844b11f
6 changed files with 78 additions and 28 deletions
+28
View File
@@ -8,6 +8,7 @@ from app.bets.service import BetError, place_bet
from app.config import settings
from app.db.base import Base
from app.db.models import AuditLog, PendingTransaction, Round, RoundConfig, RoundParticipant, User, UtxoEvent
from app.rounds.events import broadcaster
from app.rounds.service import open_new_round_if_needed
from app.wallet.hd import derive_user_address
from app.wallet.psbt_builder import MAX_TX_INPUTS
@@ -196,6 +197,33 @@ async def test_failed_broadcast_leaves_nothing_behind(session_factory):
assert "bet_placed" not in events
async def test_failed_broadcast_publishes_an_sse_update(session_factory): # B-49
"""The rollback moves as much state as the successful path does, so it must ping
the dashboards the same way — otherwise the phantom bet stays on screen until the
next poll."""
user_id = await _make_funded_user(session_factory, 21, 3_000_000_000)
async with session_factory() as session:
# Open the round up front: place_bet would otherwise open it itself, and that
# publish() would satisfy the assertion below whether or not the rollback ever
# published one of its own.
await open_new_round_if_needed(session)
await session.commit()
queue = broadcaster.subscribe()
try:
while not queue.empty():
queue.get_nowait()
async with session_factory() as session:
user = await session.get(User, user_id)
with pytest.raises(BetError, match="refused"):
await place_bet(session, RejectingElectrumClient(), user)
assert not queue.empty()
finally:
broadcaster.unsubscribe(queue)
async def test_failed_broadcast_reports_the_broadcast_failed_code(session_factory):
user_id = await _make_funded_user(session_factory, 5, 3_000_000_000)