Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

649 lines
23 KiB
Python

"""
Coverage tests for api_routes.py targeting uncovered statements and branches.
Covers:
- get_current_config: exception path (lines 64-66)
- api_research_status: exception path (lines 117-119)
- api_terminate_research: exception path (lines 151-153)
- api_get_resources: exception path (lines 168-170)
- api_add_resource: SSRF rejection (lines 207-208)
- api_add_resource: success path with add_resource call (lines 227-245)
- api_add_resource: exception path (lines 243-247)
- api_delete_resource: exception path (lines 274-276)
- check_ollama_status: old API format (lines 342-343)
- check_ollama_status: invalid JSON response (lines 354-358)
- check_ollama_status: non-200 status code (lines 364-368)
- check_ollama_status: timeout error (lines 386-395)
- check_ollama_status: general exception (lines 397-406)
- check_ollama_model: non-200 status (lines 460-463)
- check_ollama_model: old API format (lines 492-493)
- check_ollama_model: no models found (lines 525-526)
- check_ollama_model: JSON parse error (lines 542-554)
- check_ollama_model: connection error (lines 556-567)
- check_ollama_model: timeout error (lines 568-578)
- check_ollama_model: general exception (lines 580-596)
"""
import requests
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from local_deep_research.web.auth import auth_bp
from local_deep_research.web.routes.api_routes import api_bp
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MODULE = "local_deep_research.web.routes.api_routes"
AUTH_DB_MANAGER = "local_deep_research.web.auth.decorators.db_manager"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_auth():
"""Return a MagicMock that satisfies login_required db_manager check."""
return MagicMock(is_user_connected=MagicMock(return_value=True))
def _make_db_ctx(mock_session):
"""Build a mock context-manager for get_user_db_session."""
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=mock_session)
ctx.__exit__ = MagicMock(return_value=None)
return ctx
def _make_db_ctx_raising(exc):
"""Build a context-manager that raises on __enter__."""
ctx = MagicMock()
ctx.__enter__ = MagicMock(side_effect=exc)
ctx.__exit__ = MagicMock(return_value=None)
return ctx
def _build_filter_chain(result):
"""Build chained SQLAlchemy mock query for filter_by().first()."""
q = MagicMock()
q.filter_by.return_value.first.return_value = result
return q
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def app():
"""Minimal Flask app with auth and api blueprints."""
application = Flask(__name__)
application.config["SECRET_KEY"] = "test-secret"
application.config["TESTING"] = True
application.register_blueprint(auth_bp)
application.register_blueprint(api_bp)
return application
def _authed_get(app, path, **kwargs):
"""Issue an authenticated GET request and return the response."""
with app.test_client() as c:
with c.session_transaction() as sess:
sess["username"] = "testuser"
return c.get(path, **kwargs)
def _authed_post(app, path, **kwargs):
"""Issue an authenticated POST request and return the response."""
with app.test_client() as c:
with c.session_transaction() as sess:
sess["username"] = "testuser"
return c.post(path, **kwargs)
def _authed_delete(app, path, **kwargs):
"""Issue an authenticated DELETE request and return the response."""
with app.test_client() as c:
with c.session_transaction() as sess:
sess["username"] = "testuser"
return c.delete(path, **kwargs)
# ---------------------------------------------------------------------------
# get_current_config: exception path (lines 64-66)
# ---------------------------------------------------------------------------
class TestGetCurrentConfigException:
"""Exception path in get_current_config."""
def test_get_current_config_exception_returns_500(self, app):
"""When get_user_db_session raises, return 500 with error."""
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.get_user_db_session",
return_value=_make_db_ctx_raising(RuntimeError("db down")),
):
resp = _authed_get(app, "/settings/current-config")
assert resp.status_code == 500
data = resp.get_json()
assert data["success"] is False
# ---------------------------------------------------------------------------
# api_research_status: exception path (lines 117-119)
# ---------------------------------------------------------------------------
class TestApiResearchStatusException:
"""Exception path in api_research_status."""
def test_research_status_exception_returns_500(self, app):
"""When db session raises, return 500 error."""
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.get_user_db_session",
return_value=_make_db_ctx_raising(RuntimeError("db error")),
):
resp = _authed_get(app, "/status/some-id")
assert resp.status_code == 500
data = resp.get_json()
assert data["status"] == "error"
# ---------------------------------------------------------------------------
# api_terminate_research: exception path (lines 151-153)
# ---------------------------------------------------------------------------
class TestApiTerminateResearchException:
"""Exception path in api_terminate_research."""
def test_terminate_exception_returns_500(self, app):
"""When cancel_research raises, return 500."""
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.cancel_research",
side_effect=RuntimeError("cancel boom"),
):
resp = _authed_post(app, "/terminate/some-id")
assert resp.status_code == 500
data = resp.get_json()
assert data["status"] == "error"
# ---------------------------------------------------------------------------
# api_get_resources: exception path (lines 168-170)
# ---------------------------------------------------------------------------
class TestApiGetResourcesException:
"""Exception path in api_get_resources."""
def test_get_resources_exception_returns_500(self, app):
"""When get_resources_for_research raises, return 500."""
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.get_resources_for_research",
side_effect=RuntimeError("res err"),
):
resp = _authed_get(app, "/resources/some-id")
assert resp.status_code == 500
data = resp.get_json()
assert data["status"] == "error"
# ---------------------------------------------------------------------------
# api_add_resource: SSRF rejection (lines 207-208)
# ---------------------------------------------------------------------------
class TestApiAddResourceSsrf:
"""SSRF URL rejection in api_add_resource."""
def test_ssrf_invalid_url_rejected(self, app):
"""When validate_url returns False, return 400."""
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
"local_deep_research.security.ssrf_validator.validate_url",
return_value=False,
):
resp = _authed_post(
app,
"/resources/some-id",
json={
"title": "Bad",
"url": "http://169.254.169.254/latest",
},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert data["message"] == "Invalid URL"
# ---------------------------------------------------------------------------
# api_add_resource: success path (lines 227-245)
# ---------------------------------------------------------------------------
class TestApiAddResourceSuccess:
"""Success and exception paths in api_add_resource."""
def test_add_resource_success(self, app):
"""When research exists and URL valid, add resource and return success."""
mock_session = MagicMock()
mock_session.query.return_value = _build_filter_chain(MagicMock())
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
"local_deep_research.security.ssrf_validator.validate_url",
return_value=True,
):
with patch(
f"{MODULE}.get_user_db_session",
return_value=_make_db_ctx(mock_session),
):
with patch(
f"{MODULE}.add_resource", return_value=42
) as mock_add:
resp = _authed_post(
app,
"/resources/res-123",
json={
"title": "My Resource",
"url": "https://example.com/page",
"content_preview": "preview text",
"source_type": "pdf",
"metadata": {"key": "val"},
},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
assert data["resource_id"] == 42
mock_add.assert_called_once_with(
research_id="res-123",
title="My Resource",
url="https://example.com/page",
content_preview="preview text",
source_type="pdf",
metadata={"key": "val"},
)
def test_add_resource_exception_returns_500(self, app):
"""When add_resource raises, return 500."""
mock_session = MagicMock()
mock_session.query.return_value = _build_filter_chain(MagicMock())
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
"local_deep_research.security.ssrf_validator.validate_url",
return_value=True,
):
with patch(
f"{MODULE}.get_user_db_session",
return_value=_make_db_ctx(mock_session),
):
with patch(
f"{MODULE}.add_resource",
side_effect=RuntimeError("insert fail"),
):
resp = _authed_post(
app,
"/resources/res-123",
json={
"title": "My Resource",
"url": "https://example.com/page",
},
content_type="application/json",
)
assert resp.status_code == 500
data = resp.get_json()
assert data["status"] == "error"
# ---------------------------------------------------------------------------
# api_delete_resource: exception path (lines 274-276)
# ---------------------------------------------------------------------------
class TestApiDeleteResourceException:
"""Exception path in api_delete_resource."""
def test_delete_resource_exception_returns_500(self, app):
"""When delete_resource raises, return 500."""
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.delete_resource",
side_effect=RuntimeError("delete boom"),
):
resp = _authed_delete(app, "/resources/res-id/delete/1")
assert resp.status_code == 500
data = resp.get_json()
assert data["status"] == "error"
# ---------------------------------------------------------------------------
# check_ollama_status: edge cases (lines 342-406)
# ---------------------------------------------------------------------------
class TestCheckOllamaStatusEdgeCases:
"""Edge cases for check_ollama_status endpoint."""
def test_old_api_format(self, app):
"""When response has no 'models' key, use old format (array)."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"ollama_base_url": "http://localhost:11434",
}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = [{"name": "llama3"}, {"name": "mistral"}]
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(f"{MODULE}.safe_get", return_value=mock_resp):
resp = _authed_get(app, "/check/ollama_status")
assert resp.status_code == 200
data = resp.get_json()
assert data["running"] is True
assert data["model_count"] == 2
def test_invalid_json_response(self, app):
"""When response.json() raises ValueError, report running with warning."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"ollama_base_url": "http://localhost:11434",
}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.side_effect = ValueError("bad json")
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(f"{MODULE}.safe_get", return_value=mock_resp):
resp = _authed_get(app, "/check/ollama_status")
assert resp.status_code == 200
data = resp.get_json()
assert data["running"] is True
assert "invalid" in data["message"].lower()
def test_non_200_status(self, app):
"""When Ollama returns non-200, report not running."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"ollama_base_url": "http://localhost:11434",
}
mock_resp = MagicMock()
mock_resp.status_code = 503
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(f"{MODULE}.safe_get", return_value=mock_resp):
resp = _authed_get(app, "/check/ollama_status")
assert resp.status_code == 200
data = resp.get_json()
assert data["running"] is False
assert data["status_code"] == 503
def test_timeout_error(self, app):
"""When safe_get raises Timeout, report not running with timeout type."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"ollama_base_url": "http://localhost:11434",
}
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.safe_get",
side_effect=requests.exceptions.Timeout("timed out"),
):
resp = _authed_get(app, "/check/ollama_status")
assert resp.status_code == 200
data = resp.get_json()
assert data["running"] is False
assert data["error_type"] == "timeout"
def test_general_exception(self, app):
"""When an unexpected exception occurs, report not running."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"ollama_base_url": "http://localhost:11434",
}
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.normalize_url",
side_effect=RuntimeError("unexpected"),
):
resp = _authed_get(app, "/check/ollama_status")
assert resp.status_code == 200
data = resp.get_json()
assert data["running"] is False
assert data["error_type"] == "exception"
# ---------------------------------------------------------------------------
# check_ollama_model: edge cases (lines 460-596)
# ---------------------------------------------------------------------------
class TestCheckOllamaModelEdgeCases:
"""Edge cases for check_ollama_model endpoint."""
def test_non_200_status(self, app):
"""When Ollama API returns non-200, report not available."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"model": "llama3",
"ollama_base_url": "http://localhost:11434",
}
mock_resp = MagicMock()
mock_resp.status_code = 503
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(f"{MODULE}.safe_get", return_value=mock_resp):
resp = _authed_get(app, "/check/ollama_model")
assert resp.status_code == 200
data = resp.get_json()
assert data["available"] is False
assert data["status_code"] == 503
def test_old_api_format(self, app):
"""When response has no 'models' key, use old format (array)."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"model": "llama3",
"ollama_base_url": "http://localhost:11434",
}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = [{"name": "llama3"}]
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(f"{MODULE}.safe_get", return_value=mock_resp):
resp = _authed_get(app, "/check/ollama_model")
assert resp.status_code == 200
data = resp.get_json()
assert data["available"] is True
def test_no_models_found(self, app):
"""When models list is empty, report not available with pull message."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"model": "llama3",
"ollama_base_url": "http://localhost:11434",
}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"models": []}
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(f"{MODULE}.safe_get", return_value=mock_resp):
resp = _authed_get(app, "/check/ollama_model")
assert resp.status_code == 200
data = resp.get_json()
assert data["available"] is False
assert "pull" in data["message"].lower()
def test_json_parse_error(self, app):
"""When response.json() raises ValueError, report parse error."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"model": "llama3",
"ollama_base_url": "http://localhost:11434",
}
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.side_effect = ValueError("bad json")
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(f"{MODULE}.safe_get", return_value=mock_resp):
resp = _authed_get(app, "/check/ollama_model")
assert resp.status_code == 200
data = resp.get_json()
assert data["available"] is False
assert data["error_type"] == "json_parse_error"
def test_connection_error(self, app):
"""When safe_get raises ConnectionError, report connection error."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"model": "llama3",
"ollama_base_url": "http://localhost:11434",
}
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.safe_get",
side_effect=requests.exceptions.ConnectionError("refused"),
):
resp = _authed_get(app, "/check/ollama_model")
assert resp.status_code == 200
data = resp.get_json()
assert data["available"] is False
assert data["error_type"] == "connection_error"
def test_timeout_error(self, app):
"""When safe_get raises Timeout, report timeout."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"model": "llama3",
"ollama_base_url": "http://localhost:11434",
}
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.safe_get",
side_effect=requests.exceptions.Timeout("timed out"),
):
resp = _authed_get(app, "/check/ollama_model")
assert resp.status_code == 200
data = resp.get_json()
assert data["available"] is False
assert data["error_type"] == "timeout"
def test_general_exception(self, app):
"""When an unexpected exception occurs, report exception."""
app.config["LLM_CONFIG"] = {
"provider": "ollama",
"model": "llama3",
"ollama_base_url": "http://localhost:11434",
}
with patch(AUTH_DB_MANAGER, _mock_auth()):
with patch(
f"{MODULE}.normalize_url",
side_effect=RuntimeError("unexpected"),
):
resp = _authed_get(app, "/check/ollama_model")
assert resp.status_code == 200
data = resp.get_json()
assert data["available"] is False
assert data["error_type"] == "exception"
class TestProbeOllamaTags:
"""Direct tests for the shared _probe_ollama_tags helper — the single
source the status and model-availability checks both consume, so an
'is Ollama up?' answer can no longer drift between them."""
def _probe(self, base_url="http://localhost:11434"):
from local_deep_research.web.routes.api_routes import (
_probe_ollama_tags,
)
return _probe_ollama_tags(base_url)
def test_ok_new_format(self):
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"models": [{"name": "llama3"}]}
with patch(f"{MODULE}.safe_get", return_value=resp):
outcome, payload = self._probe()
assert outcome == "ok"
assert payload == [{"name": "llama3"}]
def test_ok_old_format(self):
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = [{"name": "a"}, {"name": "b"}]
with patch(f"{MODULE}.safe_get", return_value=resp):
outcome, payload = self._probe()
assert outcome == "ok"
assert len(payload) == 2
def test_bad_status_returns_status_code(self):
resp = MagicMock()
resp.status_code = 503
with patch(f"{MODULE}.safe_get", return_value=resp):
outcome, payload = self._probe()
assert outcome == "bad_status"
assert payload == 503
def test_invalid_json(self):
resp = MagicMock()
resp.status_code = 200
resp.json.side_effect = ValueError("bad")
with patch(f"{MODULE}.safe_get", return_value=resp):
outcome, payload = self._probe()
assert outcome == "invalid_json"
assert payload is None
def test_connection_error(self):
import requests
with patch(
f"{MODULE}.safe_get",
side_effect=requests.exceptions.ConnectionError(),
):
outcome, payload = self._probe()
assert outcome == "connection_error"
def test_timeout(self):
import requests
with patch(
f"{MODULE}.safe_get", side_effect=requests.exceptions.Timeout()
):
outcome, payload = self._probe()
assert outcome == "timeout"