7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
677 lines
26 KiB
Python
677 lines
26 KiB
Python
"""
|
|
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/<path:path> 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
|