50846360b3
🧪 [testing improvement] add missing error logging tests for notify_settings_updated
1054 lines
42 KiB
Python
1054 lines
42 KiB
Python
"""
|
||
Targeted tests to fill remaining coverage gaps in:
|
||
- app/views/base.py (lines 36-41)
|
||
- app/views/general.py (lines 64-66, 111, 133-134, 138)
|
||
- app/views/status.py (lines 53-54, 59-60, 68-69)
|
||
- app/utils/config_validator/providers.py (lines 68-78, 86-100, 243)
|
||
- app/utils/settings_sync.py (lines 71-72, 95-98)
|
||
- app/api/logs.py (additional branches)
|
||
"""
|
||
|
||
from unittest.mock import MagicMock, Mock, mock_open, patch
|
||
|
||
import pytest
|
||
|
||
# ===========================================================================
|
||
# app/views/base.py – kwargs context path (lines 36-41)
|
||
# ===========================================================================
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestViewsBase:
|
||
"""Tests for template_response_with_version wrapper in app/views/base.py."""
|
||
|
||
def test_kwargs_context_version_injection(self):
|
||
"""Test version injection when context is passed as a keyword argument."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with patch("app.views.base.original_template_response") as mock_orig:
|
||
mock_orig.return_value = "response"
|
||
req = MagicMock()
|
||
# Make the request NOT have a csrf_token on its state
|
||
del req.state.csrf_token # ensure AttributeError on hasattr
|
||
|
||
result = template_response_with_version(
|
||
"template.html",
|
||
context={"request": req, "title": "Test"},
|
||
)
|
||
|
||
mock_orig.assert_called_once()
|
||
_, kwargs = mock_orig.call_args
|
||
assert "version" in kwargs["context"]
|
||
|
||
def test_kwargs_context_csrf_token_injection(self):
|
||
"""Test CSRF token injection when context has request with csrf_token."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with patch("app.views.base.original_template_response") as mock_orig:
|
||
mock_orig.return_value = "response"
|
||
req = MagicMock()
|
||
req.state.csrf_token = "test-csrf-token"
|
||
|
||
template_response_with_version(
|
||
"template.html",
|
||
context={"request": req},
|
||
)
|
||
|
||
_, kwargs = mock_orig.call_args
|
||
assert kwargs["context"].get("csrf_token") == "test-csrf-token"
|
||
|
||
def test_positional_context_with_csrf_token(self):
|
||
"""Test CSRF token injection via positional args."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with patch("app.views.base.original_template_response") as mock_orig:
|
||
mock_orig.return_value = "response"
|
||
req = MagicMock()
|
||
req.state.csrf_token = "my-csrf"
|
||
|
||
context = {"request": req}
|
||
template_response_with_version("template.html", context)
|
||
|
||
args, _ = mock_orig.call_args
|
||
assert args[1].get("csrf_token") == "my-csrf"
|
||
|
||
def test_kwargs_context_no_request(self):
|
||
"""Test kwargs context path when request is not in context."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with patch("app.views.base.original_template_response") as mock_orig:
|
||
mock_orig.return_value = "response"
|
||
|
||
template_response_with_version(
|
||
"template.html",
|
||
context={"title": "No request"},
|
||
)
|
||
|
||
mock_orig.assert_called_once()
|
||
_, kwargs = mock_orig.call_args
|
||
assert "version" in kwargs["context"]
|
||
|
||
def test_no_args_no_context(self):
|
||
"""Test with no args and no context (edge case)."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with patch("app.views.base.original_template_response") as mock_orig:
|
||
mock_orig.return_value = "response"
|
||
template_response_with_version("template.html")
|
||
|
||
mock_orig.assert_called_once()
|
||
|
||
def test_is_logged_in_injected_when_auth_disabled(self):
|
||
"""is_logged_in should be True when auth is disabled (single-user mode)."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with (
|
||
patch("app.views.base.original_template_response") as mock_orig,
|
||
patch("app.views.base.settings") as mock_settings,
|
||
):
|
||
mock_orig.return_value = "response"
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.multi_user_enabled = False
|
||
mock_settings.version = "0.0.0"
|
||
mock_settings.release_name = None
|
||
mock_settings.ui_default_color_scheme = "system"
|
||
|
||
req = MagicMock()
|
||
del req.state.csrf_token
|
||
|
||
template_response_with_version(
|
||
"template.html",
|
||
context={"request": req},
|
||
)
|
||
|
||
_, kwargs = mock_orig.call_args
|
||
assert kwargs["context"].get("is_logged_in") is True
|
||
|
||
def test_is_logged_in_true_when_session_user_present(self):
|
||
"""is_logged_in should be True when session contains a user."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with (
|
||
patch("app.views.base.original_template_response") as mock_orig,
|
||
patch("app.views.base.settings") as mock_settings,
|
||
):
|
||
mock_orig.return_value = "response"
|
||
mock_settings.auth_enabled = True
|
||
mock_settings.multi_user_enabled = True
|
||
mock_settings.version = "0.0.0"
|
||
mock_settings.release_name = None
|
||
mock_settings.ui_default_color_scheme = "system"
|
||
|
||
req = MagicMock()
|
||
req.session.get.return_value = {"email": "user@example.com"}
|
||
del req.state.csrf_token
|
||
|
||
template_response_with_version(
|
||
"template.html",
|
||
context={"request": req},
|
||
)
|
||
|
||
_, kwargs = mock_orig.call_args
|
||
assert kwargs["context"].get("is_logged_in") is True
|
||
|
||
def test_is_logged_in_false_when_no_session_user(self):
|
||
"""is_logged_in should be False when auth is enabled and no session user."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with (
|
||
patch("app.views.base.original_template_response") as mock_orig,
|
||
patch("app.views.base.settings") as mock_settings,
|
||
):
|
||
mock_orig.return_value = "response"
|
||
mock_settings.auth_enabled = True
|
||
mock_settings.multi_user_enabled = True
|
||
mock_settings.version = "0.0.0"
|
||
mock_settings.release_name = None
|
||
mock_settings.ui_default_color_scheme = "system"
|
||
|
||
req = MagicMock()
|
||
req.session.get.return_value = None
|
||
del req.state.csrf_token
|
||
|
||
template_response_with_version(
|
||
"template.html",
|
||
context={"request": req},
|
||
)
|
||
|
||
_, kwargs = mock_orig.call_args
|
||
assert kwargs["context"].get("is_logged_in") is False
|
||
|
||
def test_is_logged_in_false_when_request_has_no_session(self):
|
||
"""is_logged_in should default to False when request has no session attribute."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with (
|
||
patch("app.views.base.original_template_response") as mock_orig,
|
||
patch("app.views.base.settings") as mock_settings,
|
||
):
|
||
mock_orig.return_value = "response"
|
||
mock_settings.auth_enabled = True
|
||
mock_settings.multi_user_enabled = False
|
||
mock_settings.version = "0.0.0"
|
||
mock_settings.release_name = None
|
||
mock_settings.ui_default_color_scheme = "system"
|
||
|
||
# Use a simple object without session attribute
|
||
class NoSessionRequest:
|
||
class state:
|
||
pass # no csrf_token
|
||
|
||
template_response_with_version(
|
||
"template.html",
|
||
context={"request": NoSessionRequest()},
|
||
)
|
||
|
||
_, kwargs = mock_orig.call_args
|
||
assert kwargs["context"].get("is_logged_in") is False
|
||
|
||
def test_multi_user_enabled_injected(self):
|
||
"""multi_user_enabled should be injected into every template context."""
|
||
from app.views.base import template_response_with_version
|
||
|
||
with (
|
||
patch("app.views.base.original_template_response") as mock_orig,
|
||
patch("app.views.base.settings") as mock_settings,
|
||
):
|
||
mock_orig.return_value = "response"
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.multi_user_enabled = True
|
||
mock_settings.version = "0.0.0"
|
||
mock_settings.release_name = None
|
||
mock_settings.ui_default_color_scheme = "system"
|
||
|
||
template_response_with_version(
|
||
"template.html",
|
||
context={"title": "no request"},
|
||
)
|
||
|
||
_, kwargs = mock_orig.call_args
|
||
assert kwargs["context"].get("multi_user_enabled") is True
|
||
|
||
|
||
# ===========================================================================
|
||
# app/views/general.py – error branches
|
||
# ===========================================================================
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestGeneralViewsAdditional:
|
||
"""Additional tests for general view error branches."""
|
||
|
||
@patch("app.views.general.get_provider_status")
|
||
@patch("app.views.general.validate_storage_configs")
|
||
@patch("app.views.general.templates")
|
||
@pytest.mark.asyncio
|
||
async def test_db_error_logged_but_page_still_renders(self, mock_templates, mock_storage, mock_providers):
|
||
"""Test that a DB error is logged but the page still renders (lines 64-66)."""
|
||
from app.views.general import serve_index
|
||
|
||
mock_providers.return_value = {}
|
||
mock_storage.return_value = {}
|
||
mock_templates.TemplateResponse = MagicMock(return_value="response")
|
||
|
||
with patch("app.utils.settings_service.get_setting_from_db", return_value=None):
|
||
with patch("app.utils.setup_wizard.is_setup_required", return_value=False):
|
||
mock_request = MagicMock()
|
||
mock_request.query_params.get = MagicMock(return_value=None)
|
||
mock_db = MagicMock()
|
||
# Make DB query fail to trigger the except block (lines 64-66)
|
||
mock_db.query.side_effect = Exception("DB failure")
|
||
|
||
result = await serve_index(mock_request, mock_db)
|
||
|
||
# Page should still render with 0 processed_files
|
||
mock_templates.TemplateResponse.assert_called_once()
|
||
call_args = mock_templates.TemplateResponse.call_args
|
||
context = call_args[0][1]
|
||
assert context["stats"]["processed_files"] == 0
|
||
|
||
@patch("app.views.general.templates")
|
||
@pytest.mark.asyncio
|
||
async def test_favicon_not_found_raises_404(self, mock_templates):
|
||
"""Test that missing favicon raises 404 HTTPException (line 111)."""
|
||
from fastapi import HTTPException
|
||
|
||
from app.views.general import favicon
|
||
|
||
with patch("pathlib.Path.exists", return_value=False):
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
favicon()
|
||
|
||
assert exc_info.value.status_code == 404
|
||
|
||
@patch("app.views.general.templates")
|
||
@pytest.mark.asyncio
|
||
async def test_license_fallback_when_no_file_found(self, mock_templates):
|
||
"""Test that license page uses embedded text when no file found (lines 133-134, 138)."""
|
||
from app.views.general import serve_license
|
||
|
||
mock_templates.TemplateResponse = MagicMock(return_value="response")
|
||
mock_request = MagicMock()
|
||
|
||
# Patch open to raise FileNotFoundError for all paths
|
||
with patch("builtins.open", side_effect=FileNotFoundError("No such file")):
|
||
await serve_license(mock_request)
|
||
|
||
# Template should be called with embedded license text
|
||
call_args = mock_templates.TemplateResponse.call_args
|
||
context = call_args[0][1]
|
||
assert "Apache License" in context["license_text"]
|
||
|
||
def test_license_page_integration_always_renders(self, client):
|
||
"""Test license page renders (with real or embedded text)."""
|
||
response = client.get("/license")
|
||
assert response.status_code == 200
|
||
|
||
|
||
# ===========================================================================
|
||
# app/views/status.py – Docker exception branches
|
||
# ===========================================================================
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestStatusViewsAdditional:
|
||
"""Additional tests for status view exception branches."""
|
||
|
||
@patch("app.views.status.get_provider_status")
|
||
@patch("app.views.status.templates")
|
||
@patch("app.views.status.settings")
|
||
@patch("app.views.status.os.path.exists")
|
||
@patch("builtins.open", new_callable=mock_open, read_data="12:docker:/abc123")
|
||
@pytest.mark.asyncio
|
||
async def test_git_sha_exception_in_docker_env(
|
||
self, mock_file, mock_exists, mock_settings, mock_templates, mock_providers
|
||
):
|
||
"""Test Docker env: git_sha[:7] raises exception → lines 53-54."""
|
||
from app.views.status import status_dashboard
|
||
|
||
mock_exists.return_value = True # Docker env exists
|
||
mock_providers.return_value = {}
|
||
mock_settings.version = "1.0.0"
|
||
mock_settings.build_date = "2024-01-01"
|
||
# A bool is not subscriptable, so git_sha[:7] raises TypeError → lines 53-54
|
||
mock_settings.git_sha = True
|
||
mock_settings.notification_urls = []
|
||
|
||
mock_request = Mock()
|
||
|
||
await status_dashboard(mock_request)
|
||
|
||
call_args = mock_templates.TemplateResponse.call_args
|
||
context = call_args[0][1]
|
||
assert context["container_info"]["is_docker"] is True
|
||
assert context["container_info"]["git_sha"] == "Unknown"
|
||
|
||
@patch("app.views.status.get_provider_status")
|
||
@patch("app.views.status.templates")
|
||
@patch("app.views.status.settings")
|
||
@patch("app.views.status.os.path.exists")
|
||
@patch("builtins.open", new_callable=mock_open, read_data="12:docker:/abc123")
|
||
@pytest.mark.asyncio
|
||
async def test_runtime_info_exception_in_docker_env(
|
||
self, mock_file, mock_exists, mock_settings, mock_templates, mock_providers
|
||
):
|
||
"""Test Docker env: settings.runtime_info raises exception → lines 59-60."""
|
||
from unittest.mock import PropertyMock
|
||
|
||
from app.views.status import status_dashboard
|
||
|
||
mock_exists.return_value = True
|
||
mock_providers.return_value = {}
|
||
mock_settings.version = "1.0.0"
|
||
mock_settings.build_date = "2024-01-01"
|
||
mock_settings.git_sha = "abc1234567"
|
||
mock_settings.notification_urls = []
|
||
# Use spec to restrict runtime_info to raise AttributeError
|
||
# since it's not a validated Settings attribute
|
||
type(mock_settings).runtime_info = PropertyMock(side_effect=AttributeError("runtime_info not set"))
|
||
|
||
mock_request = Mock()
|
||
|
||
await status_dashboard(mock_request)
|
||
|
||
call_args = mock_templates.TemplateResponse.call_args
|
||
context = call_args[0][1]
|
||
assert context["container_info"]["is_docker"] is True
|
||
|
||
@patch("app.views.status.get_provider_status")
|
||
@patch("app.views.status.templates")
|
||
@patch("app.views.status.settings")
|
||
@patch("app.views.status.os.path.exists")
|
||
@pytest.mark.asyncio
|
||
async def test_git_sha_exception_in_non_docker_env(
|
||
self, mock_exists, mock_settings, mock_templates, mock_providers
|
||
):
|
||
"""Test non-Docker env: git_sha[:7] raises exception → lines 68-69."""
|
||
from app.views.status import status_dashboard
|
||
|
||
mock_exists.return_value = False # Not in Docker
|
||
mock_providers.return_value = {}
|
||
mock_settings.version = "1.0.0"
|
||
mock_settings.build_date = "2024-01-01"
|
||
# A bool is not subscriptable, so git_sha[:7] raises TypeError → lines 68-69
|
||
mock_settings.git_sha = True
|
||
mock_settings.notification_urls = []
|
||
|
||
mock_request = Mock()
|
||
|
||
await status_dashboard(mock_request)
|
||
|
||
call_args = mock_templates.TemplateResponse.call_args
|
||
context = call_args[0][1]
|
||
assert context["container_info"]["is_docker"] is False
|
||
assert context["container_info"]["git_sha"] == "Unknown"
|
||
|
||
|
||
# ===========================================================================
|
||
# app/utils/config_validator/providers.py – alternative AI providers
|
||
# ===========================================================================
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestProvidersAlternativeAI:
|
||
"""Tests for alternative AI provider branches in get_provider_status."""
|
||
|
||
def test_anthropic_configured(self):
|
||
"""Test provider status with anthropic AI provider (lines 68-69, 86-87)."""
|
||
from app.utils.config_validator.providers import get_provider_status
|
||
|
||
with patch("app.utils.config_validator.providers.settings") as mock_settings:
|
||
mock_settings.ai_provider = "anthropic"
|
||
mock_settings.ai_model = "claude-3-5-sonnet"
|
||
mock_settings.anthropic_api_key = "sk-ant-key"
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.notification_urls = []
|
||
mock_settings.azure_ai_key = None
|
||
mock_settings.azure_endpoint = None
|
||
mock_settings.azure_region = "eastus"
|
||
_set_minimal_provider_settings(mock_settings)
|
||
|
||
result = get_provider_status()
|
||
|
||
ai_provider = result["AI Provider"]
|
||
assert ai_provider["configured"] is True
|
||
assert ai_provider["details"]["provider"] == "anthropic"
|
||
assert "api_key" in ai_provider["details"]
|
||
|
||
def test_gemini_configured(self):
|
||
"""Test provider status with gemini AI provider (lines 70-71, 88-89)."""
|
||
from app.utils.config_validator.providers import get_provider_status
|
||
|
||
with patch("app.utils.config_validator.providers.settings") as mock_settings:
|
||
mock_settings.ai_provider = "gemini"
|
||
mock_settings.ai_model = "gemini-pro"
|
||
mock_settings.gemini_api_key = "gemini-key-123"
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.notification_urls = []
|
||
mock_settings.azure_ai_key = None
|
||
mock_settings.azure_endpoint = None
|
||
mock_settings.azure_region = "eastus"
|
||
_set_minimal_provider_settings(mock_settings)
|
||
|
||
result = get_provider_status()
|
||
|
||
ai_provider = result["AI Provider"]
|
||
assert ai_provider["configured"] is True
|
||
assert ai_provider["details"]["provider"] == "gemini"
|
||
assert "api_key" in ai_provider["details"]
|
||
|
||
def test_ollama_configured(self):
|
||
"""Test provider status with ollama AI provider (lines 72-73, 90-91)."""
|
||
from app.utils.config_validator.providers import get_provider_status
|
||
|
||
with patch("app.utils.config_validator.providers.settings") as mock_settings:
|
||
mock_settings.ai_provider = "ollama"
|
||
mock_settings.ai_model = "llama2"
|
||
mock_settings.ollama_base_url = "http://localhost:11434"
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.notification_urls = []
|
||
mock_settings.azure_ai_key = None
|
||
mock_settings.azure_endpoint = None
|
||
mock_settings.azure_region = "eastus"
|
||
_set_minimal_provider_settings(mock_settings)
|
||
|
||
result = get_provider_status()
|
||
|
||
ai_provider = result["AI Provider"]
|
||
assert ai_provider["configured"] is True
|
||
assert ai_provider["details"]["provider"] == "ollama"
|
||
assert "base_url" in ai_provider["details"]
|
||
|
||
def test_openrouter_configured(self):
|
||
"""Test provider status with openrouter AI provider (lines 74-75, 92-94)."""
|
||
from app.utils.config_validator.providers import get_provider_status
|
||
|
||
with patch("app.utils.config_validator.providers.settings") as mock_settings:
|
||
mock_settings.ai_provider = "openrouter"
|
||
mock_settings.ai_model = "openai/gpt-4"
|
||
mock_settings.openrouter_api_key = "or-key-123"
|
||
mock_settings.openrouter_base_url = "https://openrouter.ai/api/v1"
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.notification_urls = []
|
||
mock_settings.azure_ai_key = None
|
||
mock_settings.azure_endpoint = None
|
||
mock_settings.azure_region = "eastus"
|
||
_set_minimal_provider_settings(mock_settings)
|
||
|
||
result = get_provider_status()
|
||
|
||
ai_provider = result["AI Provider"]
|
||
assert ai_provider["configured"] is True
|
||
assert ai_provider["details"]["provider"] == "openrouter"
|
||
assert "api_key" in ai_provider["details"]
|
||
assert "base_url" in ai_provider["details"]
|
||
|
||
def test_portkey_configured_with_virtual_key(self):
|
||
"""Test provider status with portkey AI provider including virtual key (lines 76-77, 95-100)."""
|
||
from app.utils.config_validator.providers import get_provider_status
|
||
|
||
with patch("app.utils.config_validator.providers.settings") as mock_settings:
|
||
mock_settings.ai_provider = "portkey"
|
||
mock_settings.ai_model = "claude-3"
|
||
mock_settings.portkey_api_key = "pk-key-123"
|
||
mock_settings.portkey_virtual_key = "vk-123"
|
||
mock_settings.portkey_config = None
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.notification_urls = []
|
||
mock_settings.azure_ai_key = None
|
||
mock_settings.azure_endpoint = None
|
||
mock_settings.azure_region = "eastus"
|
||
_set_minimal_provider_settings(mock_settings)
|
||
|
||
result = get_provider_status()
|
||
|
||
ai_provider = result["AI Provider"]
|
||
assert ai_provider["configured"] is True
|
||
assert ai_provider["details"]["provider"] == "portkey"
|
||
assert "api_key" in ai_provider["details"]
|
||
assert "virtual_key" in ai_provider["details"]
|
||
|
||
def test_portkey_configured_with_config(self):
|
||
"""Test provider status with portkey AI provider including config."""
|
||
from app.utils.config_validator.providers import get_provider_status
|
||
|
||
with patch("app.utils.config_validator.providers.settings") as mock_settings:
|
||
mock_settings.ai_provider = "portkey"
|
||
mock_settings.ai_model = "claude-3"
|
||
mock_settings.portkey_api_key = "pk-key-123"
|
||
mock_settings.portkey_virtual_key = None
|
||
mock_settings.portkey_config = '{"strategy": "loadbalance"}'
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.notification_urls = []
|
||
mock_settings.azure_ai_key = None
|
||
mock_settings.azure_endpoint = None
|
||
mock_settings.azure_region = "eastus"
|
||
_set_minimal_provider_settings(mock_settings)
|
||
|
||
result = get_provider_status()
|
||
|
||
ai_provider = result["AI Provider"]
|
||
assert "config" in ai_provider["details"]
|
||
|
||
def test_unknown_ai_provider_not_configured(self):
|
||
"""Test that unknown provider returns configured=False (line 78 – return False)."""
|
||
from app.utils.config_validator.providers import get_provider_status
|
||
|
||
with patch("app.utils.config_validator.providers.settings") as mock_settings:
|
||
mock_settings.ai_provider = "unknown_provider"
|
||
mock_settings.ai_model = None
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.notification_urls = []
|
||
mock_settings.azure_ai_key = None
|
||
mock_settings.azure_endpoint = None
|
||
mock_settings.azure_region = "eastus"
|
||
_set_minimal_provider_settings(mock_settings)
|
||
|
||
result = get_provider_status()
|
||
|
||
ai_provider = result["AI Provider"]
|
||
assert ai_provider["configured"] is False
|
||
|
||
def test_nextcloud_url_with_remote_php(self):
|
||
"""Test that nextcloud base URL is extracted correctly (line 243)."""
|
||
from app.utils.config_validator.providers import get_provider_status
|
||
|
||
with patch("app.utils.config_validator.providers.settings") as mock_settings:
|
||
mock_settings.ai_provider = "openai"
|
||
mock_settings.ai_model = "gpt-4o-mini"
|
||
mock_settings.openai_api_key = "sk-key"
|
||
mock_settings.openai_base_url = "https://api.openai.com/v1"
|
||
mock_settings.auth_enabled = False
|
||
mock_settings.notification_urls = []
|
||
mock_settings.azure_ai_key = None
|
||
mock_settings.azure_endpoint = None
|
||
mock_settings.azure_region = "eastus"
|
||
# NextCloud URL with /remote.php should be split
|
||
mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/dav/files/user"
|
||
mock_settings.nextcloud_username = "user"
|
||
mock_settings.nextcloud_password = "pass"
|
||
mock_settings.nextcloud_folder = "/Docs"
|
||
_set_minimal_provider_settings(mock_settings)
|
||
|
||
result = get_provider_status()
|
||
|
||
nextcloud = result["NextCloud"]
|
||
assert nextcloud["details"]["base_url"] == "https://cloud.example.com"
|
||
|
||
|
||
def _set_minimal_provider_settings(mock_settings):
|
||
"""Helper to set the minimal settings attributes needed by get_provider_status."""
|
||
defaults = {
|
||
"authentik_client_id": None,
|
||
"authentik_client_secret": None,
|
||
"authentik_config_url": None,
|
||
"admin_username": None,
|
||
"oauth_provider_name": None,
|
||
"session_secret": "secret",
|
||
"notify_on_task_failure": True,
|
||
"notify_on_credential_failure": True,
|
||
"notify_on_startup": True,
|
||
"notify_on_shutdown": False,
|
||
"dropbox_app_key": None,
|
||
"dropbox_app_secret": None,
|
||
"dropbox_refresh_token": None,
|
||
"dropbox_folder": "/",
|
||
"email_host": None,
|
||
"email_default_recipient": None,
|
||
"email_port": 587,
|
||
"email_username": None,
|
||
"email_password": None,
|
||
"email_use_tls": True,
|
||
"email_sender": None,
|
||
"dest_email_host": None,
|
||
"dest_email_default_recipient": None,
|
||
"dest_email_port": 587,
|
||
"dest_email_username": None,
|
||
"dest_email_password": None,
|
||
"dest_email_use_tls": True,
|
||
"dest_email_sender": None,
|
||
"ftp_host": None,
|
||
"ftp_username": None,
|
||
"ftp_password": None,
|
||
"ftp_port": 21,
|
||
"ftp_folder": "/",
|
||
"ftp_use_tls": True,
|
||
"ftp_allow_plaintext": False,
|
||
"google_drive_client_id": None,
|
||
"google_drive_client_secret": None,
|
||
"google_drive_refresh_token": None,
|
||
"google_drive_credentials_json": None,
|
||
"google_drive_use_oauth": False,
|
||
"google_drive_folder_id": None,
|
||
"google_drive_delegate_to": None,
|
||
"nextcloud_upload_url": None,
|
||
"nextcloud_username": None,
|
||
"nextcloud_password": None,
|
||
"nextcloud_folder": "/",
|
||
"onedrive_client_id": None,
|
||
"onedrive_client_secret": None,
|
||
"onedrive_refresh_token": None,
|
||
"onedrive_tenant_id": None,
|
||
"onedrive_folder_path": "/",
|
||
"paperless_url": None,
|
||
"paperless_api_token": None,
|
||
"paperless_correspondent_name": None,
|
||
"paperless_document_type_name": None,
|
||
"s3_bucket_name": None,
|
||
"s3_region": None,
|
||
"aws_access_key_id": None,
|
||
"aws_secret_access_key": None,
|
||
"s3_folder_prefix": None,
|
||
"sftp_host": None,
|
||
"sftp_username": None,
|
||
"sftp_password": None,
|
||
"sftp_port": 22,
|
||
"sftp_folder": "/",
|
||
"sftp_private_key": None,
|
||
"webdav_url": None,
|
||
"webdav_username": None,
|
||
"webdav_password": None,
|
||
"webdav_folder": "/",
|
||
"imap_host": None,
|
||
"imap_username": None,
|
||
"imap_password": None,
|
||
"imap_folder": "INBOX",
|
||
"gotenberg_url": "http://localhost:3000",
|
||
"rclone_remote": None,
|
||
"rclone_base_path": None,
|
||
"uptime_kuma_push_url": None,
|
||
# AI provider specifics (set None to avoid AttributeError)
|
||
"anthropic_api_key": None,
|
||
"gemini_api_key": None,
|
||
"ollama_base_url": None,
|
||
"openrouter_api_key": None,
|
||
"openrouter_base_url": None,
|
||
"portkey_api_key": None,
|
||
"portkey_virtual_key": None,
|
||
"portkey_config": None,
|
||
"litellm": None,
|
||
"openai_api_key": None,
|
||
"openai_base_url": "https://api.openai.com/v1",
|
||
}
|
||
for attr, val in defaults.items():
|
||
if not hasattr(mock_settings, attr) or getattr(mock_settings, attr, "NOTSET") == "NOTSET":
|
||
setattr(mock_settings, attr, val)
|
||
|
||
|
||
# ===========================================================================
|
||
# app/utils/settings_sync.py – reload failure and signal handler
|
||
# ===========================================================================
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestSettingsSyncAdditional:
|
||
"""Additional tests for settings_sync covering reload failure branch."""
|
||
|
||
def test_notify_settings_updated_redis_failure_logs_warning(self):
|
||
"""Test that a Redis failure is logged, not raised (lines 58-59)."""
|
||
from app.utils.settings_sync import notify_settings_updated
|
||
|
||
with patch("app.utils.settings_sync.redis") as mock_redis_module:
|
||
mock_redis_module.from_url.side_effect = Exception("Redis connection failed")
|
||
with patch("app.utils.settings_sync.logger") as mock_logger:
|
||
notify_settings_updated()
|
||
mock_logger.warning.assert_any_call(
|
||
"Could not publish settings update to Redis: Redis connection failed"
|
||
)
|
||
|
||
def test_reload_failure_is_logged_not_raised(self):
|
||
"""Test that a reload failure is logged, not raised (lines 71-72)."""
|
||
from app.utils.settings_sync import notify_settings_updated
|
||
|
||
with patch("app.utils.settings_sync.redis") as mock_redis_module:
|
||
mock_redis_module.from_url.return_value = MagicMock() # Redis OK
|
||
with patch("app.utils.config_loader.reload_settings_from_db", side_effect=Exception("reload failed")):
|
||
with patch("app.utils.settings_sync.logger") as mock_logger:
|
||
# Should not raise despite reload failure
|
||
notify_settings_updated()
|
||
mock_logger.warning.assert_any_call("Could not reload in-process settings: reload failed")
|
||
|
||
def test_notify_settings_updated_ocr_failure_logs_warning(self):
|
||
"""Test that OCR check failure is logged, not raised (lines 82-83)."""
|
||
from app.utils.settings_sync import notify_settings_updated
|
||
|
||
with patch("app.utils.settings_sync.redis") as mock_redis_module:
|
||
mock_redis_module.from_url.return_value = MagicMock() # Redis OK
|
||
with patch("app.utils.config_loader.reload_settings_from_db"):
|
||
with patch(
|
||
"app.utils.ocr_language_manager.ensure_ocr_languages_async", side_effect=Exception("OCR failed")
|
||
):
|
||
with patch("app.utils.settings_sync.logger") as mock_logger:
|
||
notify_settings_updated()
|
||
mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR failed")
|
||
|
||
def test_signal_handler_ocr_check_failure_logs_warning(self):
|
||
"""Test that signal handler logs warning if OCR language check fails on worker (lines 115-116)."""
|
||
from app.utils.settings_sync import register_settings_reload_signal
|
||
|
||
handler_fn = None
|
||
|
||
def capture_connect(fn=None, weak=None, **kwargs):
|
||
nonlocal handler_fn
|
||
if fn is not None:
|
||
handler_fn = fn
|
||
return fn
|
||
|
||
def decorator(func):
|
||
nonlocal handler_fn
|
||
handler_fn = func
|
||
return func
|
||
|
||
return decorator
|
||
|
||
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
|
||
mock_signal.connect = capture_connect
|
||
register_settings_reload_signal()
|
||
|
||
assert handler_fn is not None
|
||
|
||
mock_redis = MagicMock()
|
||
mock_redis.get.return_value = b"1234567890.0"
|
||
|
||
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
|
||
mock_redis_mod.from_url.return_value = mock_redis
|
||
with patch("app.utils.config_loader.reload_settings_from_db"):
|
||
with patch("app.utils.settings_sync._last_seen_version", ""):
|
||
with patch(
|
||
"app.utils.ocr_language_manager.ensure_ocr_languages_async",
|
||
side_effect=Exception("Worker OCR fail"),
|
||
):
|
||
with patch("app.utils.settings_sync.logger") as mock_logger:
|
||
handler_fn(sender=None)
|
||
mock_logger.warning.assert_any_call(
|
||
"Could not schedule OCR language check on worker: Worker OCR fail"
|
||
)
|
||
|
||
def test_signal_handler_reloads_on_version_change(self):
|
||
"""Test the task_prerun signal handler reloads settings when version changes (lines 95-98)."""
|
||
from app.utils.settings_sync import register_settings_reload_signal
|
||
|
||
handler_fn = None
|
||
|
||
# Capture the handler; the decorator pattern means connect(weak=False)
|
||
# returns a decorator, which is then applied to _reload_if_stale
|
||
def capture_connect(fn=None, weak=None, **kwargs):
|
||
nonlocal handler_fn
|
||
if fn is not None:
|
||
handler_fn = fn
|
||
return fn
|
||
|
||
def decorator(func):
|
||
nonlocal handler_fn
|
||
handler_fn = func
|
||
return func
|
||
|
||
return decorator
|
||
|
||
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
|
||
mock_signal.connect = capture_connect
|
||
register_settings_reload_signal()
|
||
|
||
assert handler_fn is not None
|
||
|
||
# Simulate handler being called with a new version
|
||
mock_redis = MagicMock()
|
||
mock_redis.get.return_value = b"1234567890.0" # new version
|
||
|
||
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
|
||
mock_redis_mod.from_url.return_value = mock_redis
|
||
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
|
||
with patch("app.utils.settings_sync._last_seen_version", ""):
|
||
handler_fn(sender=None) # call the signal handler
|
||
mock_reload.assert_called_once()
|
||
|
||
def test_signal_handler_skips_reload_when_version_unchanged(self):
|
||
"""Test that handler skips reload when version is same as last seen."""
|
||
import app.utils.settings_sync as sync_module
|
||
from app.utils.settings_sync import register_settings_reload_signal
|
||
|
||
handler_fn = None
|
||
|
||
def capture_connect(fn=None, weak=None, **kwargs):
|
||
nonlocal handler_fn
|
||
if fn is not None:
|
||
handler_fn = fn
|
||
return fn
|
||
|
||
def decorator(func):
|
||
nonlocal handler_fn
|
||
handler_fn = func
|
||
return func
|
||
|
||
return decorator
|
||
|
||
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
|
||
mock_signal.connect = capture_connect
|
||
register_settings_reload_signal()
|
||
|
||
assert handler_fn is not None
|
||
|
||
version = "999.0"
|
||
mock_redis = MagicMock()
|
||
mock_redis.get.return_value = version.encode()
|
||
|
||
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
|
||
mock_redis_mod.from_url.return_value = mock_redis
|
||
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
|
||
# Set last seen to same version
|
||
sync_module._last_seen_version = version
|
||
handler_fn(sender=None)
|
||
mock_reload.assert_not_called()
|
||
|
||
def test_signal_handler_handles_redis_failure(self):
|
||
"""Test that signal handler skips gracefully on Redis failure."""
|
||
from app.utils.settings_sync import register_settings_reload_signal
|
||
|
||
handler_fn = None
|
||
|
||
def capture_connect(fn=None, weak=None, **kwargs):
|
||
nonlocal handler_fn
|
||
if fn is not None:
|
||
handler_fn = fn
|
||
return fn
|
||
|
||
def decorator(func):
|
||
nonlocal handler_fn
|
||
handler_fn = func
|
||
return func
|
||
|
||
return decorator
|
||
|
||
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
|
||
mock_signal.connect = capture_connect
|
||
register_settings_reload_signal()
|
||
|
||
assert handler_fn is not None
|
||
|
||
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
|
||
mock_redis_mod.from_url.side_effect = Exception("Redis down")
|
||
# Should not raise
|
||
handler_fn(sender=None)
|
||
|
||
def test_signal_handler_no_version_returned(self):
|
||
"""Test that handler does nothing if Redis returns None for version."""
|
||
from app.utils.settings_sync import register_settings_reload_signal
|
||
|
||
handler_fn = None
|
||
|
||
def capture_connect(fn=None, weak=None, **kwargs):
|
||
nonlocal handler_fn
|
||
if fn is not None:
|
||
handler_fn = fn
|
||
return fn
|
||
|
||
def decorator(func):
|
||
nonlocal handler_fn
|
||
handler_fn = func
|
||
return func
|
||
|
||
return decorator
|
||
|
||
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
|
||
mock_signal.connect = capture_connect
|
||
register_settings_reload_signal()
|
||
|
||
assert handler_fn is not None
|
||
|
||
mock_redis = MagicMock()
|
||
mock_redis.get.return_value = None # Return None for version
|
||
|
||
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
|
||
mock_redis_mod.from_url.return_value = mock_redis
|
||
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
|
||
handler_fn(sender=None)
|
||
mock_reload.assert_not_called()
|
||
|
||
def test_signal_handler_ocr_language_manager_exception(self):
|
||
"""Test that OCR language check exception inside handler is caught and logged."""
|
||
from app.utils.settings_sync import register_settings_reload_signal
|
||
|
||
handler_fn = None
|
||
|
||
def capture_connect(fn=None, weak=None, **kwargs):
|
||
nonlocal handler_fn
|
||
if fn is not None:
|
||
handler_fn = fn
|
||
return fn
|
||
|
||
def decorator(func):
|
||
nonlocal handler_fn
|
||
handler_fn = func
|
||
return func
|
||
|
||
return decorator
|
||
|
||
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
|
||
mock_signal.connect = capture_connect
|
||
register_settings_reload_signal()
|
||
|
||
assert handler_fn is not None
|
||
|
||
mock_redis = MagicMock()
|
||
mock_redis.get.return_value = b"9999999.0" # New version
|
||
|
||
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
|
||
mock_redis_mod.from_url.return_value = mock_redis
|
||
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
|
||
with patch("app.utils.settings_sync._last_seen_version", "111.0"):
|
||
with patch("app.utils.settings_sync.logger") as mock_logger:
|
||
with patch(
|
||
"app.utils.ocr_language_manager.ensure_ocr_languages_async",
|
||
side_effect=Exception("OCR failed"),
|
||
):
|
||
handler_fn(sender=None)
|
||
mock_reload.assert_called_once()
|
||
mock_logger.warning.assert_called_with(
|
||
"Could not schedule OCR language check on worker: OCR failed"
|
||
)
|
||
|
||
|
||
# ===========================================================================
|
||
# app/api/logs.py – additional branches
|
||
# ===========================================================================
|
||
|
||
|
||
@pytest.mark.integration
|
||
class TestLogsApiAdditional:
|
||
"""Additional tests for logs API to improve branch coverage."""
|
||
|
||
def test_list_logs_filter_by_file_id(self, client, db_session):
|
||
"""Test filtering logs by file_id."""
|
||
from app.models import FileRecord, ProcessingLog
|
||
|
||
file_record = FileRecord(
|
||
filehash="hash123",
|
||
original_filename="test.pdf",
|
||
local_filename="/tmp/test.pdf",
|
||
file_size=1024,
|
||
mime_type="application/pdf",
|
||
)
|
||
db_session.add(file_record)
|
||
db_session.commit()
|
||
|
||
log = ProcessingLog(
|
||
file_id=file_record.id,
|
||
task_id="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||
step_name="process",
|
||
status="success",
|
||
message="Done",
|
||
)
|
||
db_session.add(log)
|
||
db_session.commit()
|
||
|
||
response = client.get(f"/api/logs?file_id={file_record.id}")
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert all(entry["file_id"] == file_record.id for entry in data)
|
||
|
||
def test_list_logs_with_null_timestamp(self, client, db_session):
|
||
"""Test log with null timestamp serializes to None."""
|
||
from app.models import ProcessingLog
|
||
|
||
log = ProcessingLog(
|
||
task_id="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||
step_name="step",
|
||
status="success",
|
||
message="msg",
|
||
)
|
||
log.timestamp = None
|
||
db_session.add(log)
|
||
db_session.commit()
|
||
|
||
response = client.get("/api/logs")
|
||
assert response.status_code == 200
|
||
|
||
def test_get_file_logs_returns_200(self, client, db_session):
|
||
"""Test get file logs endpoint returns data."""
|
||
from app.models import FileRecord, ProcessingLog
|
||
|
||
file_record = FileRecord(
|
||
filehash="hash456",
|
||
original_filename="test2.pdf",
|
||
local_filename="/tmp/test2.pdf",
|
||
file_size=512,
|
||
mime_type="application/pdf",
|
||
)
|
||
db_session.add(file_record)
|
||
db_session.commit()
|
||
|
||
log = ProcessingLog(
|
||
file_id=file_record.id,
|
||
task_id="cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||
step_name="step",
|
||
status="success",
|
||
message="msg",
|
||
)
|
||
db_session.add(log)
|
||
db_session.commit()
|
||
|
||
response = client.get(f"/api/logs/file/{file_record.id}")
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert len(data["logs"]) >= 1
|