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
728 lines
28 KiB
Python
728 lines
28 KiB
Python
"""
|
|
Coverage tests for local_deep_research/web/app.py.
|
|
|
|
Targets the ~50 missing statements by exercising each branch of main() in
|
|
isolation. All heavy imports inside main() are patched at their canonical
|
|
module paths.
|
|
|
|
Tests:
|
|
1. test_main_creates_app_and_runs — create_app + socket_service.run called
|
|
2. test_main_https_warning — use_https=True emits warning, doesn't raise
|
|
3. test_main_cleanup_scheduler_start — debug=False starts cleanup scheduler
|
|
4. test_main_cleanup_scheduler_failure — exception in scheduler start is swallowed
|
|
5. test_main_shutdown_scheduler_atexit — news_scheduler.stop() is called
|
|
6. test_main_shutdown_databases_atexit — db_manager.close_all_databases() called
|
|
7. test_flush_logs_on_exit — first atexit handler flushes log queue
|
|
"""
|
|
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
MODULE = "local_deep_research.web.app"
|
|
|
|
_CLEANUP_MOD = "local_deep_research.web.auth.connection_cleanup"
|
|
_SESSION_MOD = "local_deep_research.web.auth.session_manager"
|
|
_DB_MOD = "local_deep_research.database.encrypted_db"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stub_log_queue_daemon():
|
|
"""Prevent main() from spawning a real log-queue daemon thread during tests."""
|
|
with (
|
|
patch(f"{MODULE}.start_log_queue_processor"),
|
|
patch(f"{MODULE}.stop_log_queue_processor"),
|
|
):
|
|
yield
|
|
|
|
|
|
def _handler_by_name(captured, name):
|
|
"""Locate an atexit handler by function name (order-independent)."""
|
|
for fn in captured:
|
|
if getattr(fn, "__name__", None) == name:
|
|
return fn
|
|
raise AssertionError(
|
|
f"No captured handler named {name!r}; got "
|
|
f"{[getattr(fn, '__name__', repr(fn)) for fn in captured]}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_config(host="127.0.0.1", port=5000, debug=False, use_https=False):
|
|
return {"host": host, "port": port, "debug": debug, "use_https": use_https}
|
|
|
|
|
|
def _run_main(config, extra_env=None, cleanup_side_effect=None):
|
|
"""
|
|
Patch everything main() needs, run it, and return the mock objects.
|
|
|
|
Returns a dict with keys: socket_service, app, scheduler, atexit_calls.
|
|
"""
|
|
mock_socket_service = MagicMock()
|
|
mock_app = MagicMock()
|
|
mock_scheduler = MagicMock()
|
|
atexit_calls = []
|
|
|
|
patches = [
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(mock_app, mock_socket_service)
|
|
),
|
|
patch("atexit.register", side_effect=atexit_calls.append),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
side_effect=cleanup_side_effect,
|
|
return_value=mock_scheduler,
|
|
),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
]
|
|
env_patch = patch.dict("os.environ", extra_env or {}, clear=False)
|
|
|
|
for p in patches:
|
|
p.start()
|
|
env_patch.start()
|
|
try:
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
finally:
|
|
for p in reversed(patches):
|
|
p.stop()
|
|
env_patch.stop()
|
|
|
|
return {
|
|
"socket_service": mock_socket_service,
|
|
"app": mock_app,
|
|
"scheduler": mock_scheduler,
|
|
"atexit_calls": atexit_calls,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. test_main_creates_app_and_runs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMainCreatesAppAndRuns:
|
|
"""main() must call create_app() and then socket_service.run()."""
|
|
|
|
def test_create_app_is_called(self):
|
|
config = _make_config()
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
) as mock_create,
|
|
patch("atexit.register"),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
),
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
mock_create.assert_called_once()
|
|
|
|
def test_socket_service_run_called(self):
|
|
config = _make_config(host="0.0.0.0", port=8080, debug=False)
|
|
result = _run_main(config)
|
|
result["socket_service"].run.assert_called_once_with(
|
|
host="0.0.0.0", port=8080, debug=False
|
|
)
|
|
|
|
def test_socket_service_run_forwards_debug_true(self):
|
|
config = _make_config(host="localhost", port=5001, debug=True)
|
|
result = _run_main(config, extra_env={"WERKZEUG_RUN_MAIN": "true"})
|
|
result["socket_service"].run.assert_called_once_with(
|
|
host="localhost", port=5001, debug=True
|
|
)
|
|
|
|
def test_main_completes_without_exception(self):
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE).
|
|
config = _make_config()
|
|
# Should not raise
|
|
_run_main(config)
|
|
|
|
def test_config_logger_called_with_debug_flag(self):
|
|
config = _make_config(debug=True)
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger") as mock_cl,
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register"),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch.dict(
|
|
"os.environ", {"WERKZEUG_RUN_MAIN": "true"}, clear=False
|
|
),
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
mock_cl.assert_called_once_with("ldr_web", debug=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. test_main_https_warning
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMainHttpsWarning:
|
|
"""When use_https=True, main() logs a warning but does not raise."""
|
|
|
|
def test_https_true_does_not_raise(self):
|
|
config = _make_config(use_https=True)
|
|
# Must not raise
|
|
result = _run_main(config)
|
|
# Server still runs
|
|
result["socket_service"].run.assert_called_once()
|
|
|
|
def test_https_true_emits_logger_warning(self):
|
|
config = _make_config(use_https=True)
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register"),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
),
|
|
# Intercept logger to verify the HTTPS warning path was reached
|
|
patch(f"{MODULE}.logger") as mock_logger,
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
|
|
# logger.info("Starting server with HTTPS ...") and logger.warning(...) should be called
|
|
assert mock_logger.info.called or mock_logger.warning.called
|
|
|
|
def test_https_false_skips_https_branch(self):
|
|
config = _make_config(use_https=False)
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register"),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(f"{MODULE}.logger") as mock_logger,
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
|
|
# "Starting server with HTTPS" message should NOT be logged
|
|
https_info_calls = [
|
|
c for c in mock_logger.info.call_args_list if "HTTPS" in str(c)
|
|
]
|
|
assert len(https_info_calls) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. test_main_cleanup_scheduler_start
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMainCleanupSchedulerStart:
|
|
"""Cleanup scheduler is started when debug=False (or WERKZEUG_RUN_MAIN=true)."""
|
|
|
|
def test_scheduler_started_when_debug_false(self):
|
|
config = _make_config(debug=False)
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register"),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
) as mock_start,
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
mock_start.assert_called_once()
|
|
|
|
def test_scheduler_started_when_werkzeug_run_main_set(self):
|
|
config = _make_config(debug=True)
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register"),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
) as mock_start,
|
|
patch.dict(
|
|
"os.environ", {"WERKZEUG_RUN_MAIN": "true"}, clear=False
|
|
),
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
mock_start.assert_called_once()
|
|
|
|
def test_scheduler_not_started_in_debug_without_werkzeug(self):
|
|
config = _make_config(debug=True)
|
|
env_without = {
|
|
k: v for k, v in os.environ.items() if k != "WERKZEUG_RUN_MAIN"
|
|
}
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register"),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
) as mock_start,
|
|
patch.dict("os.environ", env_without, clear=True),
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
mock_start.assert_not_called()
|
|
|
|
def test_scheduler_shutdown_lambda_registered_as_atexit(self):
|
|
"""After starting cleanup scheduler, main() registers a lambda atexit handler."""
|
|
config = _make_config(debug=False)
|
|
atexit_calls = []
|
|
mock_scheduler = MagicMock()
|
|
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register", side_effect=atexit_calls.append),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=mock_scheduler,
|
|
),
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
|
|
# At least 4 atexit handlers: shutdown_db, shutdown_scheduler,
|
|
# cleanup lambda, flush_logs, (+ stop_log_queue_processor)
|
|
assert len(atexit_calls) >= 4
|
|
|
|
# The cleanup_scheduler.shutdown call is an anonymous lambda — locate
|
|
# it by calling each handler and finding the one that triggers
|
|
# mock_scheduler.shutdown.
|
|
for handler in atexit_calls:
|
|
if getattr(handler, "__name__", None) == "<lambda>":
|
|
handler()
|
|
break
|
|
mock_scheduler.shutdown.assert_called_once_with(wait=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. test_main_cleanup_scheduler_failure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMainCleanupSchedulerFailure:
|
|
"""If start_connection_cleanup_scheduler raises, main() must not abort."""
|
|
|
|
def test_scheduler_exception_is_swallowed(self):
|
|
config = _make_config(debug=False)
|
|
result = _run_main(
|
|
config, cleanup_side_effect=RuntimeError("scheduler boom")
|
|
)
|
|
# Server still runs despite scheduler failure
|
|
result["socket_service"].run.assert_called_once()
|
|
|
|
def test_scheduler_exception_logs_warning(self):
|
|
config = _make_config(debug=False)
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register"),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
side_effect=Exception("bad scheduler"),
|
|
),
|
|
patch(f"{MODULE}.logger") as mock_logger,
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
|
|
mock_logger.warning.assert_called()
|
|
|
|
def test_cleanup_scheduler_none_when_it_fails(self):
|
|
"""When scheduler startup fails, cleanup_scheduler is set to None — no atexit lambda."""
|
|
config = _make_config(debug=False)
|
|
atexit_calls = []
|
|
with (
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register", side_effect=atexit_calls.append),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
side_effect=RuntimeError("fail"),
|
|
),
|
|
):
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
|
|
# When scheduler fails, no cleanup-scheduler lambda is registered.
|
|
# The other handlers (shutdown_databases, shutdown_scheduler,
|
|
# flush_logs_on_exit, stop_log_queue_processor) are still registered.
|
|
handler_names = [getattr(h, "__name__", None) for h in atexit_calls]
|
|
assert "<lambda>" not in handler_names
|
|
assert "shutdown_databases" in handler_names
|
|
assert "shutdown_scheduler" in handler_names
|
|
assert "flush_logs_on_exit" in handler_names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. test_main_shutdown_scheduler_atexit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMainShutdownSchedulerAtexit:
|
|
"""The shutdown_scheduler atexit closure calls app.background_job_scheduler.stop()."""
|
|
|
|
def _run_and_capture(self, mock_app):
|
|
captured = []
|
|
config = _make_config()
|
|
|
|
patches = [
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(f"{MODULE}.create_app", return_value=(mock_app, MagicMock())),
|
|
patch("atexit.register", side_effect=captured.append),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
),
|
|
]
|
|
for p in patches:
|
|
p.start()
|
|
try:
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
finally:
|
|
for p in reversed(patches):
|
|
p.stop()
|
|
return captured
|
|
|
|
def test_news_scheduler_stop_called(self):
|
|
"""shutdown_scheduler calls app.background_job_scheduler.stop() when it exists."""
|
|
mock_app = MagicMock()
|
|
mock_background_job_scheduler = MagicMock()
|
|
mock_app.background_job_scheduler = mock_background_job_scheduler
|
|
|
|
captured = self._run_and_capture(mock_app)
|
|
|
|
# Invoke all atexit handlers to find shutdown_scheduler
|
|
for handler in captured:
|
|
try:
|
|
handler()
|
|
except Exception:
|
|
pass
|
|
|
|
mock_background_job_scheduler.stop.assert_called_once()
|
|
|
|
def test_no_error_when_news_scheduler_missing(self):
|
|
"""shutdown_scheduler is a no-op if app has no news_scheduler."""
|
|
# spec=[] means no attributes exist on the mock
|
|
mock_app = MagicMock(spec=[])
|
|
|
|
captured = self._run_and_capture(mock_app)
|
|
# The atexit handlers registered by app.main() must not raise even
|
|
# when the app has no news_scheduler attribute. Collect any errors
|
|
# explicitly so the assertion fails loudly if a handler does raise.
|
|
errors = []
|
|
for handler in captured:
|
|
try:
|
|
handler()
|
|
except Exception as exc:
|
|
errors.append(exc)
|
|
assert errors == [], (
|
|
f"atexit handlers raised when app has no news_scheduler: {errors}"
|
|
)
|
|
|
|
def test_news_scheduler_stop_exception_is_swallowed(self):
|
|
"""shutdown_scheduler swallows exceptions from news_scheduler.stop()."""
|
|
mock_app = MagicMock()
|
|
mock_app.background_job_scheduler = MagicMock()
|
|
mock_app.background_job_scheduler.stop.side_effect = RuntimeError(
|
|
"scheduler error"
|
|
)
|
|
|
|
captured = self._run_and_capture(mock_app)
|
|
# Even though stop() raises RuntimeError, the SUT's shutdown_scheduler
|
|
# closure swallows it so the atexit chain keeps running. Collect any
|
|
# errors that escape — there should be none.
|
|
errors = []
|
|
for handler in captured:
|
|
try:
|
|
handler()
|
|
except Exception as exc:
|
|
errors.append(exc)
|
|
assert errors == [], (
|
|
f"atexit handlers leaked exceptions despite swallow: {errors}"
|
|
)
|
|
# And confirm the swallowed call actually happened.
|
|
mock_app.background_job_scheduler.stop.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6. test_main_shutdown_databases_atexit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMainShutdownDatabasesAtexit:
|
|
"""The shutdown_databases atexit closure calls db_manager.close_all_databases()."""
|
|
|
|
def _run_and_capture(self):
|
|
captured = []
|
|
config = _make_config()
|
|
|
|
patches = [
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register", side_effect=captured.append),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
),
|
|
]
|
|
for p in patches:
|
|
p.start()
|
|
try:
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
finally:
|
|
for p in reversed(patches):
|
|
p.stop()
|
|
return captured
|
|
|
|
def test_close_all_databases_called(self):
|
|
"""The shutdown_databases atexit handler calls db_manager.close_all_databases()."""
|
|
captured = self._run_and_capture()
|
|
mock_db_manager = MagicMock()
|
|
handler = _handler_by_name(captured, "shutdown_databases")
|
|
with patch(f"{_DB_MOD}.db_manager", mock_db_manager):
|
|
handler()
|
|
mock_db_manager.close_all_databases.assert_called_once()
|
|
|
|
def test_shutdown_databases_swallows_import_error(self):
|
|
"""shutdown_databases does not propagate exceptions."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE).
|
|
captured = self._run_and_capture()
|
|
handler = _handler_by_name(captured, "shutdown_databases")
|
|
# Simulate import failure inside the handler
|
|
with patch(f"{_DB_MOD}.db_manager", side_effect=ImportError("gone")):
|
|
handler() # must not raise
|
|
|
|
def test_shutdown_databases_swallows_runtime_error(self):
|
|
"""shutdown_databases swallows RuntimeError from close_all_databases."""
|
|
# audit: PUNCHLIST reviewed 2026-05 — KEEP (ASSERT_TRUE).
|
|
captured = self._run_and_capture()
|
|
handler = _handler_by_name(captured, "shutdown_databases")
|
|
mock_db = MagicMock()
|
|
mock_db.close_all_databases.side_effect = RuntimeError("db error")
|
|
with patch(f"{_DB_MOD}.db_manager", mock_db):
|
|
handler() # must not raise
|
|
|
|
def test_at_least_three_atexit_handlers_registered(self):
|
|
"""main() always registers at least 3 atexit handlers."""
|
|
captured = self._run_and_capture()
|
|
# shutdown_databases + shutdown_scheduler + flush_logs_on_exit
|
|
# (+ optional cleanup-scheduler lambda, + stop_log_queue_processor)
|
|
assert len(captured) >= 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. test_flush_logs_on_exit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFlushLogsOnExit:
|
|
"""The flush_logs_on_exit atexit handler flushes the log queue."""
|
|
|
|
def _run_and_capture(self, extra_patches=None):
|
|
captured = []
|
|
config = _make_config()
|
|
|
|
patches = [
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app", return_value=(MagicMock(), MagicMock())
|
|
),
|
|
patch("atexit.register", side_effect=captured.append),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
),
|
|
] + (extra_patches or [])
|
|
|
|
for p in patches:
|
|
p.start()
|
|
try:
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
finally:
|
|
for p in reversed(patches):
|
|
p.stop()
|
|
return captured
|
|
|
|
def test_first_handler_is_flush_logs(self):
|
|
"""Calling the flush_logs_on_exit atexit handler flushes the log queue."""
|
|
captured = self._run_and_capture()
|
|
flush_handler = _handler_by_name(captured, "flush_logs_on_exit")
|
|
# Patch flush_log_queue at the module where flush_logs_on_exit imports it
|
|
with patch(f"{MODULE}.flush_log_queue") as mock_flush:
|
|
flush_handler()
|
|
mock_flush.assert_called_once()
|
|
|
|
def test_flush_handler_swallows_exceptions(self):
|
|
"""flush_logs_on_exit does not propagate exceptions from flush_log_queue."""
|
|
captured = self._run_and_capture()
|
|
flush_handler = _handler_by_name(captured, "flush_logs_on_exit")
|
|
with patch(
|
|
f"{MODULE}.flush_log_queue", side_effect=RuntimeError("log err")
|
|
):
|
|
flush_handler() # must not raise
|
|
|
|
def test_flush_handler_uses_flask_app_context(self):
|
|
"""flush_logs_on_exit creates a minimal Flask app context before flushing."""
|
|
captured = self._run_and_capture()
|
|
flush_handler = _handler_by_name(captured, "flush_logs_on_exit")
|
|
|
|
mock_flask_app = MagicMock()
|
|
mock_flask_app.app_context.return_value.__enter__ = MagicMock(
|
|
return_value=None
|
|
)
|
|
mock_flask_app.app_context.return_value.__exit__ = MagicMock(
|
|
return_value=False
|
|
)
|
|
|
|
# Flask is imported lazily inside flush_logs_on_exit as `from flask import Flask`
|
|
# patch it at the flask package level so the local import picks up the mock
|
|
with (
|
|
patch("flask.Flask", return_value=mock_flask_app),
|
|
patch(f"{MODULE}.flush_log_queue") as mock_flush,
|
|
):
|
|
flush_handler()
|
|
|
|
mock_flask_app.app_context.assert_called_once()
|
|
mock_flush.assert_called_once()
|
|
|
|
def test_atexit_registered_before_create_app(self):
|
|
"""The flush log atexit handler is registered early (before socket run)."""
|
|
atexit_order = []
|
|
run_order = []
|
|
config = _make_config()
|
|
|
|
mock_socket_service = MagicMock()
|
|
mock_socket_service.run.side_effect = lambda **_: run_order.append(
|
|
"run"
|
|
)
|
|
|
|
def register_side_effect(fn):
|
|
atexit_order.append(fn)
|
|
|
|
patches = [
|
|
patch(f"{MODULE}.load_server_config", return_value=config),
|
|
patch(f"{MODULE}.config_logger"),
|
|
patch(
|
|
f"{MODULE}.create_app",
|
|
return_value=(MagicMock(), mock_socket_service),
|
|
),
|
|
patch("atexit.register", side_effect=register_side_effect),
|
|
patch(f"{_SESSION_MOD}.session_manager", MagicMock(), create=True),
|
|
patch(f"{_DB_MOD}.db_manager", MagicMock(), create=True),
|
|
patch(
|
|
f"{_CLEANUP_MOD}.start_connection_cleanup_scheduler",
|
|
return_value=MagicMock(),
|
|
),
|
|
]
|
|
for p in patches:
|
|
p.start()
|
|
try:
|
|
from local_deep_research.web import app as app_module
|
|
|
|
app_module.main()
|
|
finally:
|
|
for p in reversed(patches):
|
|
p.stop()
|
|
|
|
# At least one handler must have been registered
|
|
assert len(atexit_order) >= 1
|