From 1d9cc3925f966b229e0af57fbe59fead8590e594 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 20 Sep 2026 15:09:20 +0300 Subject: [PATCH] fix(testing): wire publishers to router-registered subscribers create_publisher_fake_subscriber scanned broker._subscribers, which omits every router's, so a router publisher never matched its real subscriber and got a fake built on the unprefixed topic. Behind a prefix that topic has no publisher, so publisher.mock recorded nothing. TimersBroker.subscribers narrows the base's return type, which also retires a cast in the fake producer. Closes #86 --- faststream_redis_timers/broker.py | 5 ++++ faststream_redis_timers/testing.py | 8 ++--- tests/test_unit.py | 47 +++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/faststream_redis_timers/broker.py b/faststream_redis_timers/broker.py index 91a35de..29b854a 100644 --- a/faststream_redis_timers/broker.py +++ b/faststream_redis_timers/broker.py @@ -112,6 +112,11 @@ class TimersBroker( _subscribers: list[TimersSubscriber] _publishers: list[TimersPublisher] + @property + @override + def subscribers(self) -> list[TimersSubscriber]: + return typing.cast("list[TimersSubscriber]", super().subscribers) + def __init__( # noqa: PLR0913 self, client: "RedisClient | None" = None, diff --git a/faststream_redis_timers/testing.py b/faststream_redis_timers/testing.py index 5c3008b..57c9f1e 100644 --- a/faststream_redis_timers/testing.py +++ b/faststream_redis_timers/testing.py @@ -44,13 +44,14 @@ def create_publisher_fake_subscriber( publisher: TimersPublisher, ) -> tuple[TimersSubscriber, bool]: subscriber: TimersSubscriber | None = None - for handler in broker._subscribers: # noqa: SLF001 + # `broker.subscribers`, not `_subscribers`: the latter omits every router's. + for handler in broker.subscribers: if handler._config.full_topic == publisher.config.full_topic: # noqa: SLF001 subscriber = handler break if subscriber is None: is_real = False - subscriber = broker.subscriber(publisher.config.topic) + subscriber = broker.subscriber(publisher.config.full_topic) else: is_real = True return subscriber, is_real @@ -123,8 +124,7 @@ async def publish(self, cmd: TimerPublishCommand) -> None: data=payload, ) for handler in self.broker.subscribers: - sub = typing.cast("TimersSubscriber", handler) - if sub._config.full_topic == topic: # noqa: SLF001 + if handler._config.full_topic == topic: # noqa: SLF001 await handler.process_message(msg) async def cancel(self, full_topic: str, timer_id: str) -> None: diff --git a/tests/test_unit.py b/tests/test_unit.py index 619171c..ae82a8b 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -2,7 +2,7 @@ import inspect import logging import warnings -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import anyio @@ -557,6 +557,51 @@ def test_create_publisher_fake_subscriber_is_instance_method() -> None: assert next(iter(sig.parameters)) == "self" +async def test_publisher_is_wired_to_a_subscriber_declared_on_a_prefixed_router() -> None: + """INVARIANT: a publisher is wired to the real subscriber wherever that subscriber was declared. + + `create_publisher_fake_subscriber` decides that by scanning the broker's subscribers, and + `broker._subscribers` holds only endpoints registered directly on the broker — a router's live + behind the `subscribers` property. Scanning the private list finds nothing for a router, so the + publisher is wired to a freshly built fake instead; behind a prefix that fake sits on the + unprefixed topic, which nothing publishes to, and the mock stays silent on a publish that + happened. Registering the fallback under the raw `topic` rather than `full_topic` breaks it the + same way. + """ + broker = TimersBroker() + router = TimersRouter(prefix="app:") + sub = router.subscriber("reminders") + + @sub + async def handle(body: str) -> None: ... + + publisher = router.publisher("reminders") + broker.include_router(router) + + async with TestTimersBroker(broker): + await publisher.publish("ping", activate_in=timedelta(0)) + publisher.mock.assert_called_once_with("ping") + + +@pytest.mark.parametrize("prefix", ["", "app:"], ids=["no-prefix", "prefixed"]) +async def test_router_publisher_does_not_add_a_second_subscriber(prefix: str) -> None: + """A publisher that already has a subscriber on its topic reuses it rather than building a fake.""" + broker = TimersBroker() + router = TimersRouter(prefix=prefix) + sub = router.subscriber("reminders") + + @sub + async def handle(body: str) -> None: ... + + router.publisher("reminders") + broker.include_router(router) + + async with TestTimersBroker(broker): + topics = [s._config.full_topic for s in broker.subscribers] # noqa: SLF001 + + assert topics == [f"{prefix}reminders"] + + # --- TimerStore ---