"""Tests for the session cleanup background task. Validates that :func:`~app.tasks.session_cleanup._run_cleanup_with_resources` correctly calls the repository function to delete expired sessions and logs the results, and that :func:`~app.tasks.session_cleanup.register` configures the APScheduler job with the correct interval and stable job ID. """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.tasks.session_cleanup import ( JOB_ID, SESSION_CLEANUP_INTERVAL, register, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_app() -> MagicMock: """Build a minimal mock ``app`` for session cleanup task tests. Returns: A :class:`unittest.mock.MagicMock` that mimics ``fastapi.FastAPI``. """ app = MagicMock() app.state.scheduler = MagicMock() app.state.settings = MagicMock(database_path="/tmp/fake.db") app.state.runtime_settings = None return app # --------------------------------------------------------------------------- # Tests for _run_cleanup_with_resources # --------------------------------------------------------------------------- class TestRunCleanup: """Tests for :func:`~app.tasks.session_cleanup._run_cleanup_with_resources`.""" @pytest.mark.asyncio async def test_run_cleanup_calls_delete_expired_sessions(self) -> None: """``_run_cleanup_with_resources`` must call ``delete_expired_sessions`` with db and now_iso.""" settings = MagicMock(database_path="/tmp/fake.db") mock_db = MagicMock() with patch( "app.tasks.session_cleanup.task_db", MagicMock( return_value=AsyncMock( __aenter__=AsyncMock(return_value=mock_db), __aexit__=AsyncMock(return_value=False), ) ), ), patch( "app.tasks.session_cleanup.session_repo.delete_expired_sessions", new_callable=AsyncMock, return_value=0, ) as mock_delete: from app.tasks.session_cleanup import _run_cleanup_with_resources await _run_cleanup_with_resources(settings) mock_delete.assert_awaited_once() # Verify db was passed as first argument call_args = mock_delete.await_args assert call_args[0][0] == mock_db @pytest.mark.asyncio async def test_run_cleanup_logs_deleted_count(self) -> None: """``_run_cleanup_with_resources`` must log the count of deleted sessions.""" settings = MagicMock(database_path="/tmp/fake.db") with patch( "app.tasks.session_cleanup.task_db", MagicMock( return_value=AsyncMock( __aenter__=AsyncMock(return_value=MagicMock()), __aexit__=AsyncMock(return_value=False), ) ), ), patch( "app.tasks.session_cleanup.session_repo.delete_expired_sessions", new_callable=AsyncMock, return_value=42, ), patch( "app.tasks.session_cleanup.log" ) as mock_log: from app.tasks.session_cleanup import _run_cleanup_with_resources await _run_cleanup_with_resources(settings) # Verify the log.info call happened with deleted_count info_calls = [c for c in mock_log.info.call_args_list if c[0][0] == "session_cleanup_ran"] assert len(info_calls) == 1 assert info_calls[0][1]["deleted_count"] == 42 @pytest.mark.asyncio async def test_run_cleanup_logs_even_with_zero_deletions(self) -> None: """``_run_cleanup_with_resources`` must log even when no sessions were deleted.""" settings = MagicMock(database_path="/tmp/fake.db") with patch( "app.tasks.session_cleanup.task_db", MagicMock( return_value=AsyncMock( __aenter__=AsyncMock(return_value=MagicMock()), __aexit__=AsyncMock(return_value=False), ) ), ), patch( "app.tasks.session_cleanup.session_repo.delete_expired_sessions", new_callable=AsyncMock, return_value=0, ), patch( "app.tasks.session_cleanup.log" ) as mock_log: from app.tasks.session_cleanup import _run_cleanup_with_resources await _run_cleanup_with_resources(settings) info_calls = [c for c in mock_log.info.call_args_list if c[0][0] == "session_cleanup_ran"] assert len(info_calls) == 1 assert info_calls[0][1]["deleted_count"] == 0 # --------------------------------------------------------------------------- # Tests for register # --------------------------------------------------------------------------- class TestRegister: """Tests for :func:`~app.tasks.session_cleanup.register`.""" def test_register_adds_interval_job_to_scheduler(self) -> None: """``register`` must add a job with an ``"interval"`` trigger.""" app = _make_app() register(app) app.state.scheduler.add_job.assert_called_once() _, kwargs = app.state.scheduler.add_job.call_args assert kwargs["trigger"] == "interval" assert kwargs["seconds"] == SESSION_CLEANUP_INTERVAL def test_register_uses_stable_job_id(self) -> None: """``register`` must use the module-level ``JOB_ID`` constant.""" app = _make_app() register(app) _, kwargs = app.state.scheduler.add_job.call_args assert kwargs["id"] == JOB_ID def test_register_sets_replace_existing(self) -> None: """``register`` must use ``replace_existing=True`` to avoid duplicate jobs.""" app = _make_app() register(app) _, kwargs = app.state.scheduler.add_job.call_args assert kwargs["replace_existing"] is True def test_register_passes_settings_in_kwargs(self) -> None: """The scheduled job must receive settings as kwargs.""" app = _make_app() register(app) _, kwargs = app.state.scheduler.add_job.call_args assert "settings" in kwargs["kwargs"] def test_register_logs_scheduling_confirmation(self) -> None: """``register`` must emit an info log when scheduling the job.""" app = _make_app() with patch("app.tasks.session_cleanup.log") as mock_log: register(app) info_calls = [ c for c in mock_log.info.call_args_list if c[0][0] == "session_cleanup_scheduled" ] assert len(info_calls) == 1 assert info_calls[0][1]["interval_seconds"] == SESSION_CLEANUP_INTERVAL