Frontend dashboards previously found out about state changes only on their next poll tick (up to 15s, or 3s during a draw) — this adds a push channel so updates land as soon as they happen instead. - app/rounds/events.py: a small in-process pub/sub (RoundEventBroadcaster). The message carries no payload — it's just a "something changed, go refetch" signal, so it needs no auth and no knowledge of who's allowed to see what; personalization stays entirely in the existing REST endpoints. - GET /rounds/stream: an SSE endpoint exposing that channel, with keep-alive comments so it survives idle periods behind a reverse proxy, and a defensive MAX_SUBSCRIBERS cap (well above the ~100 concurrent users expected) — past it, the endpoint returns 503 instead of opening a stream, and callers just keep working off polling. - publish() calls added at every point that actually changes what a dashboard would want to know: new round opened, round status transitions (closing/drawing/paying_out/closed), a bet or withdrawal broadcast, any pending tx confirming (bet/withdrawal/payout), a deposit credited, and a new block tip arriving (the exact moment the "drawing" phase is waiting on). - Caddyfile: excludes /rounds/stream from gzip encoding, since compression would buffer output and defeat the point of a live stream. Single-process only by design for now (no cross-worker fan-out) and the notification is a generic broadcast rather than a per-user channel — both are deliberate scope decisions for the current ~100-user, single-container deployment, not oversights. Polling is left fully in place as a fallback; this is purely additive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
63 lines
1.7 KiB
Python
63 lines
1.7 KiB
Python
import asyncio
|
|
|
|
import pytest
|
|
|
|
from app.rounds.events import RoundEventBroadcaster, RoundEventCapacityError
|
|
|
|
|
|
async def test_publish_wakes_up_subscriber():
|
|
broadcaster = RoundEventBroadcaster()
|
|
queue = broadcaster.subscribe()
|
|
|
|
broadcaster.publish()
|
|
|
|
await asyncio.wait_for(queue.get(), timeout=1)
|
|
|
|
|
|
async def test_publish_with_no_subscribers_does_not_raise():
|
|
broadcaster = RoundEventBroadcaster()
|
|
broadcaster.publish() # no subscribers yet — must be a no-op, not an error
|
|
|
|
|
|
async def test_publish_coalesces_when_subscriber_has_not_drained():
|
|
"""The queue is maxsize=1: a second publish() before the subscriber reads
|
|
the first notification must not block or raise — it's fine to drop it,
|
|
since the subscriber will refetch full state anyway on the first one."""
|
|
broadcaster = RoundEventBroadcaster()
|
|
queue = broadcaster.subscribe()
|
|
|
|
broadcaster.publish()
|
|
broadcaster.publish() # would raise QueueFull if not guarded
|
|
|
|
assert queue.qsize() == 1
|
|
|
|
|
|
async def test_unsubscribe_stops_delivery():
|
|
broadcaster = RoundEventBroadcaster()
|
|
queue = broadcaster.subscribe()
|
|
broadcaster.unsubscribe(queue)
|
|
|
|
broadcaster.publish()
|
|
|
|
assert queue.empty()
|
|
|
|
|
|
async def test_subscribe_rejects_past_the_cap():
|
|
broadcaster = RoundEventBroadcaster(max_subscribers=3)
|
|
for _ in range(3):
|
|
broadcaster.subscribe()
|
|
|
|
with pytest.raises(RoundEventCapacityError):
|
|
broadcaster.subscribe()
|
|
|
|
|
|
async def test_unsubscribe_frees_a_capacity_slot():
|
|
broadcaster = RoundEventBroadcaster(max_subscribers=1)
|
|
queue = broadcaster.subscribe()
|
|
|
|
with pytest.raises(RoundEventCapacityError):
|
|
broadcaster.subscribe()
|
|
|
|
broadcaster.unsubscribe(queue)
|
|
broadcaster.subscribe() # no longer at capacity
|