diff --git a/tests/test_coverage_config_settings.py b/tests/test_coverage_config_settings.py new file mode 100644 index 00000000..da80cedb --- /dev/null +++ b/tests/test_coverage_config_settings.py @@ -0,0 +1,629 @@ +""" +Tests for improving coverage on config_validator, settings API, license routes, +diagnostic API, and OpenAI API endpoints. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from app.api.settings import require_admin +from app.main import app as fastapi_app + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _override_admin(): + """Dependency override that simulates an admin user.""" + return {"is_admin": True, "name": "admin"} + + +# --------------------------------------------------------------------------- +# 1. app/utils/config_validator.py (backward-compatible re-export wrapper) +# --------------------------------------------------------------------------- + +class TestConfigValidatorReExports: + """Verify the backward-compatible wrapper re-exports all expected symbols.""" + + @pytest.mark.unit + def test_imports_from_wrapper(self): + """All public names are importable from the wrapper module.""" + from app.utils import config_validator as cv + + assert callable(cv.mask_sensitive_value) + assert callable(cv.get_provider_status) + assert callable(cv.dump_all_settings) + assert callable(cv.get_settings_for_display) + assert callable(cv.validate_email_config) + assert callable(cv.validate_storage_configs) + assert callable(cv.validate_notification_config) + assert callable(cv.validate_auth_config) + assert callable(cv.check_all_configs) + + @pytest.mark.unit + def test_all_list_matches_expected_exports(self): + """__all__ contains exactly the expected names.""" + from app.utils import config_validator as cv + + expected = { + "validate_email_config", + "validate_storage_configs", + "validate_notification_config", + "validate_auth_config", + "mask_sensitive_value", + "get_provider_status", + "get_settings_for_display", + "dump_all_settings", + "check_all_configs", + } + assert set(cv.__all__) == expected + + @pytest.mark.unit + def test_mask_sensitive_value_callable(self): + """mask_sensitive_value from wrapper returns a result.""" + from app.utils.config_validator import mask_sensitive_value + + result = mask_sensitive_value("secret-token-12345") + assert isinstance(result, str) + # Should mask part of the value + assert result != "secret-token-12345" + + @pytest.mark.unit + def test_get_provider_status_returns_dict(self): + """get_provider_status returns a dictionary.""" + from app.utils.config_validator import get_provider_status + + result = get_provider_status() + assert isinstance(result, dict) + + @pytest.mark.unit + def test_check_all_configs_returns_dict(self): + """check_all_configs returns a dictionary of validation results.""" + from app.utils.config_validator import check_all_configs + + result = check_all_configs() + assert isinstance(result, dict) + + +# --------------------------------------------------------------------------- +# 2. app/api/settings.py (admin-only settings CRUD) +# --------------------------------------------------------------------------- + +class TestRequireAdminDependency: + """Tests for the require_admin dependency itself.""" + + @pytest.mark.unit + def test_require_admin_raises_when_no_session_user(self): + """require_admin raises 403 when there is no user in session.""" + from fastapi import HTTPException + + mock_request = MagicMock() + mock_request.session.get.return_value = None + with pytest.raises(HTTPException) as exc_info: + require_admin(mock_request) + assert exc_info.value.status_code == 403 + + @pytest.mark.unit + def test_require_admin_raises_when_user_not_admin(self): + """require_admin raises 403 when user is not admin.""" + from fastapi import HTTPException + + mock_request = MagicMock() + mock_request.session.get.return_value = {"name": "user", "is_admin": False} + with pytest.raises(HTTPException) as exc_info: + require_admin(mock_request) + assert exc_info.value.status_code == 403 + + @pytest.mark.unit + def test_require_admin_returns_user_when_admin(self): + """require_admin returns user dict when user is admin.""" + mock_request = MagicMock() + admin_user = {"name": "admin", "is_admin": True} + mock_request.session.get.return_value = admin_user + result = require_admin(mock_request) + assert result == admin_user + + +class TestSettingsGetAll: + """GET /api/settings/ - list all settings.""" + + @pytest.mark.unit + def test_get_settings_success(self, client): + """Successfully retrieve all settings as admin.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + response = client.get("/api/settings/") + assert response.status_code == 200 + data = response.json() + assert "settings" in data + assert "categories" in data + assert "db_settings" in data + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @pytest.mark.unit + def test_get_settings_error_handling(self, client): + """500 error when internal exception occurs.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.get_all_settings_from_db", side_effect=RuntimeError("db error")): + response = client.get("/api/settings/") + assert response.status_code == 500 + assert "Failed to retrieve settings" in response.json()["detail"] + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + +class TestSettingsGetOne: + """GET /api/settings/{key} - get a specific setting.""" + + @pytest.mark.unit + def test_get_setting_known_key(self, client): + """Retrieve a known setting key.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + response = client.get("/api/settings/workdir") + assert response.status_code == 200 + data = response.json() + assert data["key"] == "workdir" + assert "metadata" in data + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @pytest.mark.unit + def test_get_setting_unknown_key(self, client): + """Retrieve an unknown setting key returns value=None.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + response = client.get("/api/settings/nonexistent_key_xyz") + assert response.status_code == 200 + data = response.json() + assert data["key"] == "nonexistent_key_xyz" + assert data["value"] is None + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @pytest.mark.unit + def test_get_setting_internal_error(self, client): + """500 error when get_setting_metadata raises.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.get_setting_metadata", side_effect=RuntimeError("boom")): + response = client.get("/api/settings/workdir") + assert response.status_code == 500 + assert "Failed to retrieve setting" in response.json()["detail"] + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + +class TestSettingsUpdate: + """POST /api/settings/{key} - update a setting.""" + + @pytest.mark.unit + def test_update_setting_success(self, client): + """Successfully update a setting.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.save_setting_to_db", return_value=True), \ + patch("app.api.settings.validate_setting_value", return_value=(True, None)): + response = client.post( + "/api/settings/workdir", + json={"key": "workdir", "value": "/new/path"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["key"] == "workdir" + assert data["value"] == "/new/path" + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @pytest.mark.unit + def test_update_setting_validation_failure(self, client): + """400 error when validation fails.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.validate_setting_value", return_value=(False, "Invalid value")): + response = client.post( + "/api/settings/workdir", + json={"key": "workdir", "value": "bad"}, + ) + assert response.status_code == 400 + assert "Invalid value" in response.json()["detail"] + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @pytest.mark.unit + def test_update_setting_save_failure(self, client): + """500 error when save_setting_to_db returns False.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.validate_setting_value", return_value=(True, None)), \ + patch("app.api.settings.save_setting_to_db", return_value=False): + response = client.post( + "/api/settings/workdir", + json={"key": "workdir", "value": "/tmp"}, + ) + assert response.status_code == 500 + assert "Failed to save setting" in response.json()["detail"] + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @pytest.mark.unit + def test_update_setting_with_none_value(self, client): + """Update a setting with None value (delete semantics).""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.save_setting_to_db", return_value=True): + response = client.post( + "/api/settings/workdir", + json={"key": "workdir", "value": None}, + ) + assert response.status_code == 200 + assert response.json()["success"] is True + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @pytest.mark.unit + def test_update_setting_unexpected_error(self, client): + """500 error when an unexpected exception is raised.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.validate_setting_value", return_value=(True, None)), \ + patch("app.api.settings.save_setting_to_db", side_effect=RuntimeError("unexpected")): + response = client.post( + "/api/settings/workdir", + json={"key": "workdir", "value": "/tmp"}, + ) + assert response.status_code == 500 + assert "Failed to update setting" in response.json()["detail"] + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + +class TestSettingsDelete: + """DELETE /api/settings/{key} - delete a setting.""" + + @pytest.mark.unit + def test_delete_setting_success(self, client): + """Successfully delete a setting.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.delete_setting_from_db", return_value=True): + response = client.delete("/api/settings/workdir") + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "deleted" in data["message"].lower() + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @pytest.mark.unit + def test_delete_setting_not_found(self, client): + """404 error when setting not found in DB.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.delete_setting_from_db", return_value=False): + response = client.delete("/api/settings/nonexistent") + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + @pytest.mark.unit + def test_delete_setting_unexpected_error(self, client): + """500 error when an unexpected exception is raised.""" + fastapi_app.dependency_overrides[require_admin] = _override_admin + try: + with patch("app.api.settings.delete_setting_from_db", side_effect=RuntimeError("db crash")): + response = client.delete("/api/settings/workdir") + assert response.status_code == 500 + assert "Failed to delete setting" in response.json()["detail"] + finally: + fastapi_app.dependency_overrides.pop(require_admin, None) + + +class TestSettingsBulkUpdate: + """Tests for bulk_update_settings handler. + + The /bulk-update route is defined after /{key} in the router, so FastAPI + matches /{key} first. We test the async handler function directly. + """ + + def _make_mock_db(self): + return MagicMock() + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_bulk_update_all_success(self): + """Successfully bulk update multiple settings.""" + from app.api.settings import SettingUpdate, bulk_update_settings + + mock_request = MagicMock() + mock_db = self._make_mock_db() + mock_admin = {"is_admin": True, "name": "admin"} + updates = [ + SettingUpdate(key="workdir", value="/tmp/a"), + SettingUpdate(key="external_hostname", value="example.com"), + ] + + with patch("app.api.settings.validate_setting_value", return_value=(True, None)), \ + patch("app.api.settings.save_setting_to_db", return_value=True): + result = await bulk_update_settings(updates, mock_request, mock_db, mock_admin) + assert result["success"] is True + assert len(result["updated"]) == 2 + assert len(result["errors"]) == 0 + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_bulk_update_with_validation_error(self): + """Bulk update skips invalid settings and reports errors.""" + from app.api.settings import SettingUpdate, bulk_update_settings + + def mock_validate(key, value): + if key == "bad_key": + return (False, "Invalid value for bad_key") + return (True, None) + + mock_request = MagicMock() + mock_db = self._make_mock_db() + updates = [ + SettingUpdate(key="workdir", value="/tmp"), + SettingUpdate(key="bad_key", value="invalid"), + ] + + with patch("app.api.settings.validate_setting_value", side_effect=mock_validate), \ + patch("app.api.settings.save_setting_to_db", return_value=True): + result = await bulk_update_settings(updates, mock_request, mock_db, {"is_admin": True}) + assert result["success"] is False + assert len(result["updated"]) == 1 + assert len(result["errors"]) == 1 + assert result["errors"][0]["key"] == "bad_key" + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_bulk_update_save_failure(self): + """Bulk update reports errors when save_setting_to_db returns False.""" + from app.api.settings import SettingUpdate, bulk_update_settings + + mock_request = MagicMock() + mock_db = self._make_mock_db() + updates = [SettingUpdate(key="workdir", value="/tmp")] + + with patch("app.api.settings.validate_setting_value", return_value=(True, None)), \ + patch("app.api.settings.save_setting_to_db", return_value=False): + result = await bulk_update_settings(updates, mock_request, mock_db, {"is_admin": True}) + assert result["success"] is False + assert len(result["errors"]) == 1 + assert "Failed to save" in result["errors"][0]["error"] + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_bulk_update_with_exception_during_save(self): + """Bulk update catches per-item exceptions and reports them.""" + from app.api.settings import SettingUpdate, bulk_update_settings + + mock_request = MagicMock() + mock_db = self._make_mock_db() + updates = [SettingUpdate(key="workdir", value="/tmp")] + + with patch("app.api.settings.validate_setting_value", return_value=(True, None)), \ + patch("app.api.settings.save_setting_to_db", side_effect=RuntimeError("boom")): + result = await bulk_update_settings(updates, mock_request, mock_db, {"is_admin": True}) + assert result["success"] is False + assert len(result["errors"]) == 1 + assert "boom" in result["errors"][0]["error"] + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_bulk_update_with_none_value(self): + """Bulk update with None value skips validation.""" + from app.api.settings import SettingUpdate, bulk_update_settings + + mock_request = MagicMock() + mock_db = self._make_mock_db() + updates = [SettingUpdate(key="workdir", value=None)] + + with patch("app.api.settings.save_setting_to_db", return_value=True): + result = await bulk_update_settings(updates, mock_request, mock_db, {"is_admin": True}) + assert result["success"] is True + assert len(result["updated"]) == 1 + + +# --------------------------------------------------------------------------- +# 3. app/views/license_routes.py +# --------------------------------------------------------------------------- + +class TestLicenseRoutes: + """Tests for license and attribution view routes.""" + + @pytest.mark.unit + def test_get_lgpl_license_success(self, client): + """GET /licenses/lgpl.txt returns the LGPL license text.""" + response = client.get("/licenses/lgpl.txt") + assert response.status_code == 200 + assert "text/plain" in response.headers["content-type"] + # LGPL license files typically contain recognizable text + assert len(response.text) > 0 + + @pytest.mark.unit + def test_get_lgpl_license_file_missing(self, client): + """GET /licenses/lgpl.txt returns 404 when file doesn't exist.""" + with patch("app.views.license_routes.Path") as MockPath: + mock_path_instance = MagicMock() + mock_path_instance.exists.return_value = False + MockPath.return_value = mock_path_instance + response = client.get("/licenses/lgpl.txt") + assert response.status_code == 404 + # HTTPException returns JSON even with PlainTextResponse response_class + assert "not found" in response.text.lower() + + @pytest.mark.unit + def test_serve_attribution_page(self, client): + """GET /attribution returns the attribution HTML page.""" + response = client.get("/attribution") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +# --------------------------------------------------------------------------- +# 4. app/api/diagnostic.py +# --------------------------------------------------------------------------- + +class TestDiagnosticSettings: + """GET /api/diagnostic/settings - dump settings.""" + + @pytest.mark.unit + def test_diagnostic_settings_success(self, client): + """Returns safe subset of settings.""" + with patch("app.utils.config_validator.dump_all_settings"): + response = client.get("/api/diagnostic/settings") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert "settings" in data + assert "configured_services" in data["settings"] + + +class TestDiagnosticTestNotification: + """POST /api/diagnostic/test-notification - send test notification.""" + + @pytest.mark.unit + def test_notification_no_urls_configured(self, client): + """Returns warning when no notification URLs are configured.""" + with patch("app.config.settings.notification_urls", new=[], create=True): + response = client.post("/api/diagnostic/test-notification") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "warning" + assert "No notification" in data["message"] + + @pytest.mark.unit + def test_notification_send_success(self, client): + """Returns success when notification is sent.""" + with patch("app.config.settings.notification_urls", new=["http://ntfy.example.com/test"], create=True), \ + patch("app.utils.notification.send_notification", return_value=True) as mock_send: + response = client.post("/api/diagnostic/test-notification") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["services_count"] == 1 + mock_send.assert_called_once() + + @pytest.mark.unit + def test_notification_send_failure(self, client): + """Returns error when send_notification returns False.""" + with patch("app.config.settings.notification_urls", new=["http://ntfy.example.com/test"], create=True), \ + patch("app.utils.notification.send_notification", return_value=False): + response = client.post("/api/diagnostic/test-notification") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "error" + assert "Failed" in data["message"] + + @pytest.mark.unit + def test_notification_send_exception(self, client): + """Returns error when send_notification raises an exception.""" + with patch("app.config.settings.notification_urls", new=["http://ntfy.example.com/test"], create=True), \ + patch("app.utils.notification.send_notification", side_effect=RuntimeError("connection refused")): + response = client.post("/api/diagnostic/test-notification") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "error" + assert "connection refused" in data["message"] + + +# --------------------------------------------------------------------------- +# 5. app/api/openai.py +# --------------------------------------------------------------------------- + +class TestOpenAITestEndpoint: + """GET /api/openai/test - test OpenAI API key.""" + + @pytest.mark.unit + def test_openai_no_api_key(self, client): + """Returns error when no API key is configured.""" + with patch("app.config.settings.openai_api_key", new=""): + response = client.get("/api/openai/test") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "error" + assert "No OpenAI API key" in data["message"] + + @pytest.mark.unit + def test_openai_valid_key(self, client): + """Returns success when API key is valid.""" + mock_models = MagicMock() + mock_models.data = [MagicMock(), MagicMock(), MagicMock()] + + mock_client_instance = MagicMock() + mock_client_instance.models.list.return_value = mock_models + + with patch("app.config.settings.openai_api_key", new="sk-valid-key"), \ + patch("openai.OpenAI", return_value=mock_client_instance): + response = client.get("/api/openai/test") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["models_available"] == 3 + + @pytest.mark.unit + def test_openai_auth_error(self, client): + """Returns error with auth flag when key is invalid.""" + mock_client_instance = MagicMock() + mock_client_instance.models.list.side_effect = Exception("Incorrect API key provided") + + with patch("app.config.settings.openai_api_key", new="sk-bad-key"), \ + patch("openai.OpenAI", return_value=mock_client_instance): + response = client.get("/api/openai/test") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "error" + assert data["is_auth_error"] is True + + @pytest.mark.unit + def test_openai_non_auth_error(self, client): + """Returns error without auth flag for non-auth failures.""" + mock_client_instance = MagicMock() + mock_client_instance.models.list.side_effect = Exception("Connection timeout") + + with patch("app.config.settings.openai_api_key", new="sk-valid-key"), \ + patch("openai.OpenAI", return_value=mock_client_instance): + response = client.get("/api/openai/test") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "error" + assert data["is_auth_error"] is False + assert "Connection timeout" in data["message"] + + @pytest.mark.unit + def test_openai_import_error(self, client): + """Returns error when openai package is not installed.""" + import builtins + + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "openai": + raise ImportError("No module named 'openai'") + return original_import(name, *args, **kwargs) + + with patch("app.config.settings.openai_api_key", new="sk-key"), \ + patch("builtins.__import__", side_effect=mock_import): + response = client.get("/api/openai/test") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "error" + assert "not installed" in data["message"] + + @pytest.mark.unit + def test_openai_unexpected_error(self, client): + """Returns error for unexpected exceptions outside the inner try.""" + with patch("app.config.settings.openai_api_key", new="sk-key"), \ + patch("openai.OpenAI", side_effect=RuntimeError("unexpected crash")): + response = client.get("/api/openai/test") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "error" + assert "Unexpected error" in data["message"] diff --git a/tests/test_coverage_uploads_notification.py b/tests/test_coverage_uploads_notification.py new file mode 100644 index 00000000..51677b7c --- /dev/null +++ b/tests/test_coverage_uploads_notification.py @@ -0,0 +1,876 @@ +""" +Tests targeting uncovered lines in: +- app/tasks/upload_to_s3.py (lines 43-46, 49-52, 67-71, 78, 99-109) +- app/tasks/upload_to_sftp.py (lines 39-42, 62-63, 80-88, 103, 107-111, 128-132, 146-158) +- app/utils/notification.py (lines 74-118) +- app/tasks/send_to_all.py (lines 50, 76, 85-105, 122-146, 210-212, 228-230) +""" + +from contextlib import ExitStack +from unittest.mock import MagicMock, patch + +import pytest + +from app.tasks.upload_to_s3 import upload_to_s3 +from app.tasks.upload_to_sftp import upload_to_sftp +from app.tasks.send_to_all import send_to_all_destinations + +_TEST_CRED = "test_secret" # noqa: S105 + + +# --------------------------------------------------------------------------- +# S3 upload tests +# --------------------------------------------------------------------------- + + +class TestUploadToS3Coverage: + """Tests for upload_to_s3 covering uncovered lines.""" + + @pytest.mark.unit + def test_no_bucket_name_raises(self, tmp_path): + """Lines 43-46: ValueError when s3_bucket_name is empty.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + with ( + patch("app.tasks.upload_to_s3.settings") as ms, + patch("app.tasks.upload_to_s3.log_task_progress"), + ): + ms.s3_bucket_name = "" + ms.aws_access_key_id = "key" + ms.aws_secret_access_key = _TEST_CRED + + with pytest.raises(ValueError, match="bucket name"): + upload_to_s3.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + @pytest.mark.unit + def test_no_aws_credentials_raises(self, tmp_path): + """Lines 49-52: ValueError when AWS credentials missing.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + with ( + patch("app.tasks.upload_to_s3.settings") as ms, + patch("app.tasks.upload_to_s3.log_task_progress"), + ): + ms.s3_bucket_name = "my-bucket" + ms.aws_access_key_id = "" + ms.aws_secret_access_key = "" + + with pytest.raises(ValueError, match="credentials"): + upload_to_s3.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + @pytest.mark.unit + def test_folder_prefix_appends_slash(self, tmp_path): + """Lines 67-71: folder prefix without trailing slash gets one added.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_s3 = MagicMock() + with ( + patch("app.tasks.upload_to_s3.settings") as ms, + patch("app.tasks.upload_to_s3.boto3") as mock_boto3, + patch("app.tasks.upload_to_s3.log_task_progress"), + ): + ms.s3_bucket_name = "my-bucket" + ms.aws_access_key_id = "key" + ms.aws_secret_access_key = _TEST_CRED + ms.aws_region = "us-east-1" + ms.s3_folder_prefix = "docs" # no trailing slash + ms.s3_storage_class = "STANDARD" + ms.s3_acl = "" + mock_boto3.client.return_value = mock_s3 + + result = upload_to_s3.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + uploaded_key = mock_s3.upload_file.call_args[0][2] + assert uploaded_key == "docs/test.pdf" + assert result["status"] == "Completed" + + @pytest.mark.unit + def test_s3_key_without_prefix(self, tmp_path): + """Line 71: s3_key = filename when no folder prefix.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_s3 = MagicMock() + with ( + patch("app.tasks.upload_to_s3.settings") as ms, + patch("app.tasks.upload_to_s3.boto3") as mock_boto3, + patch("app.tasks.upload_to_s3.log_task_progress"), + ): + ms.s3_bucket_name = "my-bucket" + ms.aws_access_key_id = "key" + ms.aws_secret_access_key = _TEST_CRED + ms.aws_region = "us-east-1" + ms.s3_folder_prefix = "" + ms.s3_storage_class = "STANDARD" + ms.s3_acl = "" + mock_boto3.client.return_value = mock_s3 + + result = upload_to_s3.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + assert mock_s3.upload_file.call_args[0][2] == "test.pdf" + assert result["s3_key"] == "test.pdf" + + @pytest.mark.unit + def test_acl_added_when_configured(self, tmp_path): + """Line 78: ACL added to extra_args.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_s3 = MagicMock() + with ( + patch("app.tasks.upload_to_s3.settings") as ms, + patch("app.tasks.upload_to_s3.boto3") as mock_boto3, + patch("app.tasks.upload_to_s3.log_task_progress"), + ): + ms.s3_bucket_name = "my-bucket" + ms.aws_access_key_id = "key" + ms.aws_secret_access_key = _TEST_CRED + ms.aws_region = "us-east-1" + ms.s3_folder_prefix = "" + ms.s3_storage_class = "STANDARD" + ms.s3_acl = "public-read" + mock_boto3.client.return_value = mock_s3 + + upload_to_s3.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + extra_args = mock_s3.upload_file.call_args[1]["ExtraArgs"] + assert extra_args["ACL"] == "public-read" + + @pytest.mark.unit + def test_client_error_handler(self, tmp_path): + """Lines 99-109: ClientError is caught and re-raised.""" + from botocore.exceptions import ClientError + + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_s3 = MagicMock() + error_response = {"Error": {"Code": "NoSuchBucket", "Message": "not found"}} + mock_s3.upload_file.side_effect = ClientError(error_response, "PutObject") + + with ( + patch("app.tasks.upload_to_s3.settings") as ms, + patch("app.tasks.upload_to_s3.boto3") as mock_boto3, + patch("app.tasks.upload_to_s3.log_task_progress"), + ): + ms.s3_bucket_name = "my-bucket" + ms.aws_access_key_id = "key" + ms.aws_secret_access_key = _TEST_CRED + ms.aws_region = "us-east-1" + ms.s3_folder_prefix = "" + ms.s3_storage_class = "STANDARD" + ms.s3_acl = "" + mock_boto3.client.return_value = mock_s3 + + with pytest.raises(Exception, match="Failed to upload"): + upload_to_s3.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + @pytest.mark.unit + def test_generic_exception_handler(self, tmp_path): + """Lines 105-109: Generic exception is caught and re-raised.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_s3 = MagicMock() + mock_s3.upload_file.side_effect = RuntimeError("network timeout") + + with ( + patch("app.tasks.upload_to_s3.settings") as ms, + patch("app.tasks.upload_to_s3.boto3") as mock_boto3, + patch("app.tasks.upload_to_s3.log_task_progress"), + ): + ms.s3_bucket_name = "my-bucket" + ms.aws_access_key_id = "key" + ms.aws_secret_access_key = _TEST_CRED + ms.aws_region = "us-east-1" + ms.s3_folder_prefix = "" + ms.s3_storage_class = "STANDARD" + ms.s3_acl = "" + mock_boto3.client.return_value = mock_s3 + + with pytest.raises(Exception, match="Error uploading"): + upload_to_s3.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + +# --------------------------------------------------------------------------- +# SFTP upload tests +# --------------------------------------------------------------------------- + + +class TestUploadToSftpCoverage: + """Tests for upload_to_sftp covering uncovered lines.""" + + def _sftp_settings(self, ms, password="pass", key=None, passphrase=None, disable_verify=True): + """Helper to configure common SFTP mock settings.""" + ms.sftp_host = "host" + ms.sftp_port = 22 + ms.sftp_username = "user" + ms.sftp_password = password + ms.sftp_folder = "/upload" + ms.sftp_disable_host_key_verification = disable_verify + ms.sftp_private_key = key + ms.sftp_private_key_passphrase = passphrase + ms.workdir = "/workdir" + + @pytest.mark.unit + def test_missing_config_skips(self, tmp_path): + """Lines 39-42: Skipped when sftp config is incomplete.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + with ( + patch("app.tasks.upload_to_sftp.settings") as ms, + patch("app.tasks.upload_to_sftp.log_task_progress"), + ): + ms.sftp_host = "" + ms.sftp_port = 22 + ms.sftp_username = "" + + result = upload_to_sftp.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + assert result["status"] == "Skipped" + + @pytest.mark.unit + def test_system_host_keys_loaded(self, tmp_path): + """Lines 62-63: load_system_host_keys + RejectPolicy when verification enabled.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_ssh = MagicMock() + mock_sftp_client = MagicMock() + mock_ssh.open_sftp.return_value = mock_sftp_client + mock_sftp_client.stat.return_value = MagicMock() + + with ( + patch("app.tasks.upload_to_sftp.settings") as ms, + patch("app.tasks.upload_to_sftp.paramiko") as mock_paramiko, + patch("app.tasks.upload_to_sftp.log_task_progress"), + patch("app.tasks.upload_to_sftp.sanitize_filename", return_value="test.pdf"), + patch("app.tasks.upload_to_sftp.extract_remote_path", return_value="/upload/test.pdf"), + patch("app.tasks.upload_to_sftp.get_unique_filename", return_value="/upload/test.pdf"), + ): + self._sftp_settings(ms, disable_verify=False) + mock_paramiko.SSHClient.return_value = mock_ssh + + result = upload_to_sftp.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + mock_ssh.load_system_host_keys.assert_called_once() + mock_ssh.set_missing_host_key_policy.assert_called_once_with(mock_paramiko.RejectPolicy()) + assert result["status"] == "Completed" + + @pytest.mark.unit + def test_key_auth_with_passphrase(self, tmp_path): + """Lines 80-81: key_filename and passphrase in connect_kwargs.""" + f = tmp_path / "test.pdf" + f.write_text("data") + key_file = tmp_path / "id_rsa" + key_file.write_text("fake-key") + + mock_ssh = MagicMock() + mock_sftp_client = MagicMock() + mock_ssh.open_sftp.return_value = mock_sftp_client + mock_sftp_client.stat.return_value = MagicMock() + + with ( + patch("app.tasks.upload_to_sftp.settings") as ms, + patch("app.tasks.upload_to_sftp.paramiko") as mock_paramiko, + patch("app.tasks.upload_to_sftp.log_task_progress"), + patch("app.tasks.upload_to_sftp.sanitize_filename", return_value="test.pdf"), + patch("app.tasks.upload_to_sftp.extract_remote_path", return_value="/upload/test.pdf"), + patch("app.tasks.upload_to_sftp.get_unique_filename", return_value="/upload/test.pdf"), + ): + self._sftp_settings(ms, password="", key=str(key_file), passphrase="my-passphrase") + mock_paramiko.SSHClient.return_value = mock_ssh + + result = upload_to_sftp.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + connect_kwargs = mock_ssh.connect.call_args[1] + assert connect_kwargs["key_filename"] == str(key_file) + assert connect_kwargs["passphrase"] == "my-passphrase" + assert result["status"] == "Completed" + + @pytest.mark.unit + def test_password_auth(self, tmp_path): + """Lines 82-84: password authentication branch.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_ssh = MagicMock() + mock_sftp_client = MagicMock() + mock_ssh.open_sftp.return_value = mock_sftp_client + mock_sftp_client.stat.return_value = MagicMock() + + with ( + patch("app.tasks.upload_to_sftp.settings") as ms, + patch("app.tasks.upload_to_sftp.paramiko") as mock_paramiko, + patch("app.tasks.upload_to_sftp.log_task_progress"), + patch("app.tasks.upload_to_sftp.sanitize_filename", return_value="test.pdf"), + patch("app.tasks.upload_to_sftp.extract_remote_path", return_value="/upload/test.pdf"), + patch("app.tasks.upload_to_sftp.get_unique_filename", return_value="/upload/test.pdf"), + ): + self._sftp_settings(ms, password="secret-pass") + mock_paramiko.SSHClient.return_value = mock_ssh + + result = upload_to_sftp.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + connect_kwargs = mock_ssh.connect.call_args[1] + assert connect_kwargs["password"] == "secret-pass" + assert "key_filename" not in connect_kwargs + assert result["status"] == "Completed" + + @pytest.mark.unit + def test_no_auth_method_raises(self, tmp_path): + """Lines 86-88: raises when no key or password.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_ssh = MagicMock() + + with ( + patch("app.tasks.upload_to_sftp.settings") as ms, + patch("app.tasks.upload_to_sftp.paramiko") as mock_paramiko, + patch("app.tasks.upload_to_sftp.log_task_progress"), + patch("app.tasks.upload_to_sftp.sanitize_filename", return_value="test.pdf"), + ): + self._sftp_settings(ms, password="") + mock_paramiko.SSHClient.return_value = mock_ssh + + with pytest.raises(Exception, match="Failed to upload"): + upload_to_sftp.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + @pytest.mark.unit + def test_remote_path_slash_prepended(self, tmp_path): + """Line 103: slash prepended when remote_base starts with /.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_ssh = MagicMock() + mock_sftp_client = MagicMock() + mock_ssh.open_sftp.return_value = mock_sftp_client + mock_sftp_client.stat.return_value = MagicMock() + + with ( + patch("app.tasks.upload_to_sftp.settings") as ms, + patch("app.tasks.upload_to_sftp.paramiko") as mock_paramiko, + patch("app.tasks.upload_to_sftp.log_task_progress"), + patch("app.tasks.upload_to_sftp.sanitize_filename", return_value="test.pdf"), + patch("app.tasks.upload_to_sftp.extract_remote_path", return_value="upload/test.pdf"), + patch("app.tasks.upload_to_sftp.get_unique_filename", side_effect=lambda p, _: p), + ): + self._sftp_settings(ms) + ms.sftp_folder = "/data" # starts with / + mock_paramiko.SSHClient.return_value = mock_ssh + + result = upload_to_sftp.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + assert result["sftp_path"].startswith("/") + + @pytest.mark.unit + def test_remote_dir_creation(self, tmp_path): + """Lines 107-111, 128-132: creates remote directories when they don't exist.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_ssh = MagicMock() + mock_sftp_client = MagicMock() + mock_ssh.open_sftp.return_value = mock_sftp_client + + def stat_side_effect(path): + if path in ("/upload", "/upload/sub"): + raise FileNotFoundError() + return MagicMock() + + mock_sftp_client.stat.side_effect = stat_side_effect + + with ( + patch("app.tasks.upload_to_sftp.settings") as ms, + patch("app.tasks.upload_to_sftp.paramiko") as mock_paramiko, + patch("app.tasks.upload_to_sftp.log_task_progress"), + patch("app.tasks.upload_to_sftp.sanitize_filename", return_value="test.pdf"), + patch("app.tasks.upload_to_sftp.extract_remote_path", return_value="/upload/sub/test.pdf"), + patch("app.tasks.upload_to_sftp.get_unique_filename", side_effect=lambda p, _: p), + ): + self._sftp_settings(ms) + ms.sftp_folder = "/" + mock_paramiko.SSHClient.return_value = mock_ssh + + result = upload_to_sftp.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + mock_sftp_client.mkdir.assert_any_call("/upload") + mock_sftp_client.mkdir.assert_any_call("/upload/sub") + assert result["status"] == "Completed" + + @pytest.mark.unit + def test_mkdir_exception_logged(self, tmp_path): + """Lines 128-132: exception during mkdir is caught as warning.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_ssh = MagicMock() + mock_sftp_client = MagicMock() + mock_ssh.open_sftp.return_value = mock_sftp_client + mock_sftp_client.stat.side_effect = FileNotFoundError() + mock_sftp_client.mkdir.side_effect = PermissionError("denied") + + with ( + patch("app.tasks.upload_to_sftp.settings") as ms, + patch("app.tasks.upload_to_sftp.paramiko") as mock_paramiko, + patch("app.tasks.upload_to_sftp.log_task_progress"), + patch("app.tasks.upload_to_sftp.sanitize_filename", return_value="test.pdf"), + patch("app.tasks.upload_to_sftp.extract_remote_path", return_value="/upload/test.pdf"), + patch("app.tasks.upload_to_sftp.get_unique_filename", side_effect=lambda p, _: p), + ): + self._sftp_settings(ms) + ms.sftp_folder = "/" + mock_paramiko.SSHClient.return_value = mock_ssh + + result = upload_to_sftp.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + assert result["status"] == "Completed" + + @pytest.mark.unit + def test_cleanup_on_exception(self, tmp_path): + """Lines 146-158: connections cleaned up on exception.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_ssh = MagicMock() + mock_ssh.connect.side_effect = ConnectionRefusedError("refused") + + with ( + patch("app.tasks.upload_to_sftp.settings") as ms, + patch("app.tasks.upload_to_sftp.paramiko") as mock_paramiko, + patch("app.tasks.upload_to_sftp.log_task_progress"), + patch("app.tasks.upload_to_sftp.sanitize_filename", return_value="test.pdf"), + ): + self._sftp_settings(ms) + mock_paramiko.SSHClient.return_value = mock_ssh + + with pytest.raises(Exception, match="Failed to upload"): + upload_to_sftp.apply(args=[str(f)], kwargs={"file_id": 1}).get() + + mock_ssh.close.assert_called() + + +# --------------------------------------------------------------------------- +# Notification tests +# --------------------------------------------------------------------------- + + +class TestNotificationCoverage: + """Tests for app/utils/notification covering lines 74-118.""" + + def _reset_apprise(self): + import app.utils.notification as notif_module + + notif_module._apprise = None + + @pytest.mark.unit + def test_send_notification_success(self): + """Lines 74-114: full body with successful server.""" + self._reset_apprise() + + mock_server = MagicMock() + mock_server.__str__ = lambda s: "json://localhost" + mock_server.notify.return_value = True + + mock_apprise_instance = MagicMock() + mock_apprise_instance.servers = [mock_server] + + with ( + patch("app.utils.notification.settings") as ms, + patch("app.utils.notification.init_apprise", return_value=mock_apprise_instance), + ): + ms.notification_urls = ["json://localhost"] + from app.utils.notification import send_notification + + result = send_notification("Test Title", "Test body", notification_type="success") + + assert result is True + mock_server.notify.assert_called_once() + + @pytest.mark.unit + def test_send_notification_failure_type(self): + """Lines 83-84: notification_type='failure' maps to FAILURE.""" + self._reset_apprise() + + mock_server = MagicMock() + mock_server.__str__ = lambda s: "json://localhost" + mock_server.notify.return_value = True + + mock_apprise_instance = MagicMock() + mock_apprise_instance.servers = [mock_server] + + with ( + patch("app.utils.notification.settings") as ms, + patch("app.utils.notification.init_apprise", return_value=mock_apprise_instance), + ): + ms.notification_urls = ["json://localhost"] + from app.utils.notification import send_notification + + result = send_notification("Fail", "Something failed", notification_type="failure") + + assert result is True + + @pytest.mark.unit + def test_send_notification_warning_type(self): + """Lines 81-82: notification_type='warn' maps to WARNING.""" + self._reset_apprise() + + mock_server = MagicMock() + mock_server.__str__ = lambda s: "json://localhost" + mock_server.notify.return_value = True + + mock_apprise_instance = MagicMock() + mock_apprise_instance.servers = [mock_server] + + with ( + patch("app.utils.notification.settings") as ms, + patch("app.utils.notification.init_apprise", return_value=mock_apprise_instance), + ): + ms.notification_urls = ["json://localhost"] + from app.utils.notification import send_notification + + result = send_notification("Warn", "Warning msg", notification_type="warn") + + assert result is True + + @pytest.mark.unit + def test_send_notification_no_servers(self): + """Lines 87-89: returns False when servers list is empty.""" + self._reset_apprise() + + mock_apprise_instance = MagicMock() + mock_apprise_instance.servers = [] + + with ( + patch("app.utils.notification.settings") as ms, + patch("app.utils.notification.init_apprise", return_value=mock_apprise_instance), + ): + ms.notification_urls = ["json://localhost"] + from app.utils.notification import send_notification + + result = send_notification("Title", "Body") + + assert result is False + + @pytest.mark.unit + def test_send_notification_server_fails(self): + """Lines 102-103: server.notify returns False.""" + self._reset_apprise() + + mock_server = MagicMock() + mock_server.__str__ = lambda s: "json://localhost" + mock_server.notify.return_value = False + + mock_apprise_instance = MagicMock() + mock_apprise_instance.servers = [mock_server] + + with ( + patch("app.utils.notification.settings") as ms, + patch("app.utils.notification.init_apprise", return_value=mock_apprise_instance), + ): + ms.notification_urls = ["json://localhost"] + from app.utils.notification import send_notification + + result = send_notification("Title", "Body") + + assert result is False + + @pytest.mark.unit + def test_send_notification_server_exception(self): + """Lines 104-105: exception from server.notify is caught.""" + self._reset_apprise() + + mock_server = MagicMock() + mock_server.__str__ = lambda s: "json://localhost" + mock_server.notify.side_effect = RuntimeError("boom") + + mock_apprise_instance = MagicMock() + mock_apprise_instance.servers = [mock_server] + + with ( + patch("app.utils.notification.settings") as ms, + patch("app.utils.notification.init_apprise", return_value=mock_apprise_instance), + ): + ms.notification_urls = ["json://localhost"] + from app.utils.notification import send_notification + + result = send_notification("Title", "Body") + + assert result is False + + @pytest.mark.unit + def test_send_notification_outer_exception(self): + """Lines 116-118: outer exception returns False.""" + self._reset_apprise() + + with ( + patch("app.utils.notification.settings") as ms, + patch("app.utils.notification.init_apprise", side_effect=RuntimeError("init failed")), + ): + ms.notification_urls = ["json://localhost"] + from app.utils.notification import send_notification + + result = send_notification("Title", "Body") + + assert result is False + + @pytest.mark.unit + def test_send_notification_partial_success(self): + """Lines 91-114: one server succeeds, one fails -> True.""" + self._reset_apprise() + + good = MagicMock() + good.__str__ = lambda s: "json://good" + good.notify.return_value = True + + bad = MagicMock() + bad.__str__ = lambda s: "json://bad" + bad.notify.return_value = False + + mock_apprise_instance = MagicMock() + mock_apprise_instance.servers = [good, bad] + + with ( + patch("app.utils.notification.settings") as ms, + patch("app.utils.notification.init_apprise", return_value=mock_apprise_instance), + ): + ms.notification_urls = ["json://good", "json://bad"] + from app.utils.notification import send_notification + + result = send_notification("Title", "Body", notification_type="info") + + assert result is True + + +# --------------------------------------------------------------------------- +# send_to_all_destinations tests +# --------------------------------------------------------------------------- + + +def _all_should_upload_false(): + """Return a list of patch context managers that set all _should_upload_* to False.""" + services = [ + "dropbox", "nextcloud", "paperless", "google_drive", + "webdav", "ftp", "sftp", "email", "onedrive", "s3", + ] + return [ + patch(f"app.tasks.send_to_all._should_upload_to_{s}", return_value=False) + for s in services + ] + + +class TestSendToAllCoverage: + """Tests for send_to_all_destinations covering uncovered lines.""" + + @pytest.mark.unit + def test_should_upload_returns_false(self, tmp_path): + """Line 50: service skipped when should_upload returns False.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + with ( + patch("app.tasks.send_to_all.os.path.exists", return_value=True), + patch("app.tasks.send_to_all.log_task_progress"), + patch("app.tasks.send_to_all.settings") as ms, + patch("app.tasks.send_to_all.get_provider_status", side_effect=Exception("unavailable")), + patch("app.tasks.send_to_all._should_upload_to_dropbox", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_nextcloud", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_paperless", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_google_drive", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_webdav", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_ftp", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_sftp", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_email", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False), + patch("app.tasks.send_to_all.SessionLocal") as mock_session_cls, + ): + ms.workdir = str(tmp_path) + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + mock_session_cls.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_session_cls.return_value.__exit__ = MagicMock(return_value=False) + + result = send_to_all_destinations.apply( + args=[str(f)], kwargs={"use_validator": True, "file_id": 1} + ).get() + + assert result["status"] == "Queued" + assert result["tasks"] == {} + + @pytest.mark.unit + def test_get_configured_services_from_validator(self): + """Lines 85-105: get_configured_services_from_validator maps provider status.""" + with patch("app.tasks.send_to_all.get_provider_status") as mock_prov: + mock_prov.return_value = { + "Dropbox": {"configured": True}, + "NextCloud": {"configured": False}, + "S3 Storage": {"configured": True}, + } + from app.tasks.send_to_all import get_configured_services_from_validator + + result = get_configured_services_from_validator() + + assert result["dropbox"] is True + assert result["nextcloud"] is False + assert result["s3"] is True + + @pytest.mark.unit + def test_file_id_lookup_from_db(self, tmp_path): + """Lines 136-146: file_id looked up from database when not provided.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_db = MagicMock() + mock_record = MagicMock() + mock_record.id = 42 + mock_db.query.return_value.filter.return_value.first.return_value = mock_record + + mock_session_cls = MagicMock() + mock_session_cls.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_session_cls.return_value.__exit__ = MagicMock(return_value=False) + + with ExitStack() as stack: + stack.enter_context(patch("app.tasks.send_to_all.os.path.exists", return_value=True)) + stack.enter_context(patch("app.tasks.send_to_all.log_task_progress")) + ms = stack.enter_context(patch("app.tasks.send_to_all.settings")) + stack.enter_context(patch("app.tasks.send_to_all.SessionLocal", mock_session_cls)) + stack.enter_context(patch("app.tasks.send_to_all.get_provider_status", return_value={})) + for p in _all_should_upload_false(): + stack.enter_context(p) + ms.workdir = str(tmp_path) + + result = send_to_all_destinations.apply( + args=[str(f)], kwargs={"use_validator": True, "file_id": None} + ).get() + + assert result["status"] == "Queued" + + @pytest.mark.unit + def test_file_id_lookup_no_record(self, tmp_path): + """Lines 136-146: file_id stays None when no DB record found.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + mock_session_cls = MagicMock() + mock_session_cls.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_session_cls.return_value.__exit__ = MagicMock(return_value=False) + + with ExitStack() as stack: + stack.enter_context(patch("app.tasks.send_to_all.os.path.exists", return_value=True)) + stack.enter_context(patch("app.tasks.send_to_all.log_task_progress")) + ms = stack.enter_context(patch("app.tasks.send_to_all.settings")) + stack.enter_context(patch("app.tasks.send_to_all.SessionLocal", mock_session_cls)) + stack.enter_context(patch("app.tasks.send_to_all.get_provider_status", return_value={})) + for p in _all_should_upload_false(): + stack.enter_context(p) + ms.workdir = str(tmp_path) + + result = send_to_all_destinations.apply( + args=[str(f)], kwargs={"use_validator": True, "file_id": None} + ).get() + + assert result["status"] == "Queued" + + @pytest.mark.unit + def test_validator_exception_falls_back(self, tmp_path): + """Lines 210-212: validator exception causes fallback to should_upload.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + with ( + patch("app.tasks.send_to_all.os.path.exists", return_value=True), + patch("app.tasks.send_to_all.log_task_progress"), + patch("app.tasks.send_to_all.settings") as ms, + patch("app.tasks.send_to_all.get_provider_status", side_effect=RuntimeError("fail")), + patch("app.tasks.send_to_all._should_upload_to_dropbox", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_nextcloud", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_paperless", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_google_drive", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_webdav", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_ftp", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_sftp", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_email", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False), + patch("app.tasks.send_to_all.SessionLocal"), + ): + ms.workdir = str(tmp_path) + + result = send_to_all_destinations.apply( + args=[str(f)], kwargs={"use_validator": True, "file_id": 1} + ).get() + + assert result["status"] == "Queued" + + @pytest.mark.unit + def test_queue_error(self, tmp_path): + """Lines 245-248: exception when queueing a task is caught.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + mock_upload = MagicMock() + mock_upload.delay.side_effect = RuntimeError("broker down") + + with ExitStack() as stack: + stack.enter_context(patch("app.tasks.send_to_all.os.path.exists", return_value=True)) + stack.enter_context(patch("app.tasks.send_to_all.log_task_progress")) + ms = stack.enter_context(patch("app.tasks.send_to_all.settings")) + stack.enter_context( + patch( + "app.tasks.send_to_all.get_provider_status", + return_value={"Dropbox": {"configured": True}}, + ) + ) + stack.enter_context(patch("app.tasks.send_to_all.upload_to_dropbox", mock_upload)) + stack.enter_context(patch("app.tasks.send_to_all.SessionLocal")) + for p in _all_should_upload_false(): + stack.enter_context(p) + ms.workdir = str(tmp_path) + + result = send_to_all_destinations.apply( + args=[str(f)], kwargs={"use_validator": True, "file_id": 1} + ).get() + + assert "dropbox_error" in result["tasks"] + assert "broker down" in result["tasks"]["dropbox_error"] + + @pytest.mark.unit + def test_should_upload_check_exception(self, tmp_path): + """Lines 228-230: exception in should_upload sets is_configured=False.""" + f = tmp_path / "test.pdf" + f.write_text("data") + + with ( + patch("app.tasks.send_to_all.os.path.exists", return_value=True), + patch("app.tasks.send_to_all.log_task_progress"), + patch("app.tasks.send_to_all.settings") as ms, + patch("app.tasks.send_to_all.get_provider_status", side_effect=RuntimeError("fail")), + patch("app.tasks.send_to_all._should_upload_to_dropbox", side_effect=RuntimeError("bad")), + patch("app.tasks.send_to_all._should_upload_to_nextcloud", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_paperless", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_google_drive", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_webdav", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_ftp", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_sftp", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_email", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False), + patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False), + patch("app.tasks.send_to_all.SessionLocal"), + ): + ms.workdir = str(tmp_path) + + result = send_to_all_destinations.apply( + args=[str(f)], kwargs={"use_validator": True, "file_id": 1} + ).get() + + assert "dropbox_task_id" not in result["tasks"] + assert result["status"] == "Queued"