diff --git a/app/publishers/Boosty/main.py b/app/publishers/Boosty/main.py index 27966a6..c6b4ca5 100644 --- a/app/publishers/Boosty/main.py +++ b/app/publishers/Boosty/main.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio +import contextlib import os from boosty_client import BoostyClient @@ -111,8 +112,19 @@ async def _refresh_loop(self) -> None: logger.warning(f"Boosty hourly token refresh failed: {e!r}") async def run(self) -> None: # type: ignore[override] + """Consumer-цикл + фоновый прогрев сессии. + + Прогрев живёт ровно столько же, сколько цикл: при остановке (отмена + снаружи, падение consumer'а) таск гасится, иначе он переживает + publisher и продолжает дёргать refresh уже никому не нужной сессии. + """ self._refresh_task = asyncio.create_task(self._refresh_loop()) - await super().run() + try: + await super().run() + finally: + self._refresh_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._refresh_task _publisher = BoostyPublisher() diff --git a/tests/unit/publishers/boosty/test_boosty_handler.py b/tests/unit/publishers/boosty/test_boosty_handler.py index edc774f..2f7bbb6 100644 --- a/tests/unit/publishers/boosty/test_boosty_handler.py +++ b/tests/unit/publishers/boosty/test_boosty_handler.py @@ -139,3 +139,41 @@ def test_model_dump_roundtrip(self, sample_boosty_event_dict): dump = event.model_dump() assert dump["tags"] == ["тест", "подкаст"] assert BoostyEvent(**dump) == event + + +class TestRunLifecycle: + """Фоновый refresh-таск не должен переживать consumer-цикл.""" + + @staticmethod + def _patch_base_run(monkeypatch, impl): + """Подменяет BasePublisher.run у того класса, от которого реально + наследуется publisher: main.py импортирует `shared.publishers.base` + (in-container путь), и это НЕ тот же модуль, что `app.shared...`. + """ + from app.publishers.Boosty import main + + monkeypatch.setattr(main.BasePublisher, "run", impl) + return main + + @pytest.mark.asyncio + async def test_refresh_task_cancelled_when_loop_fails(self, monkeypatch): + async def failing_run(self): + raise RuntimeError("consumer down") + + main = self._patch_base_run(monkeypatch, failing_run) + + with pytest.raises(RuntimeError): + await main._publisher.run() + + assert main._publisher._refresh_task.cancelled() + + @pytest.mark.asyncio + async def test_refresh_task_cancelled_on_clean_exit(self, monkeypatch): + async def clean_run(self): + return None + + main = self._patch_base_run(monkeypatch, clean_run) + + await main._publisher.run() + + assert main._publisher._refresh_task.cancelled()