"""Tests for the in-process EventBus. These exercise the drop-oldest overflow strategy, per-kind fan-out, and the non-blocking publish contract. They are async because the bus exposes an async iterator for subscribers. """ from __future__ import annotations import asyncio import time import pytest from cyclone.pubsub import EventBus, get_event_bus @pytest.mark.asyncio async def test_publish_no_subscribers_is_noop(): bus = EventBus() # No subscribers at all — must not raise. await bus.publish("x", {"a": 1}) @pytest.mark.asyncio async def test_publish_to_n_subscribers_delivers_to_all(): bus = EventBus() queues = [bus.subscribe(["claim"]) for _ in range(3)] await bus.publish("claim", {"id": "C1"}) received = [await asyncio.wait_for(q.__anext__(), timeout=0.5) for q in queues] for event in received: assert event == {"id": "C1", "_kind": "claim"} @pytest.mark.asyncio async def test_subscriber_only_receives_matching_kinds(): bus = EventBus() queue = bus.subscribe(["a"]) await bus.publish("b", {"n": 1}) await bus.publish("a", {"n": 2}) event = await asyncio.wait_for(queue.__anext__(), timeout=0.5) assert event == {"n": 2, "_kind": "a"} @pytest.mark.asyncio async def test_slow_subscriber_does_not_block_publish(): bus = EventBus(max_queue_size=2) queue = bus.subscribe(["e"]) async def emit(): for i in range(3): await bus.publish("e", {"i": i}) start = time.monotonic() await emit() elapsed = time.monotonic() - start # Publishing must not block even though the subscriber queue is full # after the second event; the drop-oldest policy keeps publishes non-blocking. assert elapsed < 0.05, f"publish blocked for {elapsed:.3f}s" # The slow subscriber retains only the last two events. seen = [] for _ in range(2): seen.append(await asyncio.wait_for(queue.__anext__(), timeout=0.5)) assert [e["i"] for e in seen] == [1, 2] @pytest.mark.asyncio async def test_queue_overflow_drops_oldest(): bus = EventBus(max_queue_size=2) queue = bus.subscribe(["e"]) for i in range(3): await bus.publish("e", {"i": i}) seen = [] for _ in range(2): seen.append(await asyncio.wait_for(queue.__anext__(), timeout=0.5)) assert [e["i"] for e in seen] == [1, 2] @pytest.mark.asyncio async def test_multiple_concurrent_subscribers_each_get_all_events(): bus = EventBus() queues = [bus.subscribe(["claim"]) for _ in range(2)] for i in range(5): await bus.publish("claim", {"i": i}) for q in queues: seen = [] for _ in range(5): seen.append(await asyncio.wait_for(q.__anext__(), timeout=0.5)) assert [e["i"] for e in seen] == [0, 1, 2, 3, 4] def test_get_event_bus_raises_when_app_state_uninitialized(): """get_event_bus() late-imports cyclone.api; without a running app, accessing app.state.event_bus raises AttributeError.""" # Don't import cyclone.api at module level — that would force a load # and could mask the real failure. Just call the helper and expect # the AttributeError to surface from app.state. with pytest.raises((AttributeError, RuntimeError)): get_event_bus()