Files
plm-lottery/tests/unit/test_round_events.py
T
davideandClaude Sonnet 5 f229f91632 Add server-push (SSE) notifications for round/bet/balance state changes
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>
2026-07-23 10:52:07 +02:00

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