diff --git a/tests/test_database.py b/tests/test_database.py index 8310aae8..008b1d7e 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -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() diff --git a/tests/test_notification.py b/tests/test_notification.py index a828b7a9..2fd2edee 100644 --- a/tests/test_notification.py +++ b/tests/test_notification.py @@ -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 diff --git a/tests/test_upload_to_s3.py b/tests/test_upload_to_s3.py new file mode 100644 index 00000000..c20c0c46 --- /dev/null +++ b/tests/test_upload_to_s3.py @@ -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 diff --git a/tests/test_upload_to_sftp.py b/tests/test_upload_to_sftp.py new file mode 100644 index 00000000..cea5ed56 --- /dev/null +++ b/tests/test_upload_to_sftp.py @@ -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() diff --git a/tests/test_views_license.py b/tests/test_views_license.py index c5867ed6..c2b7d571 100644 --- a/tests/test_views_license.py +++ b/tests/test_views_license.py @@ -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