Skip to content

Commit 4b8aec9

Browse files
author
Review
committed
test(ex_app): cover the new sync->async bridges
Add unit tests for the new code paths introduced when the logger handler and ``fetch_models_task`` were taught to schedule async :py:meth:`NextcloudApp.log` and :py:meth:`NextcloudApp.set_init_status` calls onto a captured event loop. These exercise the branches that the integration suite cannot reach: * ``_ProgressReporter`` with no captured loop (mock and real coroutine paths), with a closed loop, with errors raised by the scheduled coroutine, and the happy-path scheduling against a running loop via ``asyncio.to_thread``. * ``_NextcloudLogsHandler.emit`` short-circuiting when no loop / closed loop is attached, plus the dispatch path that observes the formatted record landing on the captured loop. * ``setup_nextcloud_logging`` capturing the running loop when called from async context and returning an inert handler when called outside one. * ``fetch_models_task`` accepting an explicit ``loop`` argument and falling back to ``asyncio.get_running_loop`` when invoked via ``to_thread``. Restores codecov coverage above the 1% drop threshold.
1 parent 76e9f84 commit 4b8aec9

1 file changed

Lines changed: 213 additions & 0 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Unit tests for the sync->async bridges added in Phase 2.
2+
3+
Cover the branches the integration tests can't reach: invocation without a
4+
running loop, scheduling failures on the captured loop, and the logging
5+
handler's loop-capture / disabled paths.
6+
"""
7+
8+
import asyncio
9+
import logging
10+
import warnings
11+
from unittest import mock
12+
13+
import pytest
14+
15+
16+
def _async_mock(return_value=None):
17+
"""Return a mock whose call result is an awaitable (coroutine)."""
18+
19+
async def _coro(*args, **kwargs):
20+
return return_value
21+
22+
return mock.MagicMock(side_effect=_coro)
23+
24+
25+
class TestProgressReporter:
26+
def test_calls_set_init_status_with_progress_only_when_no_error(self):
27+
from nc_py_api.ex_app.integration_fastapi import _ProgressReporter
28+
29+
nc = mock.MagicMock()
30+
reporter = _ProgressReporter(nc, loop=None)
31+
reporter(42)
32+
assert nc.set_init_status.call_args_list == [mock.call(42)]
33+
34+
def test_passes_error_when_present(self):
35+
from nc_py_api.ex_app.integration_fastapi import _ProgressReporter
36+
37+
nc = mock.MagicMock()
38+
reporter = _ProgressReporter(nc, loop=None)
39+
reporter(50, "boom")
40+
assert nc.set_init_status.call_args_list == [mock.call(50, "boom")]
41+
42+
def test_closes_orphan_coroutine_when_loop_unavailable(self):
43+
from nc_py_api.ex_app.integration_fastapi import _ProgressReporter
44+
45+
nc = mock.MagicMock()
46+
nc.set_init_status = _async_mock()
47+
reporter = _ProgressReporter(nc, loop=None)
48+
with warnings.catch_warnings():
49+
warnings.simplefilter("error") # promote "coroutine never awaited" to error
50+
reporter(75)
51+
nc.set_init_status.assert_called_once()
52+
53+
def test_closes_orphan_coroutine_when_loop_closed(self):
54+
from nc_py_api.ex_app.integration_fastapi import _ProgressReporter
55+
56+
loop = asyncio.new_event_loop()
57+
loop.close()
58+
nc = mock.MagicMock()
59+
nc.set_init_status = _async_mock()
60+
reporter = _ProgressReporter(nc, loop=loop)
61+
with warnings.catch_warnings():
62+
warnings.simplefilter("error")
63+
reporter(80)
64+
65+
def test_schedules_on_running_loop_and_waits_for_result(self):
66+
from nc_py_api.ex_app.integration_fastapi import _ProgressReporter
67+
68+
seen = []
69+
70+
async def fake_set_init_status(progress, error=""):
71+
seen.append((progress, error))
72+
73+
nc = mock.MagicMock()
74+
nc.set_init_status = fake_set_init_status
75+
76+
async def driver():
77+
loop = asyncio.get_running_loop()
78+
reporter = _ProgressReporter(nc, loop)
79+
await asyncio.to_thread(reporter, 33)
80+
await asyncio.to_thread(reporter, 66, "warn")
81+
82+
asyncio.run(driver())
83+
assert seen == [(33, ""), (66, "warn")]
84+
85+
def test_swallows_scheduling_errors(self):
86+
from nc_py_api.ex_app.integration_fastapi import _ProgressReporter
87+
88+
async def boom(*_args, **_kw):
89+
raise RuntimeError("nope")
90+
91+
nc = mock.MagicMock()
92+
nc.set_init_status = boom
93+
94+
async def driver():
95+
loop = asyncio.get_running_loop()
96+
reporter = _ProgressReporter(nc, loop)
97+
await asyncio.to_thread(reporter, 90) # must not propagate the RuntimeError
98+
99+
asyncio.run(driver())
100+
101+
102+
class TestNextcloudLogsHandler:
103+
def test_emit_is_noop_without_captured_loop(self):
104+
from nc_py_api.ex_app.logger import _NextcloudLogsHandler
105+
106+
handler = _NextcloudLogsHandler(loop=None)
107+
record = logging.LogRecord("name", logging.INFO, "p", 1, "msg", None, None)
108+
with mock.patch("nc_py_api.ex_app.logger.NextcloudApp") as nc_app_cls:
109+
handler.emit(record)
110+
nc_app_cls.assert_not_called()
111+
112+
def test_emit_is_noop_when_loop_closed(self):
113+
from nc_py_api.ex_app.logger import _NextcloudLogsHandler
114+
115+
loop = asyncio.new_event_loop()
116+
loop.close()
117+
handler = _NextcloudLogsHandler(loop=loop)
118+
record = logging.LogRecord("name", logging.INFO, "p", 1, "msg", None, None)
119+
with mock.patch("nc_py_api.ex_app.logger.NextcloudApp") as nc_app_cls:
120+
handler.emit(record)
121+
nc_app_cls.assert_not_called()
122+
123+
def test_emit_dispatches_to_loop(self):
124+
from nc_py_api.ex_app.logger import LogLvl, _NextcloudLogsHandler
125+
126+
seen: list[tuple] = []
127+
128+
async def fake_log(level, message, fast_send=False):
129+
seen.append((int(level), message, fast_send))
130+
131+
nc_instance = mock.MagicMock()
132+
nc_instance.log = fake_log
133+
134+
async def driver():
135+
loop = asyncio.get_running_loop()
136+
handler = _NextcloudLogsHandler(loop=loop)
137+
handler.setFormatter(logging.Formatter("%(message)s"))
138+
record = logging.LogRecord("name", logging.WARNING, "p", 1, "hello", None, None)
139+
with mock.patch("nc_py_api.ex_app.logger.NextcloudApp", return_value=nc_instance):
140+
await asyncio.to_thread(handler.emit, record)
141+
# Yield to let the scheduled coroutine run.
142+
for _ in range(10):
143+
if seen:
144+
break
145+
await asyncio.sleep(0.01)
146+
147+
asyncio.run(driver())
148+
assert seen == [(int(LogLvl.WARNING), "hello", True)]
149+
150+
151+
class TestSetupNextcloudLogging:
152+
def test_returns_inert_handler_outside_running_loop(self):
153+
from nc_py_api.ex_app.logger import (
154+
_NextcloudLogsHandler,
155+
setup_nextcloud_logging,
156+
)
157+
158+
logger = logging.getLogger("nc_py_api_test_inert")
159+
logger.handlers.clear()
160+
try:
161+
handler = setup_nextcloud_logging("nc_py_api_test_inert")
162+
assert isinstance(handler, _NextcloudLogsHandler)
163+
assert handler._loop is None # noqa: SLF001 - intentional
164+
assert handler in logger.handlers
165+
finally:
166+
logger.handlers.clear()
167+
168+
def test_captures_running_loop(self):
169+
from nc_py_api.ex_app.logger import setup_nextcloud_logging
170+
171+
async def driver():
172+
logger = logging.getLogger("nc_py_api_test_loop")
173+
logger.handlers.clear()
174+
try:
175+
handler = setup_nextcloud_logging("nc_py_api_test_loop")
176+
assert handler._loop is asyncio.get_running_loop() # noqa: SLF001
177+
finally:
178+
logger.handlers.clear()
179+
180+
asyncio.run(driver())
181+
182+
183+
class TestFetchModelsTaskLoopParam:
184+
def test_passes_loop_to_progress_reporter(self):
185+
"""``loop`` defaults to None outside an async context but can be provided."""
186+
from nc_py_api.ex_app.integration_fastapi import fetch_models_task
187+
188+
nc = mock.MagicMock()
189+
# No models -> only the terminal ``set_init_status(100)`` should fire.
190+
fetch_models_task(nc, {}, 0)
191+
assert nc.set_init_status.call_args_list == [mock.call(100)]
192+
193+
def test_uses_explicit_loop_when_provided(self):
194+
from nc_py_api.ex_app.integration_fastapi import fetch_models_task
195+
196+
loop = asyncio.new_event_loop()
197+
try:
198+
nc = mock.MagicMock()
199+
fetch_models_task(nc, {}, 50, loop)
200+
assert nc.set_init_status.called
201+
finally:
202+
loop.close()
203+
204+
205+
@pytest.mark.asyncio
206+
async def test_fetch_models_task_inside_loop_picks_up_current():
207+
"""When called from an async context with no explicit loop, ``fetch_models_task``
208+
auto-detects the running loop."""
209+
from nc_py_api.ex_app.integration_fastapi import fetch_models_task
210+
211+
nc = mock.MagicMock()
212+
await asyncio.to_thread(fetch_models_task, nc, {}, 0)
213+
assert nc.set_init_status.called

0 commit comments

Comments
 (0)