|
1 | 1 | import asyncio |
| 2 | +import logging |
2 | 3 | import warnings |
3 | 4 | from datetime import UTC, datetime |
4 | | -from unittest.mock import AsyncMock, MagicMock |
| 5 | +from unittest.mock import AsyncMock, MagicMock, patch |
5 | 6 |
|
| 7 | +import anyio |
6 | 8 | import faststream.asgi.factories.asyncapi.try_it_out |
7 | 9 | import pytest |
8 | 10 | from faststream.exceptions import IncorrectState |
@@ -300,6 +302,119 @@ async def handler(body: str) -> None: ... |
300 | 302 | await asyncio.sleep(0.05) |
301 | 303 |
|
302 | 304 |
|
| 305 | +# --- topic must be non-empty (B8) --- |
| 306 | + |
| 307 | + |
| 308 | +async def test_publish_rejects_empty_topic() -> None: |
| 309 | + broker = TimersBroker(AsyncMock()) |
| 310 | + with pytest.raises(ValueError, match="topic must be a non-empty string"): |
| 311 | + await broker.publish("msg", topic="") |
| 312 | + |
| 313 | + |
| 314 | +async def test_cancel_timer_rejects_empty_topic() -> None: |
| 315 | + broker = TimersBroker(AsyncMock()) |
| 316 | + with pytest.raises(ValueError, match="topic must be a non-empty string"): |
| 317 | + await broker.cancel_timer("", "id") |
| 318 | + |
| 319 | + |
| 320 | +async def test_has_pending_rejects_empty_topic() -> None: |
| 321 | + broker = TimersBroker(AsyncMock()) |
| 322 | + with pytest.raises(ValueError, match="topic must be a non-empty string"): |
| 323 | + await broker.has_pending("", "id") |
| 324 | + |
| 325 | + |
| 326 | +async def test_get_pending_timers_rejects_empty_topic() -> None: |
| 327 | + broker = TimersBroker(AsyncMock()) |
| 328 | + with pytest.raises(ValueError, match="topic must be a non-empty string"): |
| 329 | + await broker.get_pending_timers("") |
| 330 | + |
| 331 | + |
| 332 | +async def test_cancel_all_rejects_empty_topic() -> None: |
| 333 | + broker = TimersBroker(AsyncMock()) |
| 334 | + with pytest.raises(ValueError, match="topic must be a non-empty string"): |
| 335 | + await broker.cancel_all("") |
| 336 | + |
| 337 | + |
| 338 | +# --- error path log format (B6) --- |
| 339 | + |
| 340 | + |
| 341 | +async def test_consume_logs_get_msgs_error_with_repr() -> None: |
| 342 | + """A Redis error during _get_msgs is logged at ERROR with `{e!r}` so the type and msg survive Sentry truncation.""" |
| 343 | + client = AsyncMock() |
| 344 | + client.ping.return_value = True |
| 345 | + client.zrangebyscore.side_effect = ConnectionError("redis down") |
| 346 | + broker = TimersBroker(client, start_timeout=2.0) |
| 347 | + |
| 348 | + @broker.subscriber("topic", polling_interval=0.05) |
| 349 | + async def handler(body: str) -> None: ... |
| 350 | + |
| 351 | + sub = next(iter(broker._subscribers)) # noqa: SLF001 |
| 352 | + log_calls: list[dict[str, object]] = [] |
| 353 | + sub._log = MagicMock(side_effect=lambda **kwargs: log_calls.append(kwargs)) # noqa: SLF001 # ty: ignore[invalid-assignment] |
| 354 | + |
| 355 | + async with broker: |
| 356 | + await asyncio.sleep(0.1) |
| 357 | + |
| 358 | + error_logs = [c for c in log_calls if c.get("log_level") == logging.ERROR] |
| 359 | + assert error_logs, "expected at least one ERROR log" |
| 360 | + msg = error_logs[0]["message"] |
| 361 | + assert isinstance(msg, str) |
| 362 | + assert "Message fetch error" in msg |
| 363 | + assert "ConnectionError('redis down')" in msg |
| 364 | + |
| 365 | + |
| 366 | +async def test_claim_and_consume_logs_unhandled_error_with_repr() -> None: |
| 367 | + """An unhandled error inside the limiter block is logged with `{raw_id!r}` and `{e!r}`.""" |
| 368 | + client = AsyncMock() |
| 369 | + broker = TimersBroker(client) |
| 370 | + sub = broker.subscriber("topic") |
| 371 | + await broker.connect() |
| 372 | + |
| 373 | + log_calls: list[dict[str, object]] = [] |
| 374 | + sub._log = MagicMock(side_effect=lambda **kwargs: log_calls.append(kwargs)) # noqa: SLF001 # ty: ignore[invalid-assignment] |
| 375 | + |
| 376 | + limiter = anyio.CapacityLimiter(1) |
| 377 | + raw_id = b"timer-1" |
| 378 | + |
| 379 | + with patch( |
| 380 | + "faststream_redis_timers.subscriber.usecase.eval_cached", |
| 381 | + new=AsyncMock(side_effect=RuntimeError("boom")), |
| 382 | + ): |
| 383 | + await sub._claim_and_consume(raw_id, 30, limiter, client) # noqa: SLF001 |
| 384 | + |
| 385 | + error_logs = [c for c in log_calls if c.get("log_level") == logging.ERROR] |
| 386 | + assert error_logs |
| 387 | + msg = error_logs[0]["message"] |
| 388 | + assert isinstance(msg, str) |
| 389 | + assert "b'timer-1'" in msg |
| 390 | + assert "RuntimeError('boom')" in msg |
| 391 | + |
| 392 | + |
| 393 | +# --- non-UTF-8 timer id recovery (B2) --- |
| 394 | + |
| 395 | + |
| 396 | +async def test_claim_and_consume_drops_non_utf8_id() -> None: |
| 397 | + """A non-UTF-8 raw_id is removed from both keys so polls recover instead of looping forever.""" |
| 398 | + client = AsyncMock() |
| 399 | + pipe = MagicMock() |
| 400 | + pipe.__aenter__ = AsyncMock(return_value=pipe) |
| 401 | + pipe.__aexit__ = AsyncMock(return_value=None) |
| 402 | + pipe.execute = AsyncMock(return_value=[1, 1]) |
| 403 | + client.pipeline = MagicMock(return_value=pipe) |
| 404 | + |
| 405 | + broker = TimersBroker(client) |
| 406 | + sub = broker.subscriber("topic") |
| 407 | + await broker.connect() |
| 408 | + bad_id = b"\xff\xfe-broken" |
| 409 | + limiter = anyio.CapacityLimiter(1) |
| 410 | + |
| 411 | + await sub._claim_and_consume(bad_id, 30, limiter, client) # noqa: SLF001 |
| 412 | + |
| 413 | + pipe.zrem.assert_called_once_with(sub._config.topic_timeline_key, bad_id) # noqa: SLF001 |
| 414 | + pipe.hdel.assert_called_once_with(sub._config.topic_payloads_key, bad_id) # noqa: SLF001 |
| 415 | + pipe.execute.assert_awaited_once() |
| 416 | + |
| 417 | + |
303 | 418 | # --- eval_cached NOSCRIPT fallback (O1) --- |
304 | 419 |
|
305 | 420 |
|
|
0 commit comments