"""unit test: run_bounded_cycle session lifetime, budget, and pass-through run from the repo root: uv run pytest tests/test_run_bounded_cycle.py offline — get_default_session is faked at the runner's namespace so no proxies or network are touched; run_monitor_cycle underneath runs real. Gap: the real session wiring (timeout/max_clients reaching curl) is covered by the monitors' own pool-size tests, not here. """ import asyncio import logging import aiohttp import pytest from monitor_utils import runner from monitor_utils.models import CycleResult from monitor_utils.runner import run_bounded_cycle class FakeSession: def __init__(self): self.closed = False async def __aenter__(self): return self async def __aexit__(self, *exc): self.closed = True def stub_session(monkeypatch) -> dict: """Fake get_default_session, recording each call's kwargs and session.""" calls = [] def fake(proxies, timeout=None, max_clients=None): session = FakeSession() calls.append( {"timeout": timeout, "max_clients": max_clients, "session": session} ) return session monkeypatch.setattr(runner, "get_default_session", fake) return calls def test_returns_cycle_result_and_closes_session(monkeypatch): calls = stub_session(monkeypatch) async def task(category, session, collection, **kwargs): pass result = asyncio.run(run_bounded_cycle(["a", "b"], task, None, None)) assert result == CycleResult(successes=2, failures=0) assert calls[0]["session"].closed def test_session_params_reach_the_factory(monkeypatch): calls = stub_session(monkeypatch) async def task(category, session, collection, **kwargs): pass asyncio.run( run_bounded_cycle( ["a"], task, None, None, session_timeout=8, max_clients=81 ) ) assert calls[0]["timeout"] == 8 assert calls[0]["max_clients"] == 81 def test_expired_budget_returns_none_not_raise(monkeypatch, caplog): """A cycle past its budget is abandoned and logged, and the loop lives on.""" calls = stub_session(monkeypatch) async def parked(category, session, collection, **kwargs): await asyncio.Event().wait() with caplog.at_level(logging.ERROR, logger="monitor_utils.runner"): result = asyncio.run( run_bounded_cycle(["a"], parked, None, None, cycle_timeout=0.05) ) assert result is None assert calls[0]["session"].closed assert any( r.message == "cycle exceeded budget, abandoning cycle" for r in caplog.records ) def test_expected_task_error_counts_as_failure(monkeypatch): stub_session(monkeypatch) async def flaky(category, session, collection, **kwargs): raise aiohttp.ClientError("routine") result = asyncio.run(run_bounded_cycle(["a"], flaky, None, None)) assert result == CycleResult(successes=0, failures=1) def test_unexpected_error_propagates_and_closes_session(monkeypatch): """fail loud: a real bug must escape the helper, not vanish into a log.""" calls = stub_session(monkeypatch) async def broken(category, session, collection, **kwargs): raise RuntimeError("bug") with pytest.raises(RuntimeError): asyncio.run(run_bounded_cycle(["a"], broken, None, None)) assert calls[0]["session"].closed def test_each_call_opens_a_fresh_session(monkeypatch): calls = stub_session(monkeypatch) async def task(category, session, collection, **kwargs): pass async def two_cycles(): await run_bounded_cycle(["a"], task, None, None) await run_bounded_cycle(["a"], task, None, None) asyncio.run(two_cycles()) assert len(calls) == 2 assert calls[0]["session"] is not calls[1]["session"]