2026-07-23 10:52:07 +02:00
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
2026-07-27 14:44:15 +02:00
|
|
|
from app.rounds.events import EVICTED, RoundEventBroadcaster, RoundEventCapacityError
|
2026-07-23 10:52:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-27 14:44:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- B-38: a single IP must not be able to exhaust the global cap and degrade
|
|
|
|
|
# every other user to polling. ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_subscribe_evicts_the_same_ips_oldest_connection_past_its_cap():
|
|
|
|
|
"""Past MAX_SUBSCRIBERS_PER_IP, one more stream from the *same* IP evicts
|
|
|
|
|
that IP's own oldest connection rather than being refused — bounds one
|
|
|
|
|
source's footprint without an outright block."""
|
|
|
|
|
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2)
|
|
|
|
|
first = broadcaster.subscribe("1.2.3.4")
|
|
|
|
|
second = broadcaster.subscribe("1.2.3.4")
|
|
|
|
|
|
|
|
|
|
third = broadcaster.subscribe("1.2.3.4") # past the per-IP cap of 2
|
|
|
|
|
|
|
|
|
|
assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED
|
|
|
|
|
assert second.empty() # untouched — only the oldest was evicted
|
|
|
|
|
assert third is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_subscribe_does_not_evict_across_different_ips():
|
|
|
|
|
"""A different IP hitting its own cap must never evict an unrelated IP's
|
|
|
|
|
connection — that would let one abusive source crowd out real users, which
|
|
|
|
|
is exactly what the global-only cap used to allow."""
|
|
|
|
|
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1)
|
|
|
|
|
other_ip_queue = broadcaster.subscribe("9.9.9.9")
|
|
|
|
|
|
|
|
|
|
broadcaster.subscribe("1.2.3.4")
|
|
|
|
|
broadcaster.subscribe("1.2.3.4") # evicts 1.2.3.4's own oldest, not 9.9.9.9's
|
|
|
|
|
|
|
|
|
|
assert other_ip_queue.empty()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_subscribe_still_enforces_the_global_cap_across_many_ips():
|
|
|
|
|
"""The per-IP cap doesn't replace the global backstop — spreading across
|
|
|
|
|
enough distinct IPs must still eventually hit MAX_SUBSCRIBERS."""
|
|
|
|
|
broadcaster = RoundEventBroadcaster(max_subscribers=3, max_per_ip=1)
|
|
|
|
|
broadcaster.subscribe("1.1.1.1")
|
|
|
|
|
broadcaster.subscribe("2.2.2.2")
|
|
|
|
|
broadcaster.subscribe("3.3.3.3")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(RoundEventCapacityError):
|
|
|
|
|
broadcaster.subscribe("4.4.4.4")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_unsubscribe_clears_the_per_ip_tracking_too():
|
|
|
|
|
"""Regression guard: unsubscribe must forget the queue's IP association, or
|
|
|
|
|
a churned-through connection would keep counting against that IP's cap
|
|
|
|
|
forever."""
|
|
|
|
|
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=1)
|
|
|
|
|
queue = broadcaster.subscribe("1.2.3.4")
|
|
|
|
|
broadcaster.unsubscribe(queue)
|
|
|
|
|
|
|
|
|
|
broadcaster.subscribe("1.2.3.4") # must not evict anything — nothing left to evict
|
|
|
|
|
assert queue.empty()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_subscribe_defaults_to_a_shared_ip_when_none_given():
|
|
|
|
|
"""Existing callers (and most tests) that don't care about IP isolation
|
|
|
|
|
still share one implicit bucket rather than needing every call updated."""
|
|
|
|
|
broadcaster = RoundEventBroadcaster(max_subscribers=100, max_per_ip=2)
|
|
|
|
|
first = broadcaster.subscribe()
|
|
|
|
|
broadcaster.subscribe()
|
|
|
|
|
|
|
|
|
|
broadcaster.subscribe() # past the default bucket's cap of 2 — evicts, doesn't raise
|
|
|
|
|
|
|
|
|
|
assert await asyncio.wait_for(first.get(), timeout=1) is EVICTED
|