From 40ea23e904c289722182abf1765d97a182e7dde1 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 28 Jun 2026 23:07:35 +0000 Subject: [PATCH] gui: fix consumers_info coroutine usage + list-returning test mock + None-guard counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routes.py: change `async for ci in js.consumers_info(stream_name)` to `for ci in await js.consumers_info(stream_name)` — nats-py 2.14.0 consumers_info() is a plain coroutine returning list[ConsumerInfo], not an async iterable; the old form threw TypeError silently (swallowed by except), causing every stream to show "unavailable" and zero consumers. - test_consumers.py: replace async-generator mock with AsyncMock returning a list, matching the real API; also fix inline consumers_info_raising in the error test (remove dead yield); add explicit regression guard asserting consumer names appear in the template context. - consumers_list.html: guard num_pending/num_ack_pending/num_redelivered/ num_waiting with `… if … is not none else '—'` to prevent "None" in cells. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/central/gui/routes.py | 2 +- src/central/gui/templates/consumers_list.html | 8 +++---- tests/test_consumers.py | 23 +++++++++++-------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index d07230a..4b25fe2 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -2217,7 +2217,7 @@ async def consumers_list(request: Request) -> HTMLResponse: if js is not None: try: - async for ci in js.consumers_info(stream_name): + for ci in await js.consumers_info(stream_name): consumers.append({ "name": ci.name, "num_pending": ci.num_pending, diff --git a/src/central/gui/templates/consumers_list.html b/src/central/gui/templates/consumers_list.html index 9370d3b..252c1c4 100644 --- a/src/central/gui/templates/consumers_list.html +++ b/src/central/gui/templates/consumers_list.html @@ -32,10 +32,10 @@ has no active subscriber — it is safe to delete if it is not a central-owned c {% for c in stream.consumers %} {{ c.name }} - {{ c.num_pending }} - {{ c.num_ack_pending }} - {{ c.num_redelivered }} - {{ c.num_waiting }} + {{ c.num_pending if c.num_pending is not none else '—' }} + {{ c.num_ack_pending if c.num_ack_pending is not none else '—' }} + {{ c.num_redelivered if c.num_redelivered is not none else '—' }} + {{ c.num_waiting if c.num_waiting is not none else '—' }} {{ c.created.isoformat() if c.created else '—' }} {% if c.protected %} diff --git a/tests/test_consumers.py b/tests/test_consumers.py index 84d3c3e..b947b8f 100644 --- a/tests/test_consumers.py +++ b/tests/test_consumers.py @@ -25,14 +25,11 @@ def _make_consumer_info(name: str, num_pending: int = 0, num_ack_pending: int = def _make_js_with_consumers(consumers_by_stream: dict): - """Build a mock JetStreamContext whose consumers_info is an async generator.""" + """Build a mock JetStreamContext whose consumers_info is a coroutine returning a list.""" mock_js = MagicMock() - - async def consumers_info(stream_name): - for ci in consumers_by_stream.get(stream_name, []): - yield ci - - mock_js.consumers_info = consumers_info + mock_js.consumers_info = AsyncMock( + side_effect=lambda stream, **kw: consumers_by_stream.get(stream, []) + ) mock_js.consumer_info = AsyncMock() mock_js.delete_consumer = AsyncMock() return mock_js @@ -108,6 +105,13 @@ class TestConsumersListWithConsumers: assert meshai_c["num_pending"] == 1000 assert meshai_c["num_waiting"] == 0 + # Regression guard: consumer names must appear in the template context so + # they are rendered into the HTML body (guards against the coroutine/iterator + # bug where consumers_info was consumed as an async-iterable instead of awaited). + consumer_names_in_context = {c["name"] for c in wx["consumers"]} + assert "archive-CENTRAL_WX" in consumer_names_in_context + assert "meshai-wx" in consumer_names_in_context + @pytest.mark.asyncio async def test_stream_with_no_consumers_shows_empty(self): from central.gui.routes import consumers_list @@ -147,9 +151,8 @@ class TestConsumersListWithConsumers: async def consumers_info_raising(stream_name): if stream_name == "CENTRAL_FIRE": raise RuntimeError("stream not found") - # other streams: empty - return - yield # make it an async generator + # other streams: empty list (coroutine returning a list, not an async generator) + return [] mock_js.consumers_info = consumers_info_raising