test: add comprehensive tests for license_routes, database migrations, SFTP, S3, and notifications

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-14 00:30:52 +00:00
parent bf23d43a0e
commit 1be3f1254b
5 changed files with 929 additions and 0 deletions
+208
View File
@@ -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()
+246
View File
@@ -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
+224
View File
@@ -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
+225
View File
@@ -0,0 +1,225 @@
"""Tests for app/tasks/upload_to_sftp.py module."""
import os
from unittest.mock import MagicMock, Mock, patch
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."""
import paramiko
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()
+26
View File
@@ -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