""" Deep behavioral tests for BackgroundJobScheduler user operations. Tests update_user_info, unregister_user, start/stop, and singleton management. """ from datetime import datetime, UTC from unittest.mock import Mock, patch import pytest from apscheduler.jobstores.base import JobLookupError from local_deep_research.scheduler.background import ( DocumentSchedulerSettings, BackgroundJobScheduler, ) @pytest.fixture(autouse=True) def reset_scheduler(): """Reset the singleton before and after each test.""" BackgroundJobScheduler._instance = None yield BackgroundJobScheduler._instance = None # --- update_user_info --- class TestUpdateUserInfo: """Tests for update_user_info behavior.""" def test_does_nothing_when_not_running(self): s = BackgroundJobScheduler() s.is_running = False s.update_user_info("user1", "pass1") assert "user1" not in s.user_sessions def test_creates_new_session(self): s = BackgroundJobScheduler() s.is_running = True with patch.object(s, "_schedule_user_subscriptions"): s.update_user_info("user1", "pass1") assert "user1" in s.user_sessions def test_stores_password_in_credential_store(self): s = BackgroundJobScheduler() s.is_running = True with patch.object(s, "_schedule_user_subscriptions"): s.update_user_info("user1", "mypass") assert "password" not in s.user_sessions["user1"] assert s._credential_store.retrieve("user1") == "mypass" def test_sets_last_activity(self): s = BackgroundJobScheduler() s.is_running = True with patch.object(s, "_schedule_user_subscriptions"): s.update_user_info("user1", "pass1") assert "last_activity" in s.user_sessions["user1"] assert isinstance(s.user_sessions["user1"]["last_activity"], datetime) def test_initializes_empty_scheduled_jobs(self): s = BackgroundJobScheduler() s.is_running = True with patch.object(s, "_schedule_user_subscriptions"): s.update_user_info("user1", "pass1") assert s.user_sessions["user1"]["scheduled_jobs"] == set() def test_calls_schedule_user_subscriptions(self): s = BackgroundJobScheduler() s.is_running = True with patch.object(s, "_schedule_user_subscriptions") as mock_sched: s.update_user_info("user1", "pass1") mock_sched.assert_called_once_with("user1") def test_updates_existing_password(self): s = BackgroundJobScheduler() s.is_running = True with patch.object(s, "_schedule_user_subscriptions"): s.update_user_info("user1", "pass1") s.update_user_info("user1", "pass2") assert s._credential_store.retrieve("user1") == "pass2" def test_updates_last_activity_for_existing(self): s = BackgroundJobScheduler() s.is_running = True with patch.object(s, "_schedule_user_subscriptions"): s.update_user_info("user1", "pass1") first_activity = s.user_sessions["user1"]["last_activity"] s.update_user_info("user1", "pass1") second_activity = s.user_sessions["user1"]["last_activity"] assert second_activity >= first_activity def test_reschedules_for_existing_user(self): s = BackgroundJobScheduler() s.is_running = True with patch.object(s, "_schedule_user_subscriptions") as mock_sched: s.update_user_info("user1", "pass1") s.update_user_info("user1", "pass1") assert mock_sched.call_count == 2 def test_multiple_users(self): s = BackgroundJobScheduler() s.is_running = True with patch.object(s, "_schedule_user_subscriptions"): s.update_user_info("user1", "pass1") s.update_user_info("user2", "pass2") assert "user1" in s.user_sessions assert "user2" in s.user_sessions # --- unregister_user --- class TestUnregisterUser: """Tests for unregister_user behavior.""" def test_removes_from_sessions(self): s = BackgroundJobScheduler() s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") s.unregister_user("user1") assert "user1" not in s.user_sessions def test_removes_scheduled_jobs(self): s = BackgroundJobScheduler() mock_scheduler = Mock() s.scheduler = mock_scheduler s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": {"job1", "job2"}, } s._credential_store.store("user1", "pass") s.unregister_user("user1") assert mock_scheduler.remove_job.call_count == 2 def test_handles_job_lookup_error(self): s = BackgroundJobScheduler() mock_scheduler = Mock() mock_scheduler.remove_job.side_effect = JobLookupError("job1") s.scheduler = mock_scheduler s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": {"job1"}, } s._credential_store.store("user1", "pass") # Should not raise s.unregister_user("user1") def test_invalidates_settings_cache(self): s = BackgroundJobScheduler() s._settings_cache["user1"] = DocumentSchedulerSettings() s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") s.unregister_user("user1") assert "user1" not in s._settings_cache def test_handles_nonexistent_user(self): s = BackgroundJobScheduler() # Should not raise s.unregister_user("nonexistent") def test_does_not_affect_other_users(self): s = BackgroundJobScheduler() s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass1") s.user_sessions["user2"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user2", "pass2") s.unregister_user("user1") assert "user2" in s.user_sessions # --- start --- class TestNewsSchedulerStart: """Tests for start method.""" def test_disabled_config_does_not_start(self): s = BackgroundJobScheduler() s.config["enabled"] = False s.start() assert s.is_running is False def test_already_running_does_not_restart(self): s = BackgroundJobScheduler() s.is_running = True mock_scheduler = Mock() s.scheduler = mock_scheduler s.start() mock_scheduler.start.assert_not_called() # --- stop --- class TestNewsSchedulerStop: """Tests for stop method.""" def test_clears_user_sessions(self): s = BackgroundJobScheduler() s.is_running = True s.user_sessions["user1"] = {"data": "test"} mock_scheduler = Mock() s.scheduler = mock_scheduler s.stop() assert s.user_sessions == {} def test_sets_is_running_false(self): s = BackgroundJobScheduler() s.is_running = True mock_scheduler = Mock() s.scheduler = mock_scheduler s.stop() assert s.is_running is False def test_calls_scheduler_shutdown(self): s = BackgroundJobScheduler() s.is_running = True mock_scheduler = Mock() s.scheduler = mock_scheduler s.stop() mock_scheduler.shutdown.assert_called_once_with(wait=True) def test_not_running_does_nothing(self): s = BackgroundJobScheduler() s.is_running = False mock_scheduler = Mock() s.scheduler = mock_scheduler s.stop() mock_scheduler.shutdown.assert_not_called() # --- _get_document_scheduler_settings --- class TestGetDocumentSchedulerSettings: """Tests for document scheduler settings retrieval with caching.""" def test_returns_defaults_without_session(self): s = BackgroundJobScheduler() result = s._get_document_scheduler_settings("unknown_user") assert result == DocumentSchedulerSettings.defaults() def test_returns_cached_settings(self): s = BackgroundJobScheduler() cached = DocumentSchedulerSettings(enabled=False) s._settings_cache["user1"] = cached result = s._get_document_scheduler_settings("user1") assert result is cached def test_force_refresh_bypasses_cache(self): s = BackgroundJobScheduler() cached = DocumentSchedulerSettings(enabled=False) s._settings_cache["user1"] = cached # Without a session, force_refresh returns defaults (can't query DB) result = s._get_document_scheduler_settings("user1", force_refresh=True) assert result == DocumentSchedulerSettings.defaults() def test_returns_defaults_on_no_session(self): s = BackgroundJobScheduler() result = s._get_document_scheduler_settings("user1") assert result.enabled is True assert result.interval_seconds == 1800 # --- get_document_scheduler_status --- class TestGetDocumentSchedulerStatus: """Tests for document scheduler status retrieval.""" def test_unknown_user_returns_disabled(self): s = BackgroundJobScheduler() result = s.get_document_scheduler_status("unknown") assert result["enabled"] is False assert "not found" in result["message"].lower() def test_known_user_returns_settings(self): s = BackgroundJobScheduler() s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") s._settings_cache["user1"] = DocumentSchedulerSettings() result = s.get_document_scheduler_status("user1") assert result["enabled"] is True assert result["interval_seconds"] == 1800 def test_includes_processing_options(self): s = BackgroundJobScheduler() s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") s._settings_cache["user1"] = DocumentSchedulerSettings( download_pdfs=True, extract_text=True, generate_rag=False ) result = s.get_document_scheduler_status("user1") assert result["processing_options"]["download_pdfs"] is True assert result["processing_options"]["extract_text"] is True assert result["processing_options"]["generate_rag"] is False def test_has_scheduled_job_true(self): s = BackgroundJobScheduler() s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": {"user1_document_processing"}, } s._credential_store.store("user1", "pass") s._settings_cache["user1"] = DocumentSchedulerSettings() result = s.get_document_scheduler_status("user1") assert result["has_scheduled_job"] is True def test_has_scheduled_job_false(self): s = BackgroundJobScheduler() s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") s._settings_cache["user1"] = DocumentSchedulerSettings() result = s.get_document_scheduler_status("user1") assert result["has_scheduled_job"] is False def test_user_active_status(self): s = BackgroundJobScheduler() s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") s._settings_cache["user1"] = DocumentSchedulerSettings() result = s.get_document_scheduler_status("user1") assert result["user_active"] is True # --- trigger_document_processing --- class TestTriggerDocumentProcessing: """Tests for manual document processing trigger.""" def test_returns_false_for_unknown_user(self): s = BackgroundJobScheduler() s.is_running = True result = s.trigger_document_processing("unknown") assert result is False def test_returns_false_when_not_running(self): s = BackgroundJobScheduler() s.is_running = False s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") result = s.trigger_document_processing("user1") assert result is False def test_schedules_immediate_job(self): s = BackgroundJobScheduler() s.is_running = True mock_scheduler = Mock() mock_job = Mock() mock_job.next_run_time = datetime.now(UTC) mock_scheduler.get_job.return_value = mock_job s.scheduler = mock_scheduler s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") result = s.trigger_document_processing("user1") assert result is True mock_scheduler.add_job.assert_called_once() def test_returns_false_on_job_verification_failure(self): s = BackgroundJobScheduler() s.is_running = True mock_scheduler = Mock() mock_scheduler.get_job.return_value = None # Verification fails s.scheduler = mock_scheduler s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") result = s.trigger_document_processing("user1") assert result is False def test_handles_exception(self): s = BackgroundJobScheduler() s.is_running = True mock_scheduler = Mock() mock_scheduler.add_job.side_effect = RuntimeError("scheduler error") s.scheduler = mock_scheduler s.user_sessions["user1"] = { "last_activity": datetime.now(UTC), "scheduled_jobs": set(), } s._credential_store.store("user1", "pass") result = s.trigger_document_processing("user1") assert result is False # --- initialize_with_settings --- class TestInitializeWithSettings: """Tests for settings initialization.""" def test_stores_settings_manager(self): s = BackgroundJobScheduler() sm = Mock() sm.get_setting = Mock(return_value=True) s.initialize_with_settings(sm) assert s.settings_manager is sm def test_loads_config_from_settings(self): s = BackgroundJobScheduler() sm = Mock() def mock_get(key, default=None): return { "news.scheduler.enabled": True, "news.scheduler.retention_hours": 72, "news.scheduler.cleanup_interval_hours": 2, "news.scheduler.max_jitter_seconds": 600, "news.scheduler.max_concurrent_jobs": 20, "news.scheduler.batch_size": 10, "news.scheduler.activity_check_interval": 10, }.get(key, default) sm.get_setting = mock_get s.initialize_with_settings(sm) assert s.config["retention_hours"] == 72 assert s.config["cleanup_interval_hours"] == 2 assert s.config["max_jitter_seconds"] == 600 def test_handles_settings_error(self): s = BackgroundJobScheduler() sm = Mock() sm.get_setting.side_effect = RuntimeError("DB error") # Should not raise, keeps default config s.initialize_with_settings(sm) assert s.config["enabled"] is True # Default still intact