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:
@@ -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()
|
||||
|
||||
@@ -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,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,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()
|
||||
@@ -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