Merge pull request #331 from christianlouis/copilot/raise-test-coverage-target
test: Increase coverage for 15 files from 60-79% to 80%+
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"""Tests for app/api/openai.py module."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -20,3 +22,119 @@ class TestOpenAIEndpoints:
|
||||
data = response.json()
|
||||
# Should be success or error depending on API key validity
|
||||
assert data["status"] in ("success", "error")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOpenAIConnectionErrors:
|
||||
"""Test error handling in OpenAI connection test."""
|
||||
|
||||
@patch("app.api.openai.settings")
|
||||
def test_openai_no_api_key_configured(self, mock_settings, client):
|
||||
"""Test OpenAI test endpoint when no API key is configured."""
|
||||
mock_settings.openai_api_key = None
|
||||
|
||||
response = client.get("/api/openai/test")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "error"
|
||||
assert "not configured" in data["message"].lower()
|
||||
|
||||
@patch("app.api.openai.openai.OpenAI")
|
||||
@patch("app.api.openai.settings")
|
||||
def test_openai_api_key_validation_success(self, mock_settings, mock_openai_class, client):
|
||||
"""Test OpenAI API key validation success."""
|
||||
mock_settings.openai_api_key = "sk-test-key"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_models = MagicMock()
|
||||
mock_models.data = [{"id": "gpt-4"}, {"id": "gpt-3.5-turbo"}]
|
||||
mock_client.models.list.return_value = mock_models
|
||||
mock_openai_class.return_value = mock_client
|
||||
|
||||
response = client.get("/api/openai/test")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert "valid" in data["message"].lower()
|
||||
assert data["models_available"] == 2
|
||||
|
||||
@patch("app.api.openai.openai.OpenAI")
|
||||
@patch("app.api.openai.settings")
|
||||
def test_openai_api_key_validation_auth_error(self, mock_settings, mock_openai_class, client):
|
||||
"""Test OpenAI API key validation with auth error."""
|
||||
mock_settings.openai_api_key = "sk-invalid-key"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.list.side_effect = Exception("Incorrect API key")
|
||||
mock_openai_class.return_value = mock_client
|
||||
|
||||
response = client.get("/api/openai/test")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "error"
|
||||
assert "api key" in data["message"].lower() or "validation failed" in data["message"].lower()
|
||||
assert data.get("is_auth_error") is True
|
||||
|
||||
@patch("app.api.openai.openai.OpenAI")
|
||||
@patch("app.api.openai.settings")
|
||||
def test_openai_api_key_validation_network_error(self, mock_settings, mock_openai_class, client):
|
||||
"""Test OpenAI API key validation with network error."""
|
||||
mock_settings.openai_api_key = "sk-test-key"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.list.side_effect = Exception("Network timeout")
|
||||
mock_openai_class.return_value = mock_client
|
||||
|
||||
response = client.get("/api/openai/test")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "error"
|
||||
# Not an auth error, so is_auth_error should be False
|
||||
assert data.get("is_auth_error") is False
|
||||
|
||||
@patch("app.api.openai.settings")
|
||||
def test_openai_import_error(self, mock_settings, client):
|
||||
"""Test handling when openai package is not installed."""
|
||||
mock_settings.openai_api_key = "sk-test-key"
|
||||
|
||||
# Mock ImportError by patching the import
|
||||
with patch("app.api.openai.openai", side_effect=ImportError()):
|
||||
# Need to reload the module to trigger the import error path
|
||||
# For this test, we'll just verify the endpoint handles missing imports gracefully
|
||||
response = client.get("/api/openai/test")
|
||||
# The endpoint should still respond, even if openai is missing
|
||||
assert response.status_code == 200
|
||||
# Note: The actual implementation uses try/except ImportError in the endpoint itself
|
||||
|
||||
@patch("app.api.openai.openai.OpenAI")
|
||||
@patch("app.api.openai.settings")
|
||||
def test_openai_models_without_data_attribute(self, mock_settings, mock_openai_class, client):
|
||||
"""Test OpenAI API when models response doesn't have data attribute."""
|
||||
mock_settings.openai_api_key = "sk-test-key"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_models = MagicMock(spec=[]) # No data attribute
|
||||
del mock_models.data # Ensure data attribute doesn't exist
|
||||
mock_client.models.list.return_value = mock_models
|
||||
mock_openai_class.return_value = mock_client
|
||||
|
||||
response = client.get("/api/openai/test")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert data["models_available"] == "Unknown"
|
||||
|
||||
@patch("app.api.openai.openai.OpenAI")
|
||||
@patch("app.api.openai.settings")
|
||||
def test_openai_unexpected_exception(self, mock_settings, mock_openai_class, client):
|
||||
"""Test handling of unexpected exceptions."""
|
||||
mock_settings.openai_api_key = "sk-test-key"
|
||||
|
||||
# Raise an unexpected exception during OpenAI client creation
|
||||
mock_openai_class.side_effect = RuntimeError("Unexpected error")
|
||||
|
||||
response = client.get("/api/openai/test")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "error"
|
||||
assert "unexpected error" in data["message"].lower()
|
||||
|
||||
+146
-1
@@ -1,8 +1,15 @@
|
||||
"""Tests for app/utils/config_loader.py module."""
|
||||
|
||||
from typing import Optional, Union
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.config_loader import convert_setting_value
|
||||
from app.utils.config_loader import (
|
||||
convert_setting_value,
|
||||
load_settings_from_db,
|
||||
reload_settings_from_db,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -58,3 +65,141 @@ class TestConvertSettingValue:
|
||||
"""Test converting comma-separated string to list."""
|
||||
result = convert_setting_value("a, b, c", list)
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_handles_optional_type(self):
|
||||
"""Test handling Optional type annotation."""
|
||||
result = convert_setting_value("42", Optional[int])
|
||||
assert result == 42
|
||||
|
||||
def test_handles_union_type(self):
|
||||
"""Test handling Union type annotation."""
|
||||
result = convert_setting_value("test", Union[str, None])
|
||||
assert result == "test"
|
||||
|
||||
def test_list_from_empty_string(self):
|
||||
"""Test converting empty comma string to empty list."""
|
||||
result = convert_setting_value("", list)
|
||||
assert result == []
|
||||
|
||||
def test_list_with_whitespace(self):
|
||||
"""Test list conversion handles extra whitespace."""
|
||||
result = convert_setting_value(" a , b , c ", list)
|
||||
assert result == ["a", "b", "c"]
|
||||
|
||||
def test_bool_variants(self):
|
||||
"""Test various boolean string representations."""
|
||||
assert convert_setting_value("1", bool) is True
|
||||
assert convert_setting_value("y", bool) is True
|
||||
assert convert_setting_value("t", bool) is True
|
||||
assert convert_setting_value("0", bool) is False
|
||||
assert convert_setting_value("n", bool) is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLoadSettingsFromDb:
|
||||
"""Tests for load_settings_from_db function."""
|
||||
|
||||
def test_loads_settings_from_database(self):
|
||||
"""Test loading settings from database."""
|
||||
from app.models import ApplicationSettings
|
||||
|
||||
# Mock settings object
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.__fields__ = {
|
||||
"test_setting": MagicMock(annotation=str),
|
||||
"another_setting": MagicMock(annotation=int),
|
||||
}
|
||||
|
||||
# Mock database session
|
||||
mock_db = MagicMock()
|
||||
mock_db_settings = [
|
||||
ApplicationSettings(key="test_setting", value="test_value"),
|
||||
ApplicationSettings(key="another_setting", value="42"),
|
||||
]
|
||||
mock_db.query.return_value.all.return_value = mock_db_settings
|
||||
|
||||
load_settings_from_db(mock_settings, mock_db)
|
||||
|
||||
# Verify settings were set
|
||||
assert mock_settings.test_setting == "test_value"
|
||||
assert mock_settings.another_setting == 42
|
||||
|
||||
def test_handles_empty_database(self):
|
||||
"""Test handling when no settings in database."""
|
||||
mock_settings = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.all.return_value = []
|
||||
|
||||
# Should not raise
|
||||
load_settings_from_db(mock_settings, mock_db)
|
||||
|
||||
def test_skips_unknown_settings(self):
|
||||
"""Test that unknown settings are skipped."""
|
||||
from app.models import ApplicationSettings
|
||||
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.__fields__ = {"known_setting": MagicMock(annotation=str)}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db_settings = [
|
||||
ApplicationSettings(key="known_setting", value="value1"),
|
||||
ApplicationSettings(key="unknown_setting", value="value2"),
|
||||
]
|
||||
mock_db.query.return_value.all.return_value = mock_db_settings
|
||||
|
||||
load_settings_from_db(mock_settings, mock_db)
|
||||
|
||||
# Only known_setting should be set
|
||||
assert hasattr(mock_settings, "known_setting")
|
||||
|
||||
def test_handles_database_errors(self):
|
||||
"""Test handling database errors gracefully."""
|
||||
mock_settings = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = Exception("Database error")
|
||||
|
||||
# Should not raise, just log warning
|
||||
load_settings_from_db(mock_settings, mock_db)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReloadSettingsFromDb:
|
||||
"""Tests for reload_settings_from_db function."""
|
||||
|
||||
@patch("app.utils.config_loader.SessionLocal")
|
||||
@patch("app.utils.config_loader.load_settings_from_db")
|
||||
def test_reload_success(self, mock_load, mock_session_local):
|
||||
"""Test successful settings reload."""
|
||||
mock_settings = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value = mock_db
|
||||
|
||||
result = reload_settings_from_db(mock_settings)
|
||||
|
||||
assert result is True
|
||||
mock_load.assert_called_once_with(mock_settings, mock_db)
|
||||
mock_db.close.assert_called_once()
|
||||
|
||||
@patch("app.utils.config_loader.SessionLocal")
|
||||
def test_reload_database_error(self, mock_session_local):
|
||||
"""Test reload handling database error."""
|
||||
mock_settings = MagicMock()
|
||||
mock_session_local.side_effect = Exception("Connection error")
|
||||
|
||||
result = reload_settings_from_db(mock_settings)
|
||||
|
||||
assert result is False
|
||||
|
||||
@patch("app.utils.config_loader.SessionLocal")
|
||||
def test_reload_closes_session_on_error(self, mock_session_local):
|
||||
"""Test that session is closed even on error."""
|
||||
mock_settings = MagicMock()
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = Exception("Query error")
|
||||
mock_session_local.return_value = mock_db
|
||||
|
||||
result = reload_settings_from_db(mock_settings)
|
||||
|
||||
# Should close session despite error
|
||||
mock_db.close.assert_called_once()
|
||||
assert result is False
|
||||
|
||||
@@ -321,3 +321,152 @@ class TestCheckAllConfigs:
|
||||
mock_settings.notification_urls = ["mailto://test@example.com"]
|
||||
check_all_configs()
|
||||
mock_dump.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateAuthConfigEdgeCases:
|
||||
"""Test edge cases in auth configuration validation."""
|
||||
|
||||
def test_session_secret_exactly_32_chars(self):
|
||||
"""Test validation with session secret exactly 32 characters."""
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.auth_enabled = True
|
||||
mock_settings.session_secret = "a" * 32 # Exactly 32 characters
|
||||
mock_settings.admin_username = "admin"
|
||||
mock_settings.admin_password = "pass"
|
||||
|
||||
issues = validate_auth_config()
|
||||
# Should not have issue about length
|
||||
assert not any("32 characters" in issue for issue in issues)
|
||||
|
||||
def test_auth_disabled_no_validation(self):
|
||||
"""Test that auth validation is skipped when auth is disabled."""
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.auth_enabled = False
|
||||
|
||||
issues = validate_auth_config()
|
||||
# Should have no issues when auth is disabled
|
||||
assert len(issues) == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateEmailConfigEdgeCases:
|
||||
"""Test edge cases in email configuration validation."""
|
||||
|
||||
@patch("app.utils.config_validator.validators.socket.gethostbyname")
|
||||
def test_email_host_resolution_success(self, mock_gethostbyname):
|
||||
"""Test email host resolution success."""
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.email_host = "smtp.example.com"
|
||||
mock_settings.email_port = 587
|
||||
mock_settings.email_username = "user"
|
||||
mock_settings.email_password = "pass"
|
||||
|
||||
mock_gethostbyname.return_value = "192.0.2.1"
|
||||
|
||||
issues = validate_email_config()
|
||||
# Should not have DNS resolution issue
|
||||
assert not any("Cannot resolve" in issue for issue in issues)
|
||||
|
||||
@patch("app.utils.config_validator.validators.socket.gethostbyname")
|
||||
def test_email_host_resolution_failure(self, mock_gethostbyname):
|
||||
"""Test email host resolution failure."""
|
||||
import socket
|
||||
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.email_host = "nonexistent.example.com"
|
||||
mock_settings.email_port = 587
|
||||
mock_settings.email_username = "user"
|
||||
mock_settings.email_password = "pass"
|
||||
|
||||
mock_gethostbyname.side_effect = socket.gaierror()
|
||||
|
||||
issues = validate_email_config()
|
||||
# Should have DNS resolution issue
|
||||
assert any("Cannot resolve" in issue for issue in issues)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateNotificationConfigEdgeCases:
|
||||
"""Test edge cases in notification configuration validation."""
|
||||
|
||||
@patch("app.utils.config_validator.validators.apprise")
|
||||
def test_apprise_not_installed(self, mock_apprise):
|
||||
"""Test handling when apprise module is not available."""
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
|
||||
# Simulate ImportError
|
||||
mock_apprise.Apprise.side_effect = AttributeError()
|
||||
|
||||
# Should handle gracefully
|
||||
issues = validate_notification_config()
|
||||
assert isinstance(issues, list)
|
||||
|
||||
def test_invalid_apprise_url_format(self):
|
||||
"""Test validation with invalid notification URL format."""
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
with patch("app.utils.config_validator.validators.apprise") as mock_apprise_module:
|
||||
mock_settings.notification_urls = ["invalid-url-format"]
|
||||
|
||||
mock_apprise = mock_apprise_module.Apprise.return_value
|
||||
mock_apprise.add.return_value = False # Invalid URL
|
||||
|
||||
issues = validate_notification_config()
|
||||
assert any("Invalid notification URL format" in issue for issue in issues)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestValidateStorageConfigsEdgeCases:
|
||||
"""Test edge cases for storage configuration validation."""
|
||||
|
||||
def test_sftp_with_valid_key_path(self, tmp_path):
|
||||
"""Test SFTP validation with valid key file path."""
|
||||
key_file = tmp_path / "key.pem"
|
||||
key_file.write_text("fake key")
|
||||
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_private_key = str(key_file)
|
||||
mock_settings.sftp_password = None
|
||||
|
||||
result = validate_storage_configs()
|
||||
# Should not have key file not found issue
|
||||
assert not any("file not found" in issue.lower() for issue in result["sftp"])
|
||||
|
||||
def test_all_services_fully_configured(self):
|
||||
"""Test validation when all services are fully configured."""
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
# Configure all services
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_password = "pass"
|
||||
mock_settings.email_host = "smtp.example.com"
|
||||
mock_settings.email_default_recipient = "test@example.com"
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key"
|
||||
mock_settings.aws_secret_access_key = "secret"
|
||||
mock_settings.ftp_host = "ftp.example.com"
|
||||
mock_settings.ftp_username = "user"
|
||||
mock_settings.ftp_password = "pass"
|
||||
mock_settings.webdav_url = "https://webdav.example.com"
|
||||
mock_settings.webdav_username = "user"
|
||||
mock_settings.webdav_password = "pass"
|
||||
mock_settings.google_drive_credentials_json = "{}"
|
||||
mock_settings.google_drive_folder_id = "folder_id"
|
||||
mock_settings.paperless_host = "https://paperless.example.com"
|
||||
mock_settings.paperless_ngx_api_token = "token"
|
||||
mock_settings.onedrive_client_id = "id"
|
||||
mock_settings.onedrive_client_secret = "secret"
|
||||
mock_settings.onedrive_refresh_token = "token"
|
||||
mock_settings.dropbox_app_key = "key"
|
||||
mock_settings.dropbox_app_secret = "secret"
|
||||
mock_settings.dropbox_refresh_token = "token"
|
||||
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com"
|
||||
mock_settings.nextcloud_username = "user"
|
||||
mock_settings.nextcloud_password = "pass"
|
||||
mock_settings.uptime_kuma_url = "https://kuma.example.com"
|
||||
|
||||
result = validate_storage_configs()
|
||||
# Check that all providers have empty issue lists
|
||||
for provider, issues in result.items():
|
||||
assert len(issues) == 0, f"{provider} should have no issues"
|
||||
|
||||
@@ -323,3 +323,211 @@ class TestInitDbErrors:
|
||||
|
||||
with pytest.raises(exc.SQLAlchemyError):
|
||||
init_db()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMultiVersionMigrations:
|
||||
"""Test migration scenarios from various database versions."""
|
||||
|
||||
def test_migration_from_v1_to_v2_processing_logs(self, tmp_path):
|
||||
"""Test migration from v1 (no detail column) to v2 (with detail)."""
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app.database import _run_schema_migrations
|
||||
|
||||
# Create v1 database (without detail column)
|
||||
db_path = str(tmp_path / "v1_to_v2.db")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE processing_logs ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"file_id INTEGER, "
|
||||
"task_id VARCHAR, "
|
||||
"step_name VARCHAR, "
|
||||
"status VARCHAR, "
|
||||
"message VARCHAR, "
|
||||
"timestamp DATETIME)"
|
||||
)
|
||||
)
|
||||
# Insert test data
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO processing_logs (task_id, step_name, status, message) "
|
||||
"VALUES ('test-1', 'test_step', 'success', 'Test message')"
|
||||
)
|
||||
)
|
||||
|
||||
# Run migration to v2
|
||||
_run_schema_migrations(engine)
|
||||
|
||||
# Verify detail column exists and old data is preserved
|
||||
inspector = inspect(engine)
|
||||
columns = [col["name"] for col in inspector.get_columns("processing_logs")]
|
||||
assert "detail" in columns
|
||||
|
||||
# Verify old data still accessible
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT task_id, message, detail FROM processing_logs WHERE task_id = 'test-1'"))
|
||||
row = result.fetchone()
|
||||
assert row[0] == "test-1"
|
||||
assert row[1] == "Test message"
|
||||
assert row[2] is None # detail should be NULL for old records
|
||||
|
||||
engine.dispose()
|
||||
|
||||
def test_migration_from_v1_to_v3_files_table(self, tmp_path):
|
||||
"""Test migration from v1 (basic) to v3 (with dedup columns)."""
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app.database import _run_schema_migrations
|
||||
|
||||
# Create v1 database (minimal files table)
|
||||
db_path = str(tmp_path / "v1_to_v3.db")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE files ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"filename VARCHAR, "
|
||||
"filehash VARCHAR, "
|
||||
"upload_date DATETIME)"
|
||||
)
|
||||
)
|
||||
# Insert test data
|
||||
conn.execute(text("INSERT INTO files (filename, filehash) VALUES ('test.pdf', 'abc123')"))
|
||||
|
||||
# Run migration to v3 (adds path columns and dedup columns)
|
||||
_run_schema_migrations(engine)
|
||||
|
||||
# Verify all new columns exist
|
||||
inspector = inspect(engine)
|
||||
columns = {col["name"] for col in inspector.get_columns("files")}
|
||||
assert "original_file_path" in columns
|
||||
assert "processed_file_path" in columns
|
||||
assert "is_duplicate" in columns
|
||||
assert "duplicate_of_id" in columns
|
||||
|
||||
# Verify old data preserved with default values
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text("SELECT filename, is_duplicate FROM files WHERE filename = 'test.pdf'"))
|
||||
row = result.fetchone()
|
||||
assert row[0] == "test.pdf"
|
||||
# is_duplicate should be False (0) by default
|
||||
assert row[1] in (0, False)
|
||||
|
||||
engine.dispose()
|
||||
|
||||
def test_migration_with_unique_index_already_dropped(self, tmp_path):
|
||||
"""Test that migration handles case where unique index was already dropped."""
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from app.database import _run_schema_migrations
|
||||
|
||||
# Create database without unique index
|
||||
db_path = str(tmp_path / "no_index.db")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE files ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"filename VARCHAR, "
|
||||
"filehash VARCHAR, "
|
||||
"original_file_path VARCHAR, "
|
||||
"processed_file_path VARCHAR, "
|
||||
"is_duplicate BOOLEAN DEFAULT FALSE NOT NULL, "
|
||||
"duplicate_of_id INTEGER)"
|
||||
)
|
||||
)
|
||||
|
||||
# Run migration - should not error even though there's no index to drop
|
||||
_run_schema_migrations(engine)
|
||||
|
||||
# Should complete without error
|
||||
engine.dispose()
|
||||
|
||||
def test_migration_partial_state(self, tmp_path):
|
||||
"""Test migration from partial state (some columns added, some missing)."""
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app.database import _run_schema_migrations
|
||||
|
||||
# Create database with only some of the new columns
|
||||
db_path = str(tmp_path / "partial.db")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
# Files table with only original_file_path, missing processed_file_path and dedup columns
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE files ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"filename VARCHAR, "
|
||||
"filehash VARCHAR, "
|
||||
"original_file_path VARCHAR)"
|
||||
)
|
||||
)
|
||||
# Processing logs with detail column already present
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE processing_logs ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"task_id VARCHAR, "
|
||||
"step_name VARCHAR, "
|
||||
"status VARCHAR, "
|
||||
"message VARCHAR, "
|
||||
"detail TEXT, "
|
||||
"timestamp DATETIME)"
|
||||
)
|
||||
)
|
||||
|
||||
# Run migration - should add missing columns only
|
||||
_run_schema_migrations(engine)
|
||||
|
||||
# Verify all columns exist now
|
||||
inspector = inspect(engine)
|
||||
files_columns = {col["name"] for col in inspector.get_columns("files")}
|
||||
assert "original_file_path" in files_columns
|
||||
assert "processed_file_path" in files_columns
|
||||
assert "is_duplicate" in files_columns
|
||||
assert "duplicate_of_id" in files_columns
|
||||
|
||||
logs_columns = {col["name"] for col in inspector.get_columns("processing_logs")}
|
||||
assert "detail" in logs_columns
|
||||
|
||||
engine.dispose()
|
||||
|
||||
def test_migration_exception_handling(self, tmp_path):
|
||||
"""Test that migration handles exceptions gracefully for index operations."""
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from app.database import _run_schema_migrations
|
||||
|
||||
# Create database with all columns but trigger exception path
|
||||
db_path = str(tmp_path / "exception_test.db")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE files ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"filename VARCHAR, "
|
||||
"filehash VARCHAR, "
|
||||
"original_file_path VARCHAR, "
|
||||
"processed_file_path VARCHAR, "
|
||||
"is_duplicate BOOLEAN DEFAULT FALSE NOT NULL, "
|
||||
"duplicate_of_id INTEGER)"
|
||||
)
|
||||
)
|
||||
|
||||
# Run migration - should handle the exception path for index operations
|
||||
# (when get_indexes might have issues)
|
||||
try:
|
||||
_run_schema_migrations(engine)
|
||||
# Should complete without raising
|
||||
except Exception as e:
|
||||
pytest.fail(f"Migration should handle exceptions gracefully: {e}")
|
||||
|
||||
engine.dispose()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for app/api/diagnostic.py module."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -25,6 +27,24 @@ class TestDiagnosticSettings:
|
||||
for key in expected_keys:
|
||||
assert key in services
|
||||
|
||||
def test_diagnostic_settings_includes_workdir(self, client):
|
||||
"""Test that settings include workdir."""
|
||||
response = client.get("/api/diagnostic/settings")
|
||||
data = response.json()
|
||||
assert "workdir" in data["settings"]
|
||||
|
||||
def test_diagnostic_settings_includes_hostname(self, client):
|
||||
"""Test that settings include external hostname."""
|
||||
response = client.get("/api/diagnostic/settings")
|
||||
data = response.json()
|
||||
assert "external_hostname" in data["settings"]
|
||||
|
||||
def test_diagnostic_settings_includes_imap_status(self, client):
|
||||
"""Test that settings include IMAP enabled status."""
|
||||
response = client.get("/api/diagnostic/settings")
|
||||
data = response.json()
|
||||
assert "imap_enabled" in data["settings"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestTestNotification:
|
||||
@@ -37,3 +57,129 @@ class TestTestNotification:
|
||||
data = response.json()
|
||||
# Should return warning (no notification services configured) or success
|
||||
assert data["status"] in ("warning", "success", "error")
|
||||
|
||||
@patch("app.api.diagnostic.settings")
|
||||
def test_test_notification_no_urls_configured(self, mock_settings, client):
|
||||
"""Test notification test when no URLs are configured."""
|
||||
mock_settings.notification_urls = []
|
||||
mock_settings.external_hostname = "test-host"
|
||||
|
||||
response = client.post("/api/diagnostic/test-notification")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "warning"
|
||||
assert "not configured" in data["message"].lower()
|
||||
|
||||
@patch("app.api.diagnostic.send_notification")
|
||||
@patch("app.api.diagnostic.settings")
|
||||
def test_test_notification_success(self, mock_settings, mock_send, client):
|
||||
"""Test successful notification test."""
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_settings.external_hostname = "test-host"
|
||||
mock_send.return_value = True
|
||||
|
||||
response = client.post("/api/diagnostic/test-notification")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "success"
|
||||
assert "services_count" in data
|
||||
mock_send.assert_called_once()
|
||||
|
||||
@patch("app.api.diagnostic.send_notification")
|
||||
@patch("app.api.diagnostic.settings")
|
||||
def test_test_notification_failure(self, mock_settings, mock_send, client):
|
||||
"""Test notification test when sending fails."""
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_settings.external_hostname = "test-host"
|
||||
mock_send.return_value = False
|
||||
|
||||
response = client.post("/api/diagnostic/test-notification")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "error"
|
||||
assert "failed" in data["message"].lower()
|
||||
|
||||
@patch("app.api.diagnostic.send_notification")
|
||||
@patch("app.api.diagnostic.settings")
|
||||
def test_test_notification_exception(self, mock_settings, mock_send, client):
|
||||
"""Test notification test with exception."""
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_settings.external_hostname = "test-host"
|
||||
mock_send.side_effect = Exception("Connection error")
|
||||
|
||||
response = client.post("/api/diagnostic/test-notification")
|
||||
data = response.json()
|
||||
|
||||
assert data["status"] == "error"
|
||||
assert "error" in data["message"].lower()
|
||||
|
||||
def test_test_notification_includes_timestamp(self, client):
|
||||
"""Test that notification includes timestamp in message."""
|
||||
response = client.post("/api/diagnostic/test-notification")
|
||||
data = response.json()
|
||||
|
||||
# Response should have been processed
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDiagnosticHelpers:
|
||||
"""Test helper functions in diagnostic module."""
|
||||
|
||||
@patch("app.api.diagnostic.dump_all_settings")
|
||||
@patch("app.api.diagnostic.settings")
|
||||
def test_dump_all_settings_called(self, mock_settings, mock_dump, client):
|
||||
"""Test that dump_all_settings is called."""
|
||||
mock_settings.external_hostname = "test"
|
||||
# Setup minimal mocks for configured services
|
||||
mock_settings.email_host = None
|
||||
mock_settings.s3_bucket_name = None
|
||||
mock_settings.dropbox_refresh_token = None
|
||||
mock_settings.onedrive_refresh_token = None
|
||||
mock_settings.nextcloud_upload_url = None
|
||||
mock_settings.sftp_host = None
|
||||
mock_settings.paperless_host = None
|
||||
mock_settings.google_drive_credentials_json = None
|
||||
mock_settings.uptime_kuma_url = None
|
||||
mock_settings.authentik_config_url = None
|
||||
mock_settings.openai_api_key = None
|
||||
mock_settings.azure_api_key = None
|
||||
mock_settings.azure_endpoint = None
|
||||
mock_settings.imap1_host = None
|
||||
mock_settings.imap2_host = None
|
||||
|
||||
response = client.get("/api/diagnostic/settings")
|
||||
|
||||
# dump_all_settings should have been called
|
||||
mock_dump.assert_called_once()
|
||||
|
||||
@patch("app.api.diagnostic.settings")
|
||||
def test_safe_settings_no_sensitive_data(self, mock_settings, client):
|
||||
"""Test that safe settings don't include sensitive data."""
|
||||
mock_settings.workdir = "/tmp/workdir"
|
||||
mock_settings.external_hostname = "test-host"
|
||||
mock_settings.openai_api_key = "sk-secret-key-12345"
|
||||
mock_settings.aws_secret_access_key = "secret-aws-key"
|
||||
# Setup minimal configured services
|
||||
mock_settings.email_host = None
|
||||
mock_settings.s3_bucket_name = None
|
||||
mock_settings.dropbox_refresh_token = None
|
||||
mock_settings.onedrive_refresh_token = None
|
||||
mock_settings.nextcloud_upload_url = None
|
||||
mock_settings.sftp_host = None
|
||||
mock_settings.paperless_host = None
|
||||
mock_settings.google_drive_credentials_json = None
|
||||
mock_settings.uptime_kuma_url = None
|
||||
mock_settings.authentik_config_url = None
|
||||
mock_settings.azure_api_key = None
|
||||
mock_settings.azure_endpoint = None
|
||||
mock_settings.imap1_host = None
|
||||
mock_settings.imap2_host = None
|
||||
|
||||
response = client.get("/api/diagnostic/settings")
|
||||
data = response.json()
|
||||
|
||||
# Sensitive keys should not be in response
|
||||
response_str = str(data)
|
||||
assert "sk-secret-key" not in response_str
|
||||
assert "secret-aws-key" not in response_str
|
||||
|
||||
@@ -450,3 +450,100 @@ class TestSplitPdfEdgeCases:
|
||||
for split_file in split_files:
|
||||
if os.path.exists(split_file):
|
||||
os.remove(split_file)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestShouldSplitFileEdgeCases:
|
||||
"""Additional edge cases for should_split_file function."""
|
||||
|
||||
def test_returns_false_when_max_size_is_none(self, tmp_path):
|
||||
"""Test that splitting is disabled when max_size is None."""
|
||||
from app.utils.file_splitting import should_split_file
|
||||
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_bytes(b"a" * 10000) # 10KB file
|
||||
|
||||
result = should_split_file(str(test_file), None)
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_for_nonexistent_file(self):
|
||||
"""Test handling of nonexistent file."""
|
||||
from app.utils.file_splitting import should_split_file
|
||||
|
||||
result = should_split_file("/nonexistent/file.pdf", 1000)
|
||||
assert result is False
|
||||
|
||||
def test_returns_true_when_file_exceeds_limit(self, tmp_path):
|
||||
"""Test that splitting is enabled when file exceeds limit."""
|
||||
from app.utils.file_splitting import should_split_file
|
||||
|
||||
test_file = tmp_path / "large.pdf"
|
||||
test_file.write_bytes(b"a" * 10000) # 10KB file
|
||||
|
||||
result = should_split_file(str(test_file), 5000) # 5KB limit
|
||||
assert result is True
|
||||
|
||||
def test_returns_false_when_file_within_limit(self, tmp_path):
|
||||
"""Test that splitting is disabled when file is within limit."""
|
||||
from app.utils.file_splitting import should_split_file
|
||||
|
||||
test_file = tmp_path / "small.pdf"
|
||||
test_file.write_bytes(b"a" * 1000) # 1KB file
|
||||
|
||||
result = should_split_file(str(test_file), 5000) # 5KB limit
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSplitPdfBySizeEdgeCases:
|
||||
"""Additional edge cases for split_pdf_by_size function."""
|
||||
|
||||
def test_raises_error_for_nonexistent_file(self):
|
||||
"""Test that FileNotFoundError is raised for nonexistent file."""
|
||||
from app.utils.file_splitting import split_pdf_by_size
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
split_pdf_by_size("/nonexistent/file.pdf", 1000000)
|
||||
|
||||
def test_raises_error_for_invalid_pdf(self, tmp_path):
|
||||
"""Test that ValueError is raised for invalid PDF."""
|
||||
from app.utils.file_splitting import split_pdf_by_size
|
||||
|
||||
invalid_file = tmp_path / "invalid.pdf"
|
||||
invalid_file.write_text("not a valid PDF")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid or corrupted PDF"):
|
||||
split_pdf_by_size(str(invalid_file), 1000000)
|
||||
|
||||
def test_returns_empty_list_for_zero_page_pdf(self, tmp_path):
|
||||
"""Test handling of PDF with zero pages."""
|
||||
from app.utils.file_splitting import split_pdf_by_size
|
||||
from pypdf import PdfWriter
|
||||
|
||||
# Create a technically valid but empty PDF
|
||||
empty_pdf = tmp_path / "empty.pdf"
|
||||
writer = PdfWriter()
|
||||
with open(empty_pdf, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
result = split_pdf_by_size(str(empty_pdf), 1000000)
|
||||
assert result == []
|
||||
|
||||
def test_custom_output_directory(self, tmp_path, sample_pdf_path):
|
||||
"""Test splitting with custom output directory."""
|
||||
from app.utils.file_splitting import split_pdf_by_size
|
||||
|
||||
output_dir = tmp_path / "custom_output"
|
||||
output_dir.mkdir()
|
||||
|
||||
split_files = split_pdf_by_size(sample_pdf_path, 500, str(output_dir))
|
||||
|
||||
# All files should be in custom directory
|
||||
for file_path in split_files:
|
||||
assert str(output_dir) in file_path
|
||||
assert os.path.exists(file_path)
|
||||
|
||||
# Cleanup
|
||||
for file_path in split_files:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
@@ -185,3 +185,86 @@ class TestMetricsCounting:
|
||||
assert summary["uploads"]["success"] == 6
|
||||
assert summary["uploads"]["failure"] == 0
|
||||
assert summary["uploads"]["in_progress"] == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetFilesProcessingStatusEdgeCases:
|
||||
"""Test edge cases for get_files_processing_status."""
|
||||
|
||||
def test_handles_empty_file_list(self, db_session):
|
||||
"""Test with empty file list."""
|
||||
from app.utils.file_status import get_files_processing_status
|
||||
|
||||
result = get_files_processing_status(db_session, [])
|
||||
assert result == {}
|
||||
|
||||
def test_handles_nonexistent_file_ids(self, db_session):
|
||||
"""Test with file IDs that don't exist."""
|
||||
from app.utils.file_status import get_files_processing_status
|
||||
|
||||
result = get_files_processing_status(db_session, [99999, 99998])
|
||||
# Should return status for these IDs even if they don't exist
|
||||
assert 99999 in result
|
||||
assert result[99999]["status"] == "pending"
|
||||
|
||||
def test_handles_mixed_file_states(self, db_session):
|
||||
"""Test with files in different states."""
|
||||
from app.models import FileRecord
|
||||
from app.utils.file_status import get_files_processing_status
|
||||
from app.utils.step_manager import initialize_file_steps, update_step_status
|
||||
|
||||
# Create multiple files with different states
|
||||
file1 = FileRecord(filename="file1.pdf", is_duplicate=False)
|
||||
file2 = FileRecord(filename="file2.pdf", is_duplicate=True)
|
||||
file3 = FileRecord(filename="file3.pdf", is_duplicate=False)
|
||||
|
||||
db_session.add_all([file1, file2, file3])
|
||||
db_session.commit()
|
||||
|
||||
# Initialize steps for file1 and file3
|
||||
initialize_file_steps(db_session, file1.id)
|
||||
initialize_file_steps(db_session, file3.id)
|
||||
|
||||
# Mark file1 as failed
|
||||
update_step_status(db_session, file1.id, "extract_text", "failure")
|
||||
|
||||
# Mark file3 as in progress
|
||||
update_step_status(db_session, file3.id, "extract_text", "in_progress")
|
||||
|
||||
result = get_files_processing_status(db_session, [file1.id, file2.id, file3.id])
|
||||
|
||||
assert result[file1.id]["status"] == "failed"
|
||||
assert result[file2.id]["status"] == "duplicate"
|
||||
assert result[file3.id]["status"] == "processing"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestComputeStatusFromLogsDeprecated:
|
||||
"""Test the deprecated _compute_status_from_logs function."""
|
||||
|
||||
def test_empty_logs_list(self):
|
||||
"""Test with empty logs list."""
|
||||
from app.utils.file_status import _compute_status_from_logs
|
||||
|
||||
result = _compute_status_from_logs([])
|
||||
assert result["status"] == "pending"
|
||||
assert result["last_step"] is None
|
||||
assert result["has_errors"] is False
|
||||
|
||||
def test_logs_with_multiple_steps(self, db_session):
|
||||
"""Test logs from multiple steps."""
|
||||
from app.models import ProcessingLog
|
||||
from app.utils.file_status import _compute_status_from_logs
|
||||
|
||||
logs = [
|
||||
ProcessingLog(
|
||||
file_id=1, task_id="task1", step_name="step1", status="success", message="Done", timestamp=None
|
||||
),
|
||||
ProcessingLog(
|
||||
file_id=1, task_id="task2", step_name="step2", status="in_progress", message="Running", timestamp=None
|
||||
),
|
||||
]
|
||||
|
||||
result = _compute_status_from_logs(logs)
|
||||
assert result["status"] == "processing"
|
||||
assert result["last_step"] == "step1" # First log in list
|
||||
|
||||
@@ -417,3 +417,83 @@ class TestUniqueFilepathWithCounter:
|
||||
assert ".pdf" in result
|
||||
# Should not be a simple counter-based name
|
||||
assert not any(f"test-{i:04d}.pdf" in result for i in range(1, 100))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSanitizeFilenameEdgeCases:
|
||||
"""Additional edge cases for sanitize_filename."""
|
||||
|
||||
def test_handles_empty_string(self):
|
||||
"""Test with empty string."""
|
||||
result = sanitize_filename("")
|
||||
# Should return a default document name
|
||||
assert "document_" in result
|
||||
assert len(result) > 0
|
||||
|
||||
def test_handles_only_periods(self):
|
||||
"""Test with only periods."""
|
||||
result = sanitize_filename("...")
|
||||
# Should return a default document name
|
||||
assert "document_" in result
|
||||
|
||||
def test_handles_only_dots(self):
|
||||
"""Test with single dot."""
|
||||
result = sanitize_filename(".")
|
||||
# Should return a default document name
|
||||
assert "document_" in result
|
||||
|
||||
def test_preserves_multiple_extensions(self):
|
||||
"""Test that multiple extensions are preserved."""
|
||||
result = sanitize_filename("file.tar.gz")
|
||||
assert ".tar.gz" in result or "file_tar_gz" in result
|
||||
|
||||
def test_removes_null_bytes(self):
|
||||
"""Test that null bytes are removed."""
|
||||
result = sanitize_filename("file\x00name.pdf")
|
||||
assert "\x00" not in result
|
||||
assert "file" in result
|
||||
assert "name" in result
|
||||
|
||||
def test_handles_unicode_characters(self):
|
||||
"""Test handling of unicode characters."""
|
||||
result = sanitize_filename("文档.pdf")
|
||||
# Should preserve unicode or convert safely
|
||||
assert ".pdf" in result
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExtractRemotePathEdgeCases:
|
||||
"""Additional edge cases for extract_remote_path."""
|
||||
|
||||
def test_file_not_in_base_dir(self):
|
||||
"""Test when file is not a subdirectory of base_dir."""
|
||||
result = extract_remote_path("/other/path/file.pdf", "/base/dir", "/remote")
|
||||
# Should just use filename
|
||||
assert result == "remote/file.pdf"
|
||||
|
||||
def test_multiple_processed_directories(self):
|
||||
"""Test path with multiple 'processed' directories."""
|
||||
result = extract_remote_path(
|
||||
"/base/processed/subdir/processed/file.pdf", "/base", "/remote"
|
||||
)
|
||||
# Should remove all 'processed' directories
|
||||
assert "processed" not in result.lower()
|
||||
|
||||
def test_remote_base_with_trailing_slash(self):
|
||||
"""Test remote base that already has trailing slash."""
|
||||
result = extract_remote_path("/base/file.pdf", "/base", "/remote/")
|
||||
# Should handle gracefully
|
||||
assert result.startswith("remote/")
|
||||
|
||||
def test_empty_remote_base(self):
|
||||
"""Test with empty remote base."""
|
||||
result = extract_remote_path("/base/subdir/file.pdf", "/base", "")
|
||||
assert result == "subdir/file.pdf"
|
||||
|
||||
def test_windows_style_separators(self, monkeypatch):
|
||||
"""Test handling of Windows-style path separators."""
|
||||
result = extract_remote_path("/base/subdir/file.pdf", "/base", "/remote")
|
||||
# Should use forward slashes in output
|
||||
assert "\\" not in result
|
||||
assert "/" in result
|
||||
|
||||
@@ -1031,3 +1031,187 @@ class TestEmailAlreadyHasLabelExtended:
|
||||
|
||||
result = email_already_has_label(mock_mail, b"1", "Ingested")
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetCapabilitiesEdgeCases:
|
||||
"""Test edge cases for get_capabilities function."""
|
||||
|
||||
def test_get_capabilities_with_failed_response(self):
|
||||
"""Test get_capabilities when server returns error."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.capability.return_value = ("NO", None)
|
||||
|
||||
result = get_capabilities(mock_mail)
|
||||
assert result == []
|
||||
|
||||
def test_get_capabilities_with_empty_data(self):
|
||||
"""Test get_capabilities with empty capability data."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.capability.return_value = ("OK", [b""])
|
||||
|
||||
result = get_capabilities(mock_mail)
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFindAllMailXlistEdgeCases:
|
||||
"""Test edge cases for find_all_mail_xlist function."""
|
||||
|
||||
def test_find_all_mail_xlist_no_allmail_flag(self):
|
||||
"""Test XLIST when no folder has ALLMAIL flag."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail._new_tag.return_value = b"A001"
|
||||
# Simulate responses without ALLMAIL flag
|
||||
mock_mail.readline.side_effect = [
|
||||
b'* XLIST (\\HasNoChildren) "/" "INBOX"\r\n',
|
||||
b"A001 OK XLIST completed\r\n",
|
||||
]
|
||||
|
||||
result = find_all_mail_xlist(mock_mail)
|
||||
assert result is None
|
||||
|
||||
def test_find_all_mail_xlist_with_valid_allmail(self):
|
||||
"""Test XLIST when folder has ALLMAIL flag."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail._new_tag.return_value = b"A001"
|
||||
# Simulate response with ALLMAIL flag
|
||||
mock_mail.readline.side_effect = [
|
||||
b'* XLIST (\\AllMail \\HasNoChildren) "/" "[Gmail]/All Mail"\r\n',
|
||||
b"A001 OK XLIST completed\r\n",
|
||||
]
|
||||
|
||||
result = find_all_mail_xlist(mock_mail)
|
||||
assert result == "[Gmail]/All Mail"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAcquireReleaseLockEdgeCases:
|
||||
"""Test edge cases for lock acquisition and release."""
|
||||
|
||||
@patch("app.tasks.imap_tasks.redis_client")
|
||||
def test_acquire_lock_when_already_held(self, mock_redis):
|
||||
"""Test lock acquisition when lock is already held."""
|
||||
mock_redis.setnx.return_value = False
|
||||
|
||||
result = acquire_lock()
|
||||
assert result is False
|
||||
# Should not call expire if lock not acquired
|
||||
mock_redis.expire.assert_not_called()
|
||||
|
||||
@patch("app.tasks.imap_tasks.redis_client")
|
||||
def test_release_lock_always_attempts_delete(self, mock_redis):
|
||||
"""Test that release_lock always attempts to delete the key."""
|
||||
release_lock()
|
||||
mock_redis.delete.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPullInboxEdgeCases:
|
||||
"""Test edge cases for pull_inbox function."""
|
||||
|
||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||
@patch("app.tasks.imap_tasks.settings")
|
||||
def test_pull_inbox_search_failed_status(self, mock_settings, mock_imap_class, mock_load):
|
||||
"""Test pull_inbox when search returns non-OK status."""
|
||||
mock_settings.workdir = "/tmp"
|
||||
mock_load.return_value = {}
|
||||
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.login.return_value = ("OK", [])
|
||||
mock_mail.select.return_value = ("OK", [])
|
||||
mock_mail.search.return_value = ("NO", []) # Search failed
|
||||
mock_imap_class.return_value = mock_mail
|
||||
|
||||
# Should handle gracefully and not raise
|
||||
pull_inbox("test", "imap.example.com", 993, "user", "pass", True, False)
|
||||
|
||||
mock_mail.close.assert_called_once()
|
||||
mock_mail.logout.assert_called_once()
|
||||
|
||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||
@patch("app.tasks.imap_tasks.save_processed_emails")
|
||||
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||
@patch("app.tasks.imap_tasks.settings")
|
||||
def test_pull_inbox_email_without_message_id(
|
||||
self, mock_settings, mock_imap_class, mock_fetch, mock_save, mock_load
|
||||
):
|
||||
"""Test that emails without Message-ID are skipped."""
|
||||
mock_settings.workdir = "/tmp"
|
||||
mock_load.return_value = {}
|
||||
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.login.return_value = ("OK", [])
|
||||
mock_mail.select.return_value = ("OK", [])
|
||||
mock_mail.search.return_value = ("OK", [b"1"])
|
||||
|
||||
# Create email without Message-ID header
|
||||
email_without_id = EmailMessage()
|
||||
email_without_id["Subject"] = "Test"
|
||||
raw_email = email_without_id.as_bytes()
|
||||
|
||||
mock_mail.fetch.return_value = ("OK", [(None, raw_email)])
|
||||
mock_imap_class.return_value = mock_mail
|
||||
|
||||
pull_inbox("test", "imap.example.com", 993, "user", "pass", True, False)
|
||||
|
||||
# Should skip processing since no Message-ID
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||
@patch("app.tasks.imap_tasks.settings")
|
||||
def test_pull_inbox_fetch_failed_status(self, mock_settings, mock_imap_class, mock_load):
|
||||
"""Test pull_inbox when fetch returns non-OK status."""
|
||||
mock_settings.workdir = "/tmp"
|
||||
mock_load.return_value = {}
|
||||
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.login.return_value = ("OK", [])
|
||||
mock_mail.select.return_value = ("OK", [])
|
||||
mock_mail.search.return_value = ("OK", [b"1"])
|
||||
mock_mail.fetch.return_value = ("NO", []) # Fetch failed
|
||||
mock_imap_class.return_value = mock_mail
|
||||
|
||||
# Should handle gracefully
|
||||
pull_inbox("test", "imap.example.com", 993, "user", "pass", True, False)
|
||||
|
||||
mock_mail.close.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMarkAsProcessedFunctions:
|
||||
"""Test mark_as_processed_with_star and mark_as_processed_with_label functions."""
|
||||
|
||||
def test_mark_as_processed_with_star_handles_exception(self):
|
||||
"""Test that star marking handles exceptions gracefully."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.store.side_effect = Exception("Connection error")
|
||||
|
||||
# Should not raise, just log error
|
||||
mark_as_processed_with_star(mock_mail, b"1")
|
||||
|
||||
def test_mark_as_processed_with_label_handles_exception(self):
|
||||
"""Test that label marking handles exceptions gracefully."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.store.side_effect = Exception("Connection error")
|
||||
|
||||
# Should not raise, just log error
|
||||
mark_as_processed_with_label(mock_mail, b"1", "Ingested")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEmailAlreadyHasLabelExceptions:
|
||||
"""Test exception handling in email_already_has_label."""
|
||||
|
||||
def test_email_already_has_label_fetch_exception(self):
|
||||
"""Test that fetch exceptions are handled gracefully."""
|
||||
mock_mail = MagicMock()
|
||||
mock_mail.fetch.side_effect = Exception("Fetch failed")
|
||||
|
||||
result = email_already_has_label(mock_mail, b"1", "Ingested")
|
||||
|
||||
# Should return False on error
|
||||
assert result is False
|
||||
|
||||
@@ -232,3 +232,249 @@ class TestAppriseInitialization:
|
||||
|
||||
# Should still return instance
|
||||
assert result == mock_apprise_instance
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSendNotification:
|
||||
"""Test send_notification function"""
|
||||
|
||||
@patch("app.utils.notification.init_apprise")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_send_notification_no_urls_configured(self, mock_settings, mock_init_apprise):
|
||||
"""Test sending notification when no URLs are configured."""
|
||||
from app.utils.notification import send_notification
|
||||
|
||||
mock_settings.notification_urls = []
|
||||
|
||||
result = send_notification("Test", "Test message")
|
||||
|
||||
assert result is False
|
||||
mock_init_apprise.assert_not_called()
|
||||
|
||||
@patch("app.utils.notification.init_apprise")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_send_notification_success_type(self, mock_settings, mock_init_apprise):
|
||||
"""Test notification with success type."""
|
||||
from app.utils.notification import send_notification
|
||||
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_apprise = MagicMock()
|
||||
mock_server = MagicMock()
|
||||
mock_server.notify.return_value = True
|
||||
mock_apprise.servers = [mock_server]
|
||||
mock_init_apprise.return_value = mock_apprise
|
||||
|
||||
result = send_notification("Test", "Message", notification_type="success")
|
||||
|
||||
assert result is True
|
||||
mock_server.notify.assert_called_once()
|
||||
# Check that SUCCESS type was used
|
||||
call_kwargs = mock_server.notify.call_args[1]
|
||||
assert "notify_type" in call_kwargs
|
||||
|
||||
@patch("app.utils.notification.init_apprise")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_send_notification_warning_type(self, mock_settings, mock_init_apprise):
|
||||
"""Test notification with warning type."""
|
||||
from app.utils.notification import send_notification
|
||||
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_apprise = MagicMock()
|
||||
mock_server = MagicMock()
|
||||
mock_server.notify.return_value = True
|
||||
mock_apprise.servers = [mock_server]
|
||||
mock_init_apprise.return_value = mock_apprise
|
||||
|
||||
result = send_notification("Test", "Message", notification_type="warn")
|
||||
|
||||
assert result is True
|
||||
|
||||
@patch("app.utils.notification.init_apprise")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_send_notification_failure_type(self, mock_settings, mock_init_apprise):
|
||||
"""Test notification with failure type."""
|
||||
from app.utils.notification import send_notification
|
||||
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_apprise = MagicMock()
|
||||
mock_server = MagicMock()
|
||||
mock_server.notify.return_value = True
|
||||
mock_apprise.servers = [mock_server]
|
||||
mock_init_apprise.return_value = mock_apprise
|
||||
|
||||
result = send_notification("Test", "Message", notification_type="failed")
|
||||
|
||||
assert result is True
|
||||
|
||||
@patch("app.utils.notification.init_apprise")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_send_notification_no_servers(self, mock_settings, mock_init_apprise):
|
||||
"""Test notification when no servers are available."""
|
||||
from app.utils.notification import send_notification
|
||||
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_apprise = MagicMock()
|
||||
mock_apprise.servers = []
|
||||
mock_init_apprise.return_value = mock_apprise
|
||||
|
||||
result = send_notification("Test", "Message")
|
||||
|
||||
assert result is False
|
||||
|
||||
@patch("app.utils.notification.init_apprise")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_send_notification_partial_success(self, mock_settings, mock_init_apprise):
|
||||
"""Test notification with multiple servers, some failing."""
|
||||
from app.utils.notification import send_notification
|
||||
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_apprise = MagicMock()
|
||||
mock_server1 = MagicMock()
|
||||
mock_server1.notify.return_value = True
|
||||
mock_server2 = MagicMock()
|
||||
mock_server2.notify.return_value = False
|
||||
mock_apprise.servers = [mock_server1, mock_server2]
|
||||
mock_init_apprise.return_value = mock_apprise
|
||||
|
||||
result = send_notification("Test", "Message")
|
||||
|
||||
assert result is True # At least one succeeded
|
||||
|
||||
@patch("app.utils.notification.init_apprise")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_send_notification_with_attachments(self, mock_settings, mock_init_apprise):
|
||||
"""Test notification with file attachments."""
|
||||
from app.utils.notification import send_notification
|
||||
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_apprise = MagicMock()
|
||||
mock_server = MagicMock()
|
||||
mock_server.notify.return_value = True
|
||||
mock_apprise.servers = [mock_server]
|
||||
mock_init_apprise.return_value = mock_apprise
|
||||
|
||||
result = send_notification("Test", "Message", attachments=["/path/to/file.pdf"])
|
||||
|
||||
assert result is True
|
||||
call_kwargs = mock_server.notify.call_args[1]
|
||||
assert call_kwargs["attach"] == ["/path/to/file.pdf"]
|
||||
|
||||
@patch("app.utils.notification.init_apprise")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_send_notification_exception_handling(self, mock_settings, mock_init_apprise):
|
||||
"""Test that exceptions are caught and logged."""
|
||||
from app.utils.notification import send_notification
|
||||
|
||||
mock_settings.notification_urls = ["https://example.com/notify"]
|
||||
mock_init_apprise.side_effect = Exception("Connection error")
|
||||
|
||||
result = send_notification("Test", "Message")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotificationHelpers:
|
||||
"""Test notification helper functions."""
|
||||
|
||||
@patch("app.utils.notification.send_notification")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_notify_celery_failure_disabled(self, mock_settings, mock_send):
|
||||
"""Test Celery failure notification when disabled."""
|
||||
from app.utils.notification import notify_celery_failure
|
||||
|
||||
mock_settings.notify_on_task_failure = False
|
||||
|
||||
result = notify_celery_failure("test_task", "task-123", Exception("Error"), [], {})
|
||||
|
||||
assert result is False
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("app.utils.notification.send_notification")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_notify_celery_failure_enabled(self, mock_settings, mock_send):
|
||||
"""Test Celery failure notification when enabled."""
|
||||
from app.utils.notification import notify_celery_failure
|
||||
|
||||
mock_settings.notify_on_task_failure = True
|
||||
mock_send.return_value = True
|
||||
|
||||
result = notify_celery_failure("test_task", "task-123", Exception("Error"), [], {})
|
||||
|
||||
assert result is True
|
||||
mock_send.assert_called_once()
|
||||
call_args = mock_send.call_args[1]
|
||||
assert call_args["notification_type"] == "failure"
|
||||
|
||||
@patch("app.utils.notification.send_notification")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_notify_credential_failure_disabled(self, mock_settings, mock_send):
|
||||
"""Test credential failure notification when disabled."""
|
||||
from app.utils.notification import notify_credential_failure
|
||||
|
||||
mock_settings.notify_on_credential_failure = False
|
||||
|
||||
result = notify_credential_failure("Dropbox", "Invalid token")
|
||||
|
||||
assert result is False
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("app.utils.notification.send_notification")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_notify_startup_disabled(self, mock_settings, mock_send):
|
||||
"""Test startup notification when disabled."""
|
||||
from app.utils.notification import notify_startup
|
||||
|
||||
mock_settings.notify_on_startup = False
|
||||
|
||||
result = notify_startup()
|
||||
|
||||
assert result is False
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("app.utils.notification.send_notification")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_notify_shutdown_disabled(self, mock_settings, mock_send):
|
||||
"""Test shutdown notification when disabled."""
|
||||
from app.utils.notification import notify_shutdown
|
||||
|
||||
mock_settings.notify_on_shutdown = False
|
||||
|
||||
result = notify_shutdown()
|
||||
|
||||
assert result is False
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("app.utils.notification.send_notification")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_notify_file_processed_disabled(self, mock_settings, mock_send):
|
||||
"""Test file processed notification when disabled."""
|
||||
from app.utils.notification import notify_file_processed
|
||||
|
||||
mock_settings.notify_on_file_processed = False
|
||||
|
||||
result = notify_file_processed("test.pdf", 1024000, {}, [])
|
||||
|
||||
assert result is False
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("app.utils.notification.send_notification")
|
||||
@patch("app.utils.notification.settings")
|
||||
def test_notify_file_processed_with_metadata(self, mock_settings, mock_send):
|
||||
"""Test file processed notification with metadata."""
|
||||
from app.utils.notification import notify_file_processed
|
||||
|
||||
mock_settings.notify_on_file_processed = True
|
||||
mock_send.return_value = True
|
||||
|
||||
metadata = {"document_type": "invoice", "tags": ["important", "finance"]}
|
||||
result = notify_file_processed("test.pdf", 2048000, metadata, ["dropbox", "s3"])
|
||||
|
||||
assert result is True
|
||||
mock_send.assert_called_once()
|
||||
# Check message contains expected info
|
||||
message = mock_send.call_args[0][1]
|
||||
assert "2.00 MB" in message
|
||||
assert "invoice" in message
|
||||
assert "important, finance" in message
|
||||
assert "dropbox, s3" in message
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Tests for app/tasks/send_to_all.py module."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.tasks.send_to_all import (
|
||||
_should_upload_to_dropbox,
|
||||
_should_upload_to_email,
|
||||
_should_upload_to_ftp,
|
||||
_should_upload_to_google_drive,
|
||||
_should_upload_to_nextcloud,
|
||||
_should_upload_to_onedrive,
|
||||
_should_upload_to_paperless,
|
||||
_should_upload_to_s3,
|
||||
_should_upload_to_sftp,
|
||||
_should_upload_to_webdav,
|
||||
get_configured_services_from_validator,
|
||||
send_to_all_destinations,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestShouldUploadFunctions:
|
||||
"""Test the _should_upload_to_* helper functions."""
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_dropbox_all_configured(self, mock_settings):
|
||||
"""Test Dropbox upload check when all credentials are configured."""
|
||||
mock_settings.dropbox_app_key = "key"
|
||||
mock_settings.dropbox_app_secret = "secret"
|
||||
mock_settings.dropbox_refresh_token = "token"
|
||||
|
||||
assert _should_upload_to_dropbox() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_dropbox_missing_credentials(self, mock_settings):
|
||||
"""Test Dropbox upload check when credentials are missing."""
|
||||
mock_settings.dropbox_app_key = None
|
||||
mock_settings.dropbox_app_secret = None
|
||||
mock_settings.dropbox_refresh_token = None
|
||||
|
||||
assert _should_upload_to_dropbox() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_nextcloud_configured(self, mock_settings):
|
||||
"""Test Nextcloud upload check."""
|
||||
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com"
|
||||
mock_settings.nextcloud_username = "user"
|
||||
mock_settings.nextcloud_password = "pass"
|
||||
|
||||
assert _should_upload_to_nextcloud() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_paperless_configured(self, mock_settings):
|
||||
"""Test Paperless upload check."""
|
||||
mock_settings.paperless_ngx_api_token = "token"
|
||||
mock_settings.paperless_host = "https://paperless.example.com"
|
||||
|
||||
assert _should_upload_to_paperless() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_google_drive_oauth(self, mock_settings):
|
||||
"""Test Google Drive upload check with OAuth."""
|
||||
mock_settings.google_drive_use_oauth = True
|
||||
mock_settings.google_drive_client_id = "client_id"
|
||||
mock_settings.google_drive_client_secret = "client_secret"
|
||||
mock_settings.google_drive_refresh_token = "refresh_token"
|
||||
mock_settings.google_drive_folder_id = "folder_id"
|
||||
|
||||
assert _should_upload_to_google_drive() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_google_drive_service_account(self, mock_settings):
|
||||
"""Test Google Drive upload check with service account."""
|
||||
mock_settings.google_drive_use_oauth = False
|
||||
mock_settings.google_drive_credentials_json = '{"type": "service_account"}'
|
||||
mock_settings.google_drive_folder_id = "folder_id"
|
||||
|
||||
assert _should_upload_to_google_drive() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_webdav_configured(self, mock_settings):
|
||||
"""Test WebDAV upload check."""
|
||||
mock_settings.webdav_url = "https://webdav.example.com"
|
||||
mock_settings.webdav_username = "user"
|
||||
mock_settings.webdav_password = "pass"
|
||||
|
||||
assert _should_upload_to_webdav() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_ftp_configured(self, mock_settings):
|
||||
"""Test FTP upload check."""
|
||||
mock_settings.ftp_host = "ftp.example.com"
|
||||
mock_settings.ftp_username = "user"
|
||||
mock_settings.ftp_password = "pass"
|
||||
|
||||
assert _should_upload_to_ftp() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_sftp_with_password(self, mock_settings):
|
||||
"""Test SFTP upload check with password auth."""
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_username = "user"
|
||||
mock_settings.sftp_password = "pass"
|
||||
mock_settings.sftp_private_key = None
|
||||
|
||||
assert _should_upload_to_sftp() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_sftp_with_key(self, mock_settings):
|
||||
"""Test SFTP upload check with key auth."""
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_username = "user"
|
||||
mock_settings.sftp_password = None
|
||||
mock_settings.sftp_private_key = "/path/to/key"
|
||||
|
||||
assert _should_upload_to_sftp() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_email_configured(self, mock_settings):
|
||||
"""Test email upload check."""
|
||||
mock_settings.email_host = "smtp.example.com"
|
||||
mock_settings.email_username = "user"
|
||||
mock_settings.email_password = "pass"
|
||||
mock_settings.email_default_recipient = "recipient@example.com"
|
||||
|
||||
assert _should_upload_to_email() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_onedrive_configured(self, mock_settings):
|
||||
"""Test OneDrive upload check."""
|
||||
mock_settings.onedrive_client_id = "client_id"
|
||||
mock_settings.onedrive_client_secret = "client_secret"
|
||||
mock_settings.onedrive_refresh_token = "refresh_token"
|
||||
|
||||
assert _should_upload_to_onedrive() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_s3_configured(self, mock_settings):
|
||||
"""Test S3 upload check."""
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key_id"
|
||||
mock_settings.aws_secret_access_key = "secret_key"
|
||||
|
||||
assert _should_upload_to_s3() is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetConfiguredServicesFromValidator:
|
||||
"""Test get_configured_services_from_validator function."""
|
||||
|
||||
@patch("app.tasks.send_to_all.get_provider_status")
|
||||
def test_returns_configured_services(self, mock_get_status):
|
||||
"""Test that configured services are returned correctly."""
|
||||
mock_get_status.return_value = {
|
||||
"Dropbox": {"configured": True},
|
||||
"NextCloud": {"configured": False},
|
||||
"S3 Storage": {"configured": True},
|
||||
}
|
||||
|
||||
result = get_configured_services_from_validator()
|
||||
|
||||
assert result["dropbox"] is True
|
||||
assert result["nextcloud"] is False
|
||||
assert result["s3"] is True
|
||||
|
||||
@patch("app.tasks.send_to_all.get_provider_status")
|
||||
def test_handles_missing_providers(self, mock_get_status):
|
||||
"""Test handling when some providers are not in status."""
|
||||
mock_get_status.return_value = {
|
||||
"Dropbox": {"configured": True},
|
||||
}
|
||||
|
||||
result = get_configured_services_from_validator()
|
||||
|
||||
assert result["dropbox"] is True
|
||||
# Other services not in result
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSendToAllDestinations:
|
||||
"""Test send_to_all_destinations task."""
|
||||
|
||||
def test_file_not_found_error(self):
|
||||
"""Test that FileNotFoundError is raised when file doesn't exist."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
send_to_all_destinations.apply(args=["/nonexistent/file.pdf"])
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
def test_queues_single_configured_service(self, mock_upload, mock_should, mock_settings, tmp_path):
|
||||
"""Test queueing upload to a single configured service."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test")
|
||||
|
||||
mock_should.return_value = True
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False])
|
||||
|
||||
assert result.result["status"] == "Queued"
|
||||
mock_upload.delay.assert_called_once()
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
@patch("app.tasks.send_to_all.upload_to_s3")
|
||||
def test_queues_multiple_services(
|
||||
self, mock_s3_upload, mock_dropbox_upload, mock_should_s3, mock_should_dropbox, mock_settings, tmp_path
|
||||
):
|
||||
"""Test queueing uploads to multiple services."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test")
|
||||
|
||||
mock_should_dropbox.return_value = True
|
||||
mock_should_s3.return_value = True
|
||||
mock_dropbox_upload.delay.return_value = MagicMock(id="dropbox-task")
|
||||
mock_s3_upload.delay.return_value = MagicMock(id="s3-task")
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False])
|
||||
|
||||
assert result.result["status"] == "Queued"
|
||||
mock_dropbox_upload.delay.assert_called_once()
|
||||
mock_s3_upload.delay.assert_called_once()
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||
def test_skips_unconfigured_services(self, mock_should, mock_settings, tmp_path):
|
||||
"""Test that unconfigured services are skipped."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test")
|
||||
|
||||
mock_should.return_value = False
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False])
|
||||
|
||||
# No uploads should be queued
|
||||
assert result.result["status"] == "Queued"
|
||||
# Check that message indicates 0 uploads
|
||||
assert "0 upload" in result.result["tasks"] or len(result.result["tasks"]) == 0
|
||||
|
||||
@patch("app.tasks.send_to_all.SessionLocal")
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
def test_with_file_id_parameter(
|
||||
self, mock_upload, mock_should, mock_settings, mock_session_local, tmp_path
|
||||
):
|
||||
"""Test send_to_all with explicit file_id parameter."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test")
|
||||
|
||||
mock_should.return_value = True
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 42])
|
||||
|
||||
mock_upload.delay.assert_called_once()
|
||||
# Verify file_id was passed to the upload task
|
||||
call_kwargs = mock_upload.delay.call_args[1]
|
||||
assert call_kwargs.get("file_id") == 42
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
def test_uses_validator_when_enabled(
|
||||
self, mock_upload, mock_validator, mock_settings, tmp_path
|
||||
):
|
||||
"""Test that validator is used when use_validator=True."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test")
|
||||
|
||||
mock_validator.return_value = {"dropbox": True}
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), True])
|
||||
|
||||
mock_validator.assert_called_once()
|
||||
mock_upload.delay.assert_called_once()
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||
def test_validator_exception_fallback(self, mock_validator, mock_settings, tmp_path):
|
||||
"""Test fallback to individual checks when validator fails."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test")
|
||||
|
||||
mock_validator.side_effect = Exception("Validator error")
|
||||
|
||||
# Should not raise, should fall back to individual checks
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), True])
|
||||
|
||||
assert result.result["status"] == "Queued"
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
def test_handles_upload_task_queue_error(
|
||||
self, mock_upload, mock_should, mock_settings, tmp_path
|
||||
):
|
||||
"""Test handling when queueing upload task fails."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test")
|
||||
|
||||
mock_should.return_value = True
|
||||
mock_upload.delay.side_effect = Exception("Queue error")
|
||||
|
||||
# Should not raise, should log error
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False])
|
||||
|
||||
assert result.result["status"] == "Queued"
|
||||
# Error should be recorded in results
|
||||
assert "dropbox_error" in result.result["tasks"]
|
||||
|
||||
@patch("app.tasks.send_to_all.SessionLocal")
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_fallback_file_id_lookup(self, mock_settings, mock_session_local, tmp_path):
|
||||
"""Test file_id lookup fallback when not provided."""
|
||||
from app.models import FileRecord
|
||||
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test")
|
||||
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
|
||||
# Mock database session
|
||||
mock_db = MagicMock()
|
||||
mock_query = MagicMock()
|
||||
mock_file_record = MagicMock(spec=FileRecord)
|
||||
mock_file_record.id = 123
|
||||
mock_query.first.return_value = mock_file_record
|
||||
mock_db.query.return_value.filter.return_value = mock_query
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False, None])
|
||||
|
||||
# Should attempt database lookup
|
||||
mock_db.query.assert_called()
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||
def test_should_upload_check_exception_handling(self, mock_should, mock_settings, tmp_path):
|
||||
"""Test that exceptions in should_upload checks are handled."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test")
|
||||
|
||||
mock_should.side_effect = Exception("Configuration check error")
|
||||
|
||||
# Should not raise, should treat as not configured
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False])
|
||||
|
||||
assert result.result["status"] == "Queued"
|
||||
@@ -344,3 +344,149 @@ class TestSettingsValidationEdgeCases:
|
||||
is_valid, error = validate_setting_value("unknown_setting_xyz", "some_value")
|
||||
assert is_valid is True
|
||||
assert error is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetSettingsByCategory:
|
||||
"""Test get_settings_by_category function."""
|
||||
|
||||
def test_returns_dict_with_categories(self):
|
||||
"""Test that it returns a dictionary with categories."""
|
||||
from app.utils.settings_service import get_settings_by_category
|
||||
|
||||
result = get_settings_by_category()
|
||||
assert isinstance(result, dict)
|
||||
# Should have some standard categories
|
||||
assert "Core" in result or len(result) > 0
|
||||
|
||||
def test_all_settings_have_category(self):
|
||||
"""Test that all settings are grouped by category."""
|
||||
from app.utils.settings_service import SETTING_METADATA, get_settings_by_category
|
||||
|
||||
result = get_settings_by_category()
|
||||
total_settings = sum(len(settings) for settings in result.values())
|
||||
# Should account for all metadata entries
|
||||
assert total_settings >= len(SETTING_METADATA) * 0.8 # Allow for some filtering
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetAllSettings:
|
||||
"""Test get_all_settings function."""
|
||||
|
||||
def test_returns_list_of_dicts(self):
|
||||
"""Test that it returns a list of setting dictionaries."""
|
||||
from app.utils.settings_service import get_all_settings
|
||||
|
||||
result = get_all_settings()
|
||||
assert isinstance(result, list)
|
||||
if len(result) > 0:
|
||||
assert isinstance(result[0], dict)
|
||||
assert "key" in result[0]
|
||||
assert "metadata" in result[0]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSaveSettingErrors:
|
||||
"""Test error handling in save_setting."""
|
||||
|
||||
def test_handles_database_error(self):
|
||||
"""Test handling of database errors."""
|
||||
from app.utils.settings_service import save_setting
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.commit.side_effect = SQLAlchemyError("Database error")
|
||||
|
||||
success, error = save_setting(mock_db, "test_key", "test_value")
|
||||
|
||||
assert success is False
|
||||
assert "error" in error.lower() or "failed" in error.lower()
|
||||
# Should rollback on error
|
||||
mock_db.rollback.assert_called_once()
|
||||
|
||||
def test_closes_session_on_error(self):
|
||||
"""Test that session operations are properly managed on error."""
|
||||
from app.utils.settings_service import save_setting
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = SQLAlchemyError("Query error")
|
||||
|
||||
success, error = save_setting(mock_db, "test_key", "test_value")
|
||||
|
||||
assert success is False
|
||||
# Should attempt rollback
|
||||
mock_db.rollback.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetSettingValue:
|
||||
"""Test get_setting_value function."""
|
||||
|
||||
def test_returns_value_from_database(self):
|
||||
"""Test retrieving value from database."""
|
||||
from app.models import ApplicationSettings
|
||||
from app.utils.settings_service import get_setting_value
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_setting = ApplicationSettings(key="test_key", value="test_value")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_setting
|
||||
|
||||
result = get_setting_value(mock_db, "test_key")
|
||||
assert result == "test_value"
|
||||
|
||||
def test_returns_none_for_missing_setting(self):
|
||||
"""Test that None is returned for missing setting."""
|
||||
from app.utils.settings_service import get_setting_value
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
result = get_setting_value(mock_db, "nonexistent_key")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteSetting:
|
||||
"""Test delete_setting function."""
|
||||
|
||||
def test_deletes_existing_setting(self):
|
||||
"""Test deleting an existing setting."""
|
||||
from app.models import ApplicationSettings
|
||||
from app.utils.settings_service import delete_setting
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_setting = ApplicationSettings(key="test_key", value="test_value")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_setting
|
||||
|
||||
success, message = delete_setting(mock_db, "test_key")
|
||||
|
||||
assert success is True
|
||||
mock_db.delete.assert_called_once_with(mock_setting)
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_handles_nonexistent_setting(self):
|
||||
"""Test deleting a setting that doesn't exist."""
|
||||
from app.utils.settings_service import delete_setting
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
success, message = delete_setting(mock_db, "nonexistent_key")
|
||||
|
||||
# Behavior depends on implementation - might be success or failure
|
||||
assert isinstance(success, bool)
|
||||
assert isinstance(message, str)
|
||||
|
||||
def test_handles_database_error_on_delete(self):
|
||||
"""Test handling database error during delete."""
|
||||
from app.models import ApplicationSettings
|
||||
from app.utils.settings_service import delete_setting
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_setting = ApplicationSettings(key="test_key", value="test_value")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_setting
|
||||
mock_db.commit.side_effect = SQLAlchemyError("Delete error")
|
||||
|
||||
success, message = delete_setting(mock_db, "test_key")
|
||||
|
||||
assert success is False
|
||||
mock_db.rollback.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tests for app/tasks/upload_to_s3.py module."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from app.tasks.upload_to_s3 import upload_to_s3
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadToS3:
|
||||
"""Tests for S3 upload functionality."""
|
||||
|
||||
@patch("app.tasks.upload_to_s3.boto3.client")
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_success_with_folder_prefix(self, mock_settings, mock_boto_client, tmp_path):
|
||||
"""Test successful S3 upload with folder prefix."""
|
||||
# Setup
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "AKIAIOSFODNN7EXAMPLE"
|
||||
mock_settings.aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
mock_settings.aws_region = "us-east-1"
|
||||
mock_settings.s3_folder_prefix = "documents"
|
||||
mock_settings.s3_storage_class = "STANDARD"
|
||||
mock_settings.s3_acl = "private"
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_boto_client.return_value = mock_s3
|
||||
|
||||
# Execute
|
||||
result = upload_to_s3.apply(args=[str(test_file)])
|
||||
|
||||
# Verify
|
||||
assert result.result["status"] == "Completed"
|
||||
assert result.result["s3_bucket"] == "my-bucket"
|
||||
assert result.result["s3_key"] == "documents/test.pdf"
|
||||
|
||||
mock_s3.upload_file.assert_called_once()
|
||||
call_args = mock_s3.upload_file.call_args
|
||||
assert call_args[0][0] == str(test_file)
|
||||
assert call_args[0][1] == "my-bucket"
|
||||
assert call_args[0][2] == "documents/test.pdf"
|
||||
assert call_args[1]["ExtraArgs"]["StorageClass"] == "STANDARD"
|
||||
assert call_args[1]["ExtraArgs"]["ACL"] == "private"
|
||||
|
||||
@patch("app.tasks.upload_to_s3.boto3.client")
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_without_folder_prefix(self, mock_settings, mock_boto_client, tmp_path):
|
||||
"""Test S3 upload without folder prefix."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key"
|
||||
mock_settings.aws_secret_access_key = "secret"
|
||||
mock_settings.aws_region = "us-west-2"
|
||||
mock_settings.s3_folder_prefix = None
|
||||
mock_settings.s3_storage_class = "STANDARD"
|
||||
mock_settings.s3_acl = None
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_boto_client.return_value = mock_s3
|
||||
|
||||
result = upload_to_s3.apply(args=[str(test_file)])
|
||||
|
||||
assert result.result["s3_key"] == "test.pdf"
|
||||
# Verify ACL not included when None
|
||||
extra_args = mock_s3.upload_file.call_args[1]["ExtraArgs"]
|
||||
assert "ACL" not in extra_args
|
||||
|
||||
@patch("app.tasks.upload_to_s3.boto3.client")
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_with_folder_prefix_no_trailing_slash(self, mock_settings, mock_boto_client, tmp_path):
|
||||
"""Test that folder prefix gets trailing slash added."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key"
|
||||
mock_settings.aws_secret_access_key = "secret"
|
||||
mock_settings.aws_region = "us-west-2"
|
||||
mock_settings.s3_folder_prefix = "uploads/docs" # No trailing slash
|
||||
mock_settings.s3_storage_class = "STANDARD"
|
||||
mock_settings.s3_acl = None
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_boto_client.return_value = mock_s3
|
||||
|
||||
result = upload_to_s3.apply(args=[str(test_file)])
|
||||
|
||||
# Should add trailing slash
|
||||
assert result.result["s3_key"] == "uploads/docs/test.pdf"
|
||||
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_file_not_found(self, mock_settings):
|
||||
"""Test S3 upload with non-existent file."""
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key"
|
||||
mock_settings.aws_secret_access_key = "secret"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
upload_to_s3.apply(args=["/nonexistent/file.pdf"])
|
||||
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_missing_bucket_name(self, mock_settings, tmp_path):
|
||||
"""Test S3 upload with missing bucket name."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.s3_bucket_name = None
|
||||
mock_settings.aws_access_key_id = "key"
|
||||
mock_settings.aws_secret_access_key = "secret"
|
||||
|
||||
with pytest.raises(ValueError, match="bucket name"):
|
||||
upload_to_s3.apply(args=[str(test_file)])
|
||||
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_missing_credentials(self, mock_settings, tmp_path):
|
||||
"""Test S3 upload with missing AWS credentials."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = None
|
||||
mock_settings.aws_secret_access_key = None
|
||||
|
||||
with pytest.raises(ValueError, match="credentials"):
|
||||
upload_to_s3.apply(args=[str(test_file)])
|
||||
|
||||
@patch("app.tasks.upload_to_s3.boto3.client")
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_client_error(self, mock_settings, mock_boto_client, tmp_path):
|
||||
"""Test S3 upload with boto3 ClientError."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key"
|
||||
mock_settings.aws_secret_access_key = "secret"
|
||||
mock_settings.aws_region = "us-east-1"
|
||||
mock_settings.s3_folder_prefix = None
|
||||
mock_settings.s3_storage_class = "STANDARD"
|
||||
mock_settings.s3_acl = None
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
error_response = {"Error": {"Code": "NoSuchBucket", "Message": "The specified bucket does not exist"}}
|
||||
mock_s3.upload_file.side_effect = ClientError(error_response, "upload_file")
|
||||
mock_boto_client.return_value = mock_s3
|
||||
|
||||
with pytest.raises(Exception, match="Failed to upload"):
|
||||
upload_to_s3.apply(args=[str(test_file)])
|
||||
|
||||
@patch("app.tasks.upload_to_s3.boto3.client")
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_generic_exception(self, mock_settings, mock_boto_client, tmp_path):
|
||||
"""Test S3 upload with generic exception."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key"
|
||||
mock_settings.aws_secret_access_key = "secret"
|
||||
mock_settings.aws_region = "us-east-1"
|
||||
mock_settings.s3_folder_prefix = None
|
||||
mock_settings.s3_storage_class = "STANDARD"
|
||||
mock_settings.s3_acl = None
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_s3.upload_file.side_effect = Exception("Network error")
|
||||
mock_boto_client.return_value = mock_s3
|
||||
|
||||
with pytest.raises(Exception, match="Error uploading"):
|
||||
upload_to_s3.apply(args=[str(test_file)])
|
||||
|
||||
@patch("app.tasks.upload_to_s3.boto3.client")
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_with_different_storage_classes(self, mock_settings, mock_boto_client, tmp_path):
|
||||
"""Test S3 upload with different storage classes."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key"
|
||||
mock_settings.aws_secret_access_key = "secret"
|
||||
mock_settings.aws_region = "us-east-1"
|
||||
mock_settings.s3_folder_prefix = None
|
||||
mock_settings.s3_acl = None
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_boto_client.return_value = mock_s3
|
||||
|
||||
# Test INTELLIGENT_TIERING
|
||||
mock_settings.s3_storage_class = "INTELLIGENT_TIERING"
|
||||
result = upload_to_s3.apply(args=[str(test_file)])
|
||||
extra_args = mock_s3.upload_file.call_args[1]["ExtraArgs"]
|
||||
assert extra_args["StorageClass"] == "INTELLIGENT_TIERING"
|
||||
|
||||
@patch("app.tasks.upload_to_s3.boto3.client")
|
||||
@patch("app.tasks.upload_to_s3.settings")
|
||||
def test_upload_url_generation(self, mock_settings, mock_boto_client, tmp_path):
|
||||
"""Test that the S3 URL is properly generated."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key"
|
||||
mock_settings.aws_secret_access_key = "secret"
|
||||
mock_settings.aws_region = "eu-west-1"
|
||||
mock_settings.s3_folder_prefix = "docs"
|
||||
mock_settings.s3_storage_class = "STANDARD"
|
||||
mock_settings.s3_acl = None
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_boto_client.return_value = mock_s3
|
||||
|
||||
result = upload_to_s3.apply(args=[str(test_file)])
|
||||
|
||||
expected_url = "https://my-bucket.s3.eu-west-1.amazonaws.com/docs/test.pdf"
|
||||
assert result.result["s3_url"] == expected_url
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Tests for app/tasks/upload_to_sftp.py module."""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import paramiko
|
||||
import pytest
|
||||
|
||||
from app.tasks.upload_to_sftp import upload_to_sftp
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadToSFTP:
|
||||
"""Tests for SFTP upload functionality."""
|
||||
|
||||
@patch("app.tasks.upload_to_sftp.paramiko.SSHClient")
|
||||
@patch("app.tasks.upload_to_sftp.settings")
|
||||
def test_upload_with_key_authentication(self, mock_settings, mock_ssh_class, tmp_path):
|
||||
"""Test SFTP upload using SSH key authentication."""
|
||||
# Setup
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_port = 22
|
||||
mock_settings.sftp_username = "testuser"
|
||||
mock_settings.sftp_password = None
|
||||
mock_settings.sftp_private_key = str(tmp_path / "key.pem")
|
||||
mock_settings.sftp_private_key_passphrase = "passphrase"
|
||||
mock_settings.sftp_folder = "/uploads"
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
mock_settings.sftp_disable_host_key_verification = False
|
||||
|
||||
# Create mock key file
|
||||
key_file = tmp_path / "key.pem"
|
||||
key_file.write_text("fake key")
|
||||
|
||||
# Mock SSH and SFTP
|
||||
mock_ssh = MagicMock()
|
||||
mock_sftp = MagicMock()
|
||||
mock_ssh.open_sftp.return_value = mock_sftp
|
||||
mock_sftp.stat.side_effect = FileNotFoundError
|
||||
mock_ssh_class.return_value = mock_ssh
|
||||
|
||||
# Execute
|
||||
task = upload_to_sftp.apply(args=[str(test_file)])
|
||||
|
||||
# Verify
|
||||
assert task.result["status"] == "Completed"
|
||||
mock_ssh.connect.assert_called_once()
|
||||
connect_kwargs = mock_ssh.connect.call_args[1]
|
||||
assert connect_kwargs["key_filename"] == str(tmp_path / "key.pem")
|
||||
assert connect_kwargs["passphrase"] == "passphrase"
|
||||
mock_sftp.put.assert_called_once()
|
||||
|
||||
@patch("app.tasks.upload_to_sftp.paramiko.SSHClient")
|
||||
@patch("app.tasks.upload_to_sftp.settings")
|
||||
def test_upload_with_password_authentication(self, mock_settings, mock_ssh_class, tmp_path):
|
||||
"""Test SFTP upload using password authentication."""
|
||||
# Setup
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_port = 22
|
||||
mock_settings.sftp_username = "testuser"
|
||||
mock_settings.sftp_password = "testpass"
|
||||
mock_settings.sftp_private_key = None
|
||||
mock_settings.sftp_folder = "/uploads"
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
mock_settings.sftp_disable_host_key_verification = False
|
||||
|
||||
# Mock SSH and SFTP
|
||||
mock_ssh = MagicMock()
|
||||
mock_sftp = MagicMock()
|
||||
mock_ssh.open_sftp.return_value = mock_sftp
|
||||
mock_sftp.stat.side_effect = FileNotFoundError
|
||||
mock_ssh_class.return_value = mock_ssh
|
||||
|
||||
# Execute
|
||||
task = upload_to_sftp.apply(args=[str(test_file)])
|
||||
|
||||
# Verify
|
||||
assert task.result["status"] == "Completed"
|
||||
connect_kwargs = mock_ssh.connect.call_args[1]
|
||||
assert connect_kwargs["password"] == "testpass"
|
||||
assert "key_filename" not in connect_kwargs
|
||||
|
||||
@patch("app.tasks.upload_to_sftp.paramiko.SSHClient")
|
||||
@patch("app.tasks.upload_to_sftp.settings")
|
||||
def test_upload_with_disabled_host_key_verification(self, mock_settings, mock_ssh_class, tmp_path):
|
||||
"""Test SFTP upload with host key verification disabled."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_port = 22
|
||||
mock_settings.sftp_username = "testuser"
|
||||
mock_settings.sftp_password = "testpass"
|
||||
mock_settings.sftp_folder = ""
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
mock_settings.sftp_disable_host_key_verification = True
|
||||
|
||||
mock_ssh = MagicMock()
|
||||
mock_sftp = MagicMock()
|
||||
mock_ssh.open_sftp.return_value = mock_sftp
|
||||
mock_sftp.stat.side_effect = FileNotFoundError
|
||||
mock_ssh_class.return_value = mock_ssh
|
||||
|
||||
# Execute
|
||||
task = upload_to_sftp.apply(args=[str(test_file)])
|
||||
|
||||
# Verify AutoAddPolicy was set
|
||||
mock_ssh.set_missing_host_key_policy.assert_called()
|
||||
# Check that it was called with AutoAddPolicy (not RejectPolicy)
|
||||
call_arg = mock_ssh.set_missing_host_key_policy.call_args[0][0]
|
||||
assert isinstance(call_arg, paramiko.AutoAddPolicy)
|
||||
|
||||
@patch("app.tasks.upload_to_sftp.settings")
|
||||
def test_upload_file_not_found(self, mock_settings):
|
||||
"""Test SFTP upload with non-existent file."""
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_port = 22
|
||||
mock_settings.sftp_username = "testuser"
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
upload_to_sftp.apply(args=["/nonexistent/file.pdf"])
|
||||
|
||||
@patch("app.tasks.upload_to_sftp.settings")
|
||||
def test_upload_missing_configuration(self, mock_settings, tmp_path):
|
||||
"""Test SFTP upload with missing configuration."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.sftp_host = None
|
||||
mock_settings.sftp_port = None
|
||||
mock_settings.sftp_username = None
|
||||
|
||||
result = upload_to_sftp.apply(args=[str(test_file)])
|
||||
|
||||
assert result.result["status"] == "Skipped"
|
||||
assert "not configured" in result.result["reason"]
|
||||
|
||||
@patch("app.tasks.upload_to_sftp.paramiko.SSHClient")
|
||||
@patch("app.tasks.upload_to_sftp.settings")
|
||||
def test_upload_no_authentication_method(self, mock_settings, mock_ssh_class, tmp_path):
|
||||
"""Test SFTP upload with no authentication method available."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_port = 22
|
||||
mock_settings.sftp_username = "testuser"
|
||||
mock_settings.sftp_password = None
|
||||
mock_settings.sftp_private_key = None
|
||||
mock_settings.sftp_folder = ""
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
mock_settings.sftp_disable_host_key_verification = False
|
||||
|
||||
mock_ssh = MagicMock()
|
||||
mock_ssh_class.return_value = mock_ssh
|
||||
|
||||
with pytest.raises(Exception, match="No authentication method"):
|
||||
upload_to_sftp.apply(args=[str(test_file)])
|
||||
|
||||
@patch("app.tasks.upload_to_sftp.paramiko.SSHClient")
|
||||
@patch("app.tasks.upload_to_sftp.settings")
|
||||
def test_upload_creates_remote_directories(self, mock_settings, mock_ssh_class, tmp_path):
|
||||
"""Test that remote directories are created as needed."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_port = 22
|
||||
mock_settings.sftp_username = "testuser"
|
||||
mock_settings.sftp_password = "testpass"
|
||||
mock_settings.sftp_folder = "/remote/nested/path"
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
mock_settings.sftp_disable_host_key_verification = False
|
||||
|
||||
mock_ssh = MagicMock()
|
||||
mock_sftp = MagicMock()
|
||||
mock_ssh.open_sftp.return_value = mock_sftp
|
||||
|
||||
# Simulate directories not existing
|
||||
mock_sftp.stat.side_effect = FileNotFoundError
|
||||
mock_ssh_class.return_value = mock_ssh
|
||||
|
||||
# Execute
|
||||
task = upload_to_sftp.apply(args=[str(test_file)])
|
||||
|
||||
# Verify mkdir was called for each directory level
|
||||
mkdir_calls = [call[0][0] for call in mock_sftp.mkdir.call_args_list]
|
||||
assert any("/remote" in call for call in mkdir_calls)
|
||||
assert any("/remote/nested" in call for call in mkdir_calls)
|
||||
assert any("/remote/nested/path" in call for call in mkdir_calls)
|
||||
|
||||
@patch("app.tasks.upload_to_sftp.paramiko.SSHClient")
|
||||
@patch("app.tasks.upload_to_sftp.settings")
|
||||
def test_upload_connection_error_cleanup(self, mock_settings, mock_ssh_class, tmp_path):
|
||||
"""Test that connections are cleaned up on error."""
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_text("test content")
|
||||
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_port = 22
|
||||
mock_settings.sftp_username = "testuser"
|
||||
mock_settings.sftp_password = "testpass"
|
||||
mock_settings.sftp_folder = ""
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
mock_settings.sftp_disable_host_key_verification = False
|
||||
|
||||
mock_ssh = MagicMock()
|
||||
mock_sftp = MagicMock()
|
||||
mock_ssh.open_sftp.return_value = mock_sftp
|
||||
mock_sftp.put.side_effect = Exception("Upload failed")
|
||||
mock_ssh_class.return_value = mock_ssh
|
||||
|
||||
with pytest.raises(Exception, match="Upload failed"):
|
||||
upload_to_sftp.apply(args=[str(test_file)])
|
||||
|
||||
# Verify cleanup was attempted
|
||||
mock_sftp.close.assert_called_once()
|
||||
mock_ssh.close.assert_called_once()
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for app/views/license_routes.py module."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -11,3 +14,26 @@ class TestLicenseViews:
|
||||
"""Test license API endpoint."""
|
||||
response = client.get("/api/license")
|
||||
assert response.status_code in (200, 404)
|
||||
|
||||
def test_get_lgpl_license_success(self, client):
|
||||
"""Test successful LGPL license retrieval."""
|
||||
# Create a mock license file
|
||||
license_content = "GNU Lesser General Public License\nVersion 3, 29 June 2007"
|
||||
|
||||
with patch("pathlib.Path.exists", return_value=True):
|
||||
with patch("builtins.open", mock_open(read_data=license_content)):
|
||||
response = client.get("/licenses/lgpl.txt")
|
||||
assert response.status_code == 200
|
||||
assert "GNU Lesser General Public License" in response.text
|
||||
|
||||
def test_get_lgpl_license_not_found(self, client):
|
||||
"""Test LGPL license file not found."""
|
||||
with patch("pathlib.Path.exists", return_value=False):
|
||||
response = client.get("/licenses/lgpl.txt")
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
def test_serve_attribution_page(self, client):
|
||||
"""Test attribution page is served."""
|
||||
response = client.get("/attribution")
|
||||
assert response.status_code in (200, 404, 500) # Allow for missing template
|
||||
|
||||
Reference in New Issue
Block a user