""" Coverage tests for app_factory.py targeting ~37 missing statements. Focuses on: - register_error_handlers: 404/500 JSON vs HTML branching, CSRF handler, news API exception handler - create_app: secret key file read/write branches, news scheduler enabled/disabled paths - apply_middleware: teardown cleanup_db_session, WebSocket handler - create_database: deprecated no-op - register_blueprints: CSRF exemption path """ import pytest from unittest.mock import MagicMock, patch from flask import Flask MODULE = "local_deep_research.web.app_factory" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _minimal_app(): """Minimal Flask app suitable for registering error handlers.""" app = Flask(__name__) app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False app.config["SECRET_KEY"] = "test-secret" return app # --------------------------------------------------------------------------- # 1. register_error_handlers # --------------------------------------------------------------------------- class TestRegisterErrorHandlers: """Tests for the register_error_handlers function.""" @pytest.fixture def app_with_handlers(self): from local_deep_research.web.app_factory import register_error_handlers app = _minimal_app() register_error_handlers(app) return app @pytest.fixture def client(self, app_with_handlers): app_with_handlers.config["PROPAGATE_EXCEPTIONS"] = False # Add a route that raises to test 500 handler @app_with_handlers.route("/boom") def boom(): raise RuntimeError("intentional error") # Add a plain 404 trigger for non-API path return app_with_handlers.test_client() def test_404_api_path_returns_json(self, client): """404 on /api/ path returns JSON body.""" response = client.get("/api/does-not-exist") assert response.status_code == 404 data = response.get_json() assert data is not None assert "error" in data def test_404_non_api_path_returns_text(self, client): """404 on a non-API path returns plain text, not JSON.""" response = client.get("/totally-missing") assert response.status_code == 404 # Should NOT be JSON assert response.content_type.startswith("text/") def test_500_api_path_returns_json(self, client): """500 on /api/ path returns JSON body.""" # Patch the boom route under /api/ app = client.application @app.route("/api/error-test") def api_error(): raise RuntimeError("api boom") response = client.get("/api/error-test") assert response.status_code == 500 data = response.get_json() assert data is not None assert "error" in data def test_500_non_api_path_returns_text(self, client): """500 on a non-API path returns plain text.""" response = client.get("/boom") assert response.status_code == 500 assert response.content_type.startswith("text/") def test_csrf_error_handler_registered(self, app_with_handlers): """CSRFError handler is registered when flask_wtf is available.""" try: from flask_wtf.csrf import CSRFError # handler should be in the error_handler_spec spec = app_with_handlers.error_handler_spec # Flatten all registered exception-class keys from inner handler maps all_exc_keys = [] for blueprint_handlers in spec.values(): for code_or_exc, handler_map in blueprint_handlers.items(): # handler_map maps exception class -> handler function if isinstance(handler_map, dict): all_exc_keys.extend(handler_map.keys()) # CSRFError should appear somewhere in registered handlers has_csrf = any( (isinstance(k, type) and issubclass(k, CSRFError)) or k == CSRFError for k in all_exc_keys ) assert has_csrf except ImportError: pytest.skip("flask_wtf not available") def test_csrf_error_handler_returns_400(self): """CSRF error handler returns 400 with JSON error message.""" from local_deep_research.web.app_factory import register_error_handlers try: from flask_wtf.csrf import CSRFProtect # noqa: F401 except ImportError: pytest.skip("flask_wtf not available") app = _minimal_app() app.config["WTF_CSRF_ENABLED"] = True app.config["SECRET_KEY"] = "test-secret-key-for-csrf" CSRFProtect(app) register_error_handlers(app) # Add a POST route that requires CSRF @app.route("/form-post", methods=["POST"]) def form_post(): return "ok" client = app.test_client() # Posting without a CSRF token on a CSRF-protected app triggers CSRFError app.config["WTF_CSRF_ENABLED"] = True response = client.post("/form-post", data={}) assert response.status_code == 400 data = response.get_json() assert "error" in data class TestNewsApiExceptionHandler: """Tests for the NewsAPIException error handler.""" def test_news_api_exception_handler_registered_when_module_available(self): """NewsAPIException handler is registered when news module is available.""" from local_deep_research.web.app_factory import register_error_handlers app = _minimal_app() register_error_handlers(app) try: from local_deep_research.news.exceptions import NewsAPIException spec = app.error_handler_spec all_exc_keys = [] for blueprint_handlers in spec.values(): for code_or_exc, handler_map in blueprint_handlers.items(): # handler_map maps exception class -> handler function if isinstance(handler_map, dict): all_exc_keys.extend(handler_map.keys()) has_news = any( (isinstance(k, type) and issubclass(k, NewsAPIException)) or k == NewsAPIException for k in all_exc_keys ) assert has_news except ImportError: pytest.skip("news module not available") def test_news_api_exception_handler_returns_json(self): """NewsAPIException is converted to a JSON response with correct status.""" from local_deep_research.web.app_factory import register_error_handlers try: from local_deep_research.news.exceptions import NewsAPIException except ImportError: pytest.skip("news module not available") app = _minimal_app() register_error_handlers(app) @app.route("/news-error") def news_error(): raise NewsAPIException( message="feed unavailable", error_code="FEED_ERROR", status_code=503, ) app.config["PROPAGATE_EXCEPTIONS"] = False client = app.test_client() response = client.get("/news-error") assert response.status_code == 503 data = response.get_json() assert data is not None # --------------------------------------------------------------------------- # 2. create_database (deprecated) # --------------------------------------------------------------------------- class TestCreateDatabase: """Tests for the deprecated create_database function.""" def test_create_database_is_noop(self): """create_database does nothing (deprecated).""" from local_deep_research.web.app_factory import create_database app = _minimal_app() # Should return None and not raise result = create_database(app) assert result is None def test_create_database_accepts_any_app(self): """create_database accepts any app argument without error.""" # audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE). from local_deep_research.web.app_factory import create_database create_database(None) # Even None is acceptable create_database(Flask(__name__)) # --------------------------------------------------------------------------- # 3. create_app: secret key branching # --------------------------------------------------------------------------- class TestCreateAppSecretKeyBranches: """Tests for the secret key file read/write branches in create_app.""" def test_secret_key_read_from_existing_file(self, tmp_path): """When secret key file exists, it is read and used.""" secret_file = tmp_path / ".secret_key" secret_file.write_text("my-persisted-key") with ( patch(MODULE + ".SocketIOService"), patch( "local_deep_research.config.paths.get_data_directory", return_value=tmp_path, ), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app app, _ = create_app() assert app.config["SECRET_KEY"] == "my-persisted-key" def test_secret_key_generated_when_file_missing(self, tmp_path): """When no secret key file exists, a new key is generated and saved.""" key_dir = tmp_path / "data" key_dir.mkdir() with ( patch(MODULE + ".SocketIOService"), patch( "local_deep_research.config.paths.get_data_directory", return_value=key_dir, ), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app app, _ = create_app() # A hex key should have been generated assert ( len(app.config["SECRET_KEY"]) == 64 ) # 32 bytes -> 64 hex chars # The key file should have been written assert (key_dir / ".secret_key").exists() def test_secret_key_fallback_when_file_read_fails(self, tmp_path): """When secret key file exists but cannot be read, a fresh key is used.""" secret_file = tmp_path / ".secret_key" secret_file.write_text("original-key") _real_open = open def _open_raises_for_secret_key(path, *args, **kwargs): """Raise OSError only for the .secret_key file; delegate otherwise.""" if str(path).endswith(".secret_key"): raise OSError("permission denied") return _real_open(path, *args, **kwargs) with ( patch(MODULE + ".SocketIOService"), patch( "local_deep_research.config.paths.get_data_directory", return_value=tmp_path, ), patch("atexit.register"), patch("builtins.open", side_effect=_open_raises_for_secret_key), ): from local_deep_research.web.app_factory import create_app app, _ = create_app() # Should still have a secret key (fallback generated) assert app.config["SECRET_KEY"] is not None assert len(app.config["SECRET_KEY"]) > 0 # --------------------------------------------------------------------------- # 4. create_app: news scheduler branching # --------------------------------------------------------------------------- class TestCreateAppNewsScheduler: """Tests for the news scheduler enabled/disabled branches.""" def test_news_scheduler_disabled_sets_none(self): """When news scheduler is disabled, app.background_job_scheduler is None.""" with ( patch(MODULE + ".SocketIOService"), patch(MODULE + ".apply_middleware"), patch(MODULE + ".register_blueprints"), patch(MODULE + ".register_error_handlers"), patch("atexit.register"), patch( "local_deep_research.settings.env_registry.get_env_setting", return_value=False, ), ): from local_deep_research.web.app_factory import create_app app, _ = create_app() assert app.background_job_scheduler is None def test_news_scheduler_exception_sets_none(self): """When scheduler init throws, app.background_job_scheduler is None.""" with ( patch(MODULE + ".SocketIOService"), patch(MODULE + ".apply_middleware"), patch(MODULE + ".register_blueprints"), patch(MODULE + ".register_error_handlers"), patch("atexit.register"), patch( "local_deep_research.settings.env_registry.get_env_setting", return_value=True, ), patch( "local_deep_research.scheduler.background.get_background_job_scheduler", side_effect=RuntimeError("scheduler boom"), ), ): from local_deep_research.web.app_factory import create_app app, _ = create_app() assert app.background_job_scheduler is None class TestCreateAppQueueProcessor: """Tests for queue processor startup gating.""" @staticmethod def _stub_queue_processor(monkeypatch): """Install a lightweight queue processor stub for app_factory imports.""" import sys import types mock_queue_processor = MagicMock() queue_pkg = types.ModuleType("local_deep_research.web.queue") queue_pkg.__path__ = [] processor_mod = types.ModuleType( "local_deep_research.web.queue.processor_v2" ) setattr(processor_mod, "queue_processor", mock_queue_processor) monkeypatch.setitem( sys.modules, "local_deep_research.web.queue", queue_pkg ) monkeypatch.setitem( sys.modules, "local_deep_research.web.queue.processor_v2", processor_mod, ) return mock_queue_processor def test_queue_processor_env_setting_is_registered(self): """The queue processor has an env-only setting operators can flip.""" from local_deep_research.settings.env_registry import registry assert ( registry.get_env_var("web.queue_processor.enabled") == "LDR_WEB_QUEUE_PROCESSOR_ENABLED" ) def test_queue_processor_disabled_during_pytest(self, monkeypatch): """Real pytest app startup does not import or start the queue processor.""" import sys monkeypatch.setenv( "PYTEST_CURRENT_TEST", "tests/web/test_app_factory.py::test_app" ) monkeypatch.setenv("LDR_NEWS_SCHEDULER_ENABLED", "false") monkeypatch.delenv("LDR_WEB_QUEUE_PROCESSOR_ENABLED", raising=False) for module_name in ( "local_deep_research.web.auth.queue_middleware", "local_deep_research.web.queue.processor_v2", ): monkeypatch.delitem(sys.modules, module_name, raising=False) with ( patch(MODULE + ".SocketIOService"), patch(MODULE + ".register_blueprints"), patch(MODULE + ".register_error_handlers"), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app create_app() assert "local_deep_research.web.queue.processor_v2" not in sys.modules def test_queue_processor_can_opt_in_under_pytest(self, monkeypatch): """Tests that need the real queue processor can opt in explicitly.""" monkeypatch.setenv( "PYTEST_CURRENT_TEST", "tests/web/test_app_factory.py::test_app" ) monkeypatch.setenv("LDR_NEWS_SCHEDULER_ENABLED", "false") monkeypatch.setenv("LDR_WEB_QUEUE_PROCESSOR_ENABLED", "true") mock_queue_processor = self._stub_queue_processor(monkeypatch) with ( patch(MODULE + ".SocketIOService"), patch(MODULE + ".apply_middleware"), patch(MODULE + ".register_blueprints"), patch(MODULE + ".register_error_handlers"), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app create_app() mock_queue_processor.start.assert_called_once_with() def test_queue_processor_still_starts_when_only_ci_is_set( self, monkeypatch ): """CI alone is not treated as test-mode opt-out for the queue.""" monkeypatch.setenv("CI", "true") monkeypatch.setenv("LDR_NEWS_SCHEDULER_ENABLED", "false") monkeypatch.delenv("TESTING", raising=False) monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("LDR_TESTING_TEST_MODE", raising=False) monkeypatch.delenv("LDR_WEB_QUEUE_PROCESSOR_ENABLED", raising=False) mock_queue_processor = self._stub_queue_processor(monkeypatch) with ( patch(MODULE + ".SocketIOService"), patch(MODULE + ".apply_middleware"), patch(MODULE + ".register_blueprints"), patch(MODULE + ".register_error_handlers"), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app create_app() mock_queue_processor.start.assert_called_once_with() def test_queue_processor_still_starts_when_testing_env_is_false( self, monkeypatch ): """TESTING=false is parsed as false, not as an opt-out signal.""" monkeypatch.setenv("TESTING", "false") monkeypatch.setenv("LDR_NEWS_SCHEDULER_ENABLED", "false") monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("LDR_TESTING_TEST_MODE", raising=False) monkeypatch.delenv("LDR_WEB_QUEUE_PROCESSOR_ENABLED", raising=False) mock_queue_processor = self._stub_queue_processor(monkeypatch) with ( patch(MODULE + ".SocketIOService"), patch(MODULE + ".apply_middleware"), patch(MODULE + ".register_blueprints"), patch(MODULE + ".register_error_handlers"), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app create_app() mock_queue_processor.start.assert_called_once_with() def test_queue_processor_still_starts_when_testing_env_is_true( self, monkeypatch ): """TESTING=true is a dev-server mode, not a pytest opt-out signal.""" monkeypatch.setenv("TESTING", "true") monkeypatch.setenv("LDR_NEWS_SCHEDULER_ENABLED", "false") monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("LDR_TESTING_TEST_MODE", raising=False) monkeypatch.delenv("LDR_WEB_QUEUE_PROCESSOR_ENABLED", raising=False) mock_queue_processor = self._stub_queue_processor(monkeypatch) with ( patch(MODULE + ".SocketIOService"), patch(MODULE + ".apply_middleware"), patch(MODULE + ".register_blueprints"), patch(MODULE + ".register_error_handlers"), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app create_app() mock_queue_processor.start.assert_called_once_with() def test_queue_processor_starts_with_runtime_test_mode(self, monkeypatch): """Runtime concurrency test mode does not disable queue processing.""" monkeypatch.setenv("LDR_TESTING_TEST_MODE", "true") monkeypatch.setenv("LDR_NEWS_SCHEDULER_ENABLED", "false") monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("LDR_WEB_QUEUE_PROCESSOR_ENABLED", raising=False) mock_queue_processor = self._stub_queue_processor(monkeypatch) with ( patch(MODULE + ".SocketIOService"), patch(MODULE + ".apply_middleware"), patch(MODULE + ".register_blueprints"), patch(MODULE + ".register_error_handlers"), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app create_app() mock_queue_processor.start.assert_called_once_with() # --------------------------------------------------------------------------- # 5. apply_middleware: teardown and WebSocket handler # --------------------------------------------------------------------------- class TestApplyMiddlewareTeardown: """Tests for cleanup_db_session teardown and WebSocket before_request.""" @pytest.fixture def app_with_middleware(self): """Create a full app and return it for request-context testing.""" with ( patch(MODULE + ".SocketIOService"), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False return app def test_teardown_runs_without_db_session(self, app_with_middleware): """Teardown context function runs cleanly when no db_session in g.""" # audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE). with app_with_middleware.test_request_context("/"): # Push and pop app context; teardown should not raise with app_with_middleware.app_context(): pass # teardown fires on exit def test_teardown_closes_session_on_exception(self, app_with_middleware): """Teardown rolls back and closes a session stored in g.""" from flask import g mock_session = MagicMock() with app_with_middleware.test_request_context("/"): with app_with_middleware.app_context(): g.db_session = mock_session # After app context exits, teardown ran; verify session was closed mock_session.rollback.assert_called_once() mock_session.close.assert_called_once() def test_websocket_path_returns_none_when_no_socket( self, app_with_middleware ): """Request to /socket.io without werkzeug.socket returns None (continue).""" client = app_with_middleware.test_client() # The before_request handler for /socket.io returns early without error # A GET to /socket.io/... may 404 but should not 500 response = client.get("/socket.io/test") # Either 404 or any non-500 is acceptable assert response.status_code != 500 # --------------------------------------------------------------------------- # 6. register_blueprints: CSRF exemption branch # --------------------------------------------------------------------------- class TestRegisterBlueprintsCsrfExemption: """Tests for the CSRF exemption block in register_blueprints.""" def test_csrf_exemption_applied_to_api_v1(self): """api_v1 blueprint is exempted from CSRF when extension is present.""" with ( patch(MODULE + ".SocketIOService"), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app app, _ = create_app() if "csrf" not in app.extensions: pytest.skip("CSRF extension not registered") csrf = app.extensions["csrf"] api_v1_bp = app.blueprints.get("api_v1") if api_v1_bp is None: pytest.skip("api_v1 blueprint not registered") # The exemption list should include the api_v1 blueprint assert api_v1_bp in csrf._exempt_blueprints def test_csrf_extension_absent_skips_exemption_gracefully(self): """When csrf extension is absent, CSRF exemption block is skipped.""" from local_deep_research.web.app_factory import register_blueprints app = _minimal_app() # Remove csrf from extensions (if present) app.extensions.pop("csrf", None) # register_blueprints should not raise even without csrf # (Requires minimal blueprint setup; we just verify no AttributeError) try: register_blueprints(app) except Exception as exc: # Blueprint import errors are acceptable; csrf-related ones are not if "csrf" in str(exc).lower(): pytest.fail(f"Unexpected csrf-related error: {exc}") # --------------------------------------------------------------------------- # 7. apply_middleware: register_blueprints static/favicon routes # --------------------------------------------------------------------------- class TestStaticAndFaviconRoutes: """Tests for static file and favicon routes registered in register_blueprints.""" @pytest.fixture def app(self): with ( patch(MODULE + ".SocketIOService"), patch("atexit.register"), ): from local_deep_research.web.app_factory import create_app app, _ = create_app() app.config["TESTING"] = True app.config["WTF_CSRF_ENABLED"] = False return app def test_favicon_route_registered(self, app): """favicon.ico route is registered.""" rules = [rule.rule for rule in app.url_map.iter_rules()] assert "/favicon.ico" in rules def test_static_path_route_registered(self, app): """Static file path route /static/ is registered.""" rules = [rule.rule for rule in app.url_map.iter_rules()] assert any("/static/" in r for r in rules) def test_static_route_returns_404_for_missing_file(self, app): """Static route returns 404 when file does not exist.""" client = app.test_client() response = client.get("/static/definitely-not-there.js") assert response.status_code == 404