chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user