Merge pull request #562 from christianlouis/copilot/refactor-dynamic-routing-user-destinations
fix(tasks): register upload_to_user_integration in Celery and add handler coverage
This commit is contained in:
+236
-42
@@ -6,13 +6,34 @@ import pytest
|
||||
|
||||
from app.tasks.finalize_document_storage import finalize_document_storage
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_file_record(file_id: int = 123, owner_id=None):
|
||||
"""Return a lightweight MagicMock that mimics a FileRecord."""
|
||||
rec = MagicMock()
|
||||
rec.id = file_id
|
||||
rec.owner_id = owner_id
|
||||
return rec
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_celery_background_tasks(mocker):
|
||||
"""Module-level autouse fixture: prevent lazy-imported Celery tasks from connecting to Redis."""
|
||||
mocker.patch("app.tasks.compute_embedding.compute_document_embedding")
|
||||
mocker.patch("app.tasks.convert_to_pdfa.convert_to_pdfa", create=True)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFinalizeDocumentStorage:
|
||||
"""Tests for finalize_document_storage Celery task."""
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
@@ -21,11 +42,12 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""Test successful document finalization with all services configured."""
|
||||
# Mock configured services
|
||||
mock_get_services.return_value = {
|
||||
"dropbox": True,
|
||||
"google_drive": True,
|
||||
@@ -33,14 +55,11 @@ class TestFinalizeDocumentStorage:
|
||||
"s3": True,
|
||||
}
|
||||
|
||||
# Mock database session
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_file_record = MagicMock()
|
||||
mock_file_record.id = 123
|
||||
mock_file_record = _make_file_record(123, owner_id=None)
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
# Mock file existence and size
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=102400):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="test_document.pdf"):
|
||||
@@ -59,10 +78,10 @@ class TestFinalizeDocumentStorage:
|
||||
file_id=123,
|
||||
)
|
||||
|
||||
# Verify send_to_all_destinations was queued
|
||||
# owner_id=None → global routing
|
||||
mock_send_all.delay.assert_called_once_with("/workdir/processed/test_document.pdf", True, 123)
|
||||
mock_send_user.delay.assert_not_called()
|
||||
|
||||
# Verify notification was sent
|
||||
mock_notify.assert_called_once()
|
||||
notify_args = mock_notify.call_args[1]
|
||||
assert notify_args["filename"] == "test_document.pdf"
|
||||
@@ -72,12 +91,13 @@ class TestFinalizeDocumentStorage:
|
||||
assert "Google Drive" in notify_args["destinations"]
|
||||
assert "S3" in notify_args["destinations"]
|
||||
|
||||
# Verify result
|
||||
assert result["status"] == "Completed"
|
||||
assert result["file"] == "/workdir/processed/test_document.pdf"
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
@@ -86,17 +106,17 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""Test file_id retrieval from database when not provided."""
|
||||
mock_get_services.return_value = {"dropbox": True}
|
||||
|
||||
# Mock database session to return a file record
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_file_record = MagicMock()
|
||||
mock_file_record.id = 456
|
||||
mock_file_record = _make_file_record(456, owner_id=None)
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
@@ -121,11 +141,13 @@ class TestFinalizeDocumentStorage:
|
||||
# Verify database was queried
|
||||
mock_db.query.assert_called_once()
|
||||
|
||||
# Verify send_to_all was called with retrieved file_id
|
||||
# Verify send_to_all was called (global routing — no user destinations)
|
||||
mock_send_all.delay.assert_called_once()
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
@@ -134,11 +156,12 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""Test handles case when no services are configured."""
|
||||
# No services configured
|
||||
mock_get_services.return_value = {
|
||||
"dropbox": False,
|
||||
"google_drive": False,
|
||||
@@ -162,17 +185,17 @@ class TestFinalizeDocumentStorage:
|
||||
file_id=789,
|
||||
)
|
||||
|
||||
# Should still queue uploads (even if none configured)
|
||||
# Should still queue global uploads (even if none configured)
|
||||
mock_send_all.delay.assert_called_once()
|
||||
|
||||
# Should still send notification
|
||||
mock_notify.assert_called_once()
|
||||
notify_args = mock_notify.call_args[1]
|
||||
# No services configured means empty destinations list
|
||||
assert notify_args["destinations"] == []
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
@@ -181,11 +204,12 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""Test handles exception when getting configured services."""
|
||||
# Simulate exception
|
||||
mock_get_services.side_effect = Exception("Service validation failed")
|
||||
|
||||
mock_db = MagicMock()
|
||||
@@ -204,16 +228,16 @@ class TestFinalizeDocumentStorage:
|
||||
file_id=101,
|
||||
)
|
||||
|
||||
# Should still complete successfully
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
# Should use fallback destinations
|
||||
mock_notify.assert_called_once()
|
||||
notify_args = mock_notify.call_args[1]
|
||||
assert "configured destinations" in notify_args["destinations"]
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
@@ -222,7 +246,9 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""Test handles notification failure gracefully."""
|
||||
@@ -232,7 +258,6 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
# Simulate notification failure
|
||||
mock_notify.side_effect = Exception("Notification service unavailable")
|
||||
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
@@ -247,14 +272,13 @@ class TestFinalizeDocumentStorage:
|
||||
file_id=202,
|
||||
)
|
||||
|
||||
# Should still complete successfully despite notification failure
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
# Should still queue uploads
|
||||
mock_send_all.delay.assert_called_once()
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
@@ -263,7 +287,9 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""Test handles case when processed file doesn't exist."""
|
||||
@@ -273,7 +299,6 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
# File doesn't exist
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=False):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="missing.pdf"):
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
@@ -285,16 +310,16 @@ class TestFinalizeDocumentStorage:
|
||||
file_id=303,
|
||||
)
|
||||
|
||||
# Should still queue uploads (send_to_all handles missing files)
|
||||
mock_send_all.delay.assert_called_once()
|
||||
|
||||
# Notification should use file_size = 0
|
||||
mock_notify.assert_called_once()
|
||||
notify_args = mock_notify.call_args[1]
|
||||
assert notify_args["file_size"] == 0
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
@@ -303,11 +328,12 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""Test that service names are formatted correctly for display."""
|
||||
# Mock services with underscores in names
|
||||
mock_get_services.return_value = {
|
||||
"google_drive": True,
|
||||
"one_drive": True,
|
||||
@@ -330,16 +356,17 @@ class TestFinalizeDocumentStorage:
|
||||
file_id=404,
|
||||
)
|
||||
|
||||
# Verify service names are formatted with spaces and title case
|
||||
mock_notify.assert_called_once()
|
||||
notify_args = mock_notify.call_args[1]
|
||||
destinations = notify_args["destinations"]
|
||||
assert "Google Drive" in destinations
|
||||
assert "One Drive" in destinations
|
||||
assert "Next Cloud" not in destinations # Not configured
|
||||
assert "Next Cloud" not in destinations
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
@@ -348,7 +375,9 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""Test that delete_after flag is correctly passed to send_to_all_destinations."""
|
||||
@@ -370,11 +399,12 @@ class TestFinalizeDocumentStorage:
|
||||
file_id=505,
|
||||
)
|
||||
|
||||
# Verify send_to_all was called with delete_after=True
|
||||
mock_send_all.delay.assert_called_once_with("/workdir/processed/file.pdf", True, 505)
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
@@ -383,17 +413,14 @@ class TestFinalizeDocumentStorage:
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""
|
||||
Regression test: when PDF/A conversion is enabled, the finalize_document_storage
|
||||
step must NOT be logged as in_progress after it has already been logged as success.
|
||||
|
||||
Previously, a second log_task_progress call with status="in_progress" was made for
|
||||
"finalize_document_storage" when queueing PDF/A archival conversion, which overwrote
|
||||
the prior success status and caused the overall file status to appear stuck in
|
||||
processing/failed.
|
||||
"""
|
||||
mock_get_services.return_value = {"dropbox": True}
|
||||
|
||||
@@ -423,18 +450,185 @@ class TestFinalizeDocumentStorage:
|
||||
file_id=606,
|
||||
)
|
||||
|
||||
# Collect all (step_name, status) pairs logged for finalize_document_storage
|
||||
# Collect all logged calls for finalize_document_storage step
|
||||
finalize_calls = [
|
||||
call
|
||||
for call in mock_log_progress.call_args_list
|
||||
if call.args[1] == "finalize_document_storage"
|
||||
c for c in mock_log_progress.call_args_list if c.args[1] == "finalize_document_storage"
|
||||
]
|
||||
|
||||
# After the success log, no in_progress log should follow for this step
|
||||
statuses = [call.args[2] for call in finalize_calls]
|
||||
statuses = [c.args[2] for c in finalize_calls]
|
||||
assert "success" in statuses, "finalize_document_storage must be logged as success"
|
||||
# The last status logged must be success, not in_progress
|
||||
assert statuses[-1] == "success", (
|
||||
"finalize_document_storage must not be regressed to in_progress after success; "
|
||||
f"got statuses: {statuses}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFinalizeDocumentStorageUserRouting:
|
||||
"""Tests for user-specific destination routing in finalize_document_storage."""
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=2)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
def test_routes_to_user_destinations_when_owner_has_integrations(
|
||||
self,
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""When a user has active DESTINATION integrations, use them instead of global config."""
|
||||
mock_get_services.return_value = {"dropbox": True}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_file_record = _make_file_record(100, owner_id="alice@example.com")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=1024):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="doc.pdf"):
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
|
||||
result = finalize_document_storage.__wrapped__(
|
||||
original_file="/tmp/original.pdf",
|
||||
processed_file="/workdir/processed/doc.pdf",
|
||||
metadata={"filename": "doc.pdf"},
|
||||
file_id=100,
|
||||
)
|
||||
|
||||
mock_send_user.delay.assert_called_once_with("/workdir/processed/doc.pdf", "alice@example.com", 100)
|
||||
mock_send_all.delay.assert_not_called()
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
def test_falls_back_to_global_when_owner_has_no_integrations(
|
||||
self,
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""When a user has no active DESTINATION integrations, fall back to global config."""
|
||||
mock_get_services.return_value = {"s3": True}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_file_record = _make_file_record(200, owner_id="bob@example.com")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=2048):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="file.pdf"):
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
|
||||
result = finalize_document_storage.__wrapped__(
|
||||
original_file="/tmp/original.pdf",
|
||||
processed_file="/workdir/processed/file.pdf",
|
||||
metadata={"filename": "file.pdf"},
|
||||
file_id=200,
|
||||
)
|
||||
|
||||
mock_send_all.delay.assert_called_once_with("/workdir/processed/file.pdf", True, 200)
|
||||
mock_send_user.delay.assert_not_called()
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
def test_falls_back_to_global_when_no_owner(
|
||||
self,
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""When a document has no owner (single-user mode), global destinations are used."""
|
||||
mock_get_services.return_value = {"nextcloud": True}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_file_record = _make_file_record(300, owner_id=None)
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=512):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="scan.pdf"):
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
|
||||
result = finalize_document_storage.__wrapped__(
|
||||
original_file="/tmp/original.pdf",
|
||||
processed_file="/workdir/processed/scan.pdf",
|
||||
metadata={"filename": "scan.pdf"},
|
||||
file_id=300,
|
||||
)
|
||||
|
||||
mock_send_all.delay.assert_called_once_with("/workdir/processed/scan.pdf", True, 300)
|
||||
mock_send_user.delay.assert_not_called()
|
||||
# get_user_destination_count must NOT be called when owner_id is None
|
||||
mock_get_dest_count.assert_not_called()
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count")
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
def test_falls_back_to_global_when_count_lookup_fails(
|
||||
self,
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
):
|
||||
"""When get_user_destination_count raises, fall back to global routing gracefully."""
|
||||
mock_get_services.return_value = {"s3": True}
|
||||
mock_get_dest_count.side_effect = Exception("DB connection error")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_file_record = _make_file_record(400, owner_id="charlie@example.com")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=4096):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="file.pdf"):
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
|
||||
result = finalize_document_storage.__wrapped__(
|
||||
original_file="/tmp/original.pdf",
|
||||
processed_file="/workdir/processed/file.pdf",
|
||||
metadata={"filename": "file.pdf"},
|
||||
file_id=400,
|
||||
)
|
||||
|
||||
mock_send_all.delay.assert_called_once_with("/workdir/processed/file.pdf", True, 400)
|
||||
mock_send_user.delay.assert_not_called()
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
"""Unit tests for the per-type upload handler functions in upload_to_user_integration.py.
|
||||
|
||||
Each ``_upload_*`` helper is tested by mocking the relevant third-party library
|
||||
so that tests are fast, hermetic, and free of external network calls.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TASK_ID = "test-handler-task-id"
|
||||
|
||||
|
||||
def _write_file(path, content: bytes = b"PDF content") -> None:
|
||||
"""Write *content* to *path*, creating parent dirs as needed."""
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_dropbox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadDropbox:
|
||||
"""Tests for _upload_dropbox handler."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_dropbox
|
||||
|
||||
return _upload_dropbox(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_missing_credentials(self, tmp_path):
|
||||
"""ValueError raised when app_key, app_secret, or refresh_token is missing."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="app_key"):
|
||||
self._call(fp, {}, {})
|
||||
|
||||
def test_small_file_upload(self, tmp_path):
|
||||
"""Files ≤10 MB are uploaded with files_upload."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp, b"x" * 100)
|
||||
|
||||
mock_dbx_instance = MagicMock()
|
||||
mock_dropbox_files = MagicMock()
|
||||
mock_dropbox_files.WriteMode.overwrite = "overwrite"
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"dropbox": MagicMock(
|
||||
Dropbox=MagicMock(return_value=mock_dbx_instance),
|
||||
files=mock_dropbox_files,
|
||||
)
|
||||
},
|
||||
):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"folder": "/Docs"},
|
||||
{"app_key": "key", "app_secret": "secret", "refresh_token": "rtoken"},
|
||||
)
|
||||
|
||||
mock_dbx_instance.files_upload.assert_called_once()
|
||||
assert result["status"] == "Completed"
|
||||
assert result["dropbox_path"] == "/Docs/doc.pdf"
|
||||
|
||||
def test_large_file_upload_uses_session(self, tmp_path):
|
||||
"""Files >10 MB are uploaded with upload session (chunked)."""
|
||||
fp = str(tmp_path / "large.pdf")
|
||||
# Write 11 MB
|
||||
_write_file(fp, b"x" * (11 * 1024 * 1024))
|
||||
|
||||
mock_dbx_instance = MagicMock()
|
||||
mock_session_start = MagicMock()
|
||||
mock_session_start.session_id = "session-1"
|
||||
mock_dbx_instance.files_upload_session_start.return_value = mock_session_start
|
||||
mock_dbx_instance.files_upload_session_finish.return_value = MagicMock()
|
||||
|
||||
mock_files_mod = MagicMock()
|
||||
mock_files_mod.UploadSessionCursor = MagicMock(return_value=MagicMock(offset=0))
|
||||
mock_files_mod.CommitInfo = MagicMock()
|
||||
mock_files_mod.WriteMode.overwrite = "overwrite"
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"dropbox": MagicMock(
|
||||
Dropbox=MagicMock(return_value=mock_dbx_instance),
|
||||
files=mock_files_mod,
|
||||
)
|
||||
},
|
||||
):
|
||||
result = self._call(
|
||||
fp,
|
||||
{},
|
||||
{"app_key": "k", "app_secret": "s", "refresh_token": "r"},
|
||||
)
|
||||
|
||||
mock_dbx_instance.files_upload_session_start.assert_called_once()
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
def test_default_folder_when_not_specified(self, tmp_path):
|
||||
"""When no folder is configured, the default '/DocuElevate' folder is used."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp, b"x" * 10)
|
||||
|
||||
mock_dbx_instance = MagicMock()
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"dropbox": MagicMock(
|
||||
Dropbox=MagicMock(return_value=mock_dbx_instance),
|
||||
files=MagicMock(WriteMode=MagicMock(overwrite="overwrite")),
|
||||
)
|
||||
},
|
||||
):
|
||||
result = self._call(fp, {}, {"app_key": "k", "app_secret": "s", "refresh_token": "r"})
|
||||
|
||||
assert result["dropbox_path"] == "/DocuElevate/doc.pdf"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_s3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadS3:
|
||||
"""Tests for _upload_s3 handler."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_s3
|
||||
|
||||
return _upload_s3(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_bucket_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="bucket"):
|
||||
self._call(fp, {}, {"access_key_id": "k", "secret_access_key": "s"})
|
||||
|
||||
def test_raises_when_credentials_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="access_key_id"):
|
||||
self._call(fp, {"bucket": "my-bucket"}, {})
|
||||
|
||||
def test_successful_upload(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_boto3 = MagicMock()
|
||||
mock_boto3.client.return_value = mock_s3
|
||||
|
||||
with patch.dict("sys.modules", {"boto3": mock_boto3, "botocore.exceptions": MagicMock(ClientError=Exception)}):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"bucket": "my-bucket", "region": "eu-west-1", "folder_prefix": "docs"},
|
||||
{"access_key_id": "AKIA", "secret_access_key": "secret"},
|
||||
)
|
||||
|
||||
mock_s3.upload_file.assert_called_once()
|
||||
assert result["status"] == "Completed"
|
||||
assert result["s3_bucket"] == "my-bucket"
|
||||
assert result["s3_key"] == "docs/doc.pdf"
|
||||
|
||||
def test_uses_endpoint_url_when_provided(self, tmp_path):
|
||||
"""Custom endpoint_url is passed to boto3.client for S3-compatible stores."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_boto3 = MagicMock()
|
||||
mock_boto3.client.return_value = mock_s3
|
||||
|
||||
with patch.dict("sys.modules", {"boto3": mock_boto3, "botocore.exceptions": MagicMock(ClientError=Exception)}):
|
||||
self._call(
|
||||
fp,
|
||||
{"bucket": "b", "endpoint_url": "https://minio.example.com"},
|
||||
{"access_key_id": "k", "secret_access_key": "s"},
|
||||
)
|
||||
|
||||
call_kwargs = mock_boto3.client.call_args[1]
|
||||
assert call_kwargs.get("endpoint_url") == "https://minio.example.com"
|
||||
|
||||
def test_wraps_client_error_as_runtime_error(self, tmp_path):
|
||||
"""S3 ClientError is re-raised as RuntimeError."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
class FakeClientError(Exception):
|
||||
pass
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_s3.upload_file.side_effect = FakeClientError("Access Denied")
|
||||
mock_boto3 = MagicMock()
|
||||
mock_boto3.client.return_value = mock_s3
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"boto3": mock_boto3, "botocore.exceptions": MagicMock(ClientError=FakeClientError)},
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="S3 upload failed"):
|
||||
self._call(fp, {"bucket": "b"}, {"access_key_id": "k", "secret_access_key": "s"})
|
||||
|
||||
def test_key_without_folder_prefix(self, tmp_path):
|
||||
"""When folder_prefix is empty, the S3 key is just the filename."""
|
||||
fp = str(tmp_path / "report.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_s3 = MagicMock()
|
||||
mock_boto3 = MagicMock()
|
||||
mock_boto3.client.return_value = mock_s3
|
||||
|
||||
with patch.dict("sys.modules", {"boto3": mock_boto3, "botocore.exceptions": MagicMock(ClientError=Exception)}):
|
||||
result = self._call(fp, {"bucket": "b"}, {"access_key_id": "k", "secret_access_key": "s"})
|
||||
|
||||
assert result["s3_key"] == "report.pdf"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_google_drive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadGoogleDrive:
|
||||
"""Tests for _upload_google_drive handler."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_google_drive
|
||||
|
||||
return _upload_google_drive(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_no_credentials(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="OAuth credentials"):
|
||||
self._call(fp, {}, {})
|
||||
|
||||
def test_oauth_upload_calls_drive_api(self, tmp_path):
|
||||
"""OAuth credentials (client_id + client_secret + refresh_token) trigger OAuth flow."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.files.return_value.create.return_value.execute.return_value = {
|
||||
"id": "gdrive-id-123",
|
||||
"webViewLink": "https://drive.google.com/file/d/gdrive-id-123",
|
||||
}
|
||||
|
||||
mock_build = MagicMock(return_value=mock_service)
|
||||
mock_oauth_creds = MagicMock()
|
||||
|
||||
mock_google_oauth2 = MagicMock()
|
||||
mock_google_oauth2.credentials.Credentials = MagicMock(return_value=mock_oauth_creds)
|
||||
mock_google_auth_transport = MagicMock()
|
||||
mock_google_auth_transport.requests.Request = MagicMock()
|
||||
mock_media_upload = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"googleapiclient.discovery": MagicMock(build=mock_build),
|
||||
"googleapiclient.http": MagicMock(MediaFileUpload=mock_media_upload),
|
||||
"google.oauth2.credentials": mock_google_oauth2.credentials,
|
||||
"google.auth.transport.requests": mock_google_auth_transport.requests,
|
||||
"google.oauth2.service_account": MagicMock(),
|
||||
},
|
||||
):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"folder_id": "folder-xyz"},
|
||||
{"client_id": "cid", "client_secret": "csec", "refresh_token": "rtoken"},
|
||||
)
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
assert result["google_drive_file_id"] == "gdrive-id-123"
|
||||
|
||||
def test_service_account_upload(self, tmp_path):
|
||||
"""credentials_json triggers service-account flow."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
sa_creds_json = json.dumps({"type": "service_account", "project_id": "myproject"})
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.files.return_value.create.return_value.execute.return_value = {
|
||||
"id": "sa-file-id",
|
||||
"webViewLink": "https://drive.google.com/file/d/sa-file-id",
|
||||
}
|
||||
|
||||
mock_sa_class = MagicMock()
|
||||
mock_sa_creds = MagicMock()
|
||||
mock_sa_class.from_service_account_info.return_value = mock_sa_creds
|
||||
|
||||
mock_build = MagicMock(return_value=mock_service)
|
||||
mock_media_upload = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"googleapiclient.discovery": MagicMock(build=mock_build),
|
||||
"googleapiclient.http": MagicMock(MediaFileUpload=mock_media_upload),
|
||||
"google.oauth2.credentials": MagicMock(),
|
||||
"google.auth.transport.requests": MagicMock(),
|
||||
"google.oauth2.service_account": MagicMock(Credentials=mock_sa_class),
|
||||
},
|
||||
):
|
||||
result = self._call(fp, {}, {"credentials_json": sa_creds_json})
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_webdav / _upload_nextcloud
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadWebdav:
|
||||
"""Tests for _upload_webdav handler (and Nextcloud which delegates to it)."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_webdav
|
||||
|
||||
return _upload_webdav(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_url_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="url"):
|
||||
self._call(fp, {}, {})
|
||||
|
||||
def test_successful_upload_201(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 201
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.put.return_value = mock_resp
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"url": "https://dav.example.com/dav/", "folder": "Files"},
|
||||
{"username": "user", "password": "pass"},
|
||||
)
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
mock_requests.put.assert_called_once()
|
||||
|
||||
def test_raises_on_non_2xx_response(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 403
|
||||
mock_resp.text = "Forbidden"
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.put.return_value = mock_resp
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with pytest.raises(RuntimeError, match="WebDAV upload failed: 403"):
|
||||
self._call(fp, {"url": "https://dav.example.com/"}, {})
|
||||
|
||||
def test_nextcloud_delegates_to_webdav(self, tmp_path):
|
||||
"""_upload_nextcloud is a thin wrapper over _upload_webdav."""
|
||||
from app.tasks.upload_to_user_integration import _upload_nextcloud
|
||||
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
with patch("app.tasks.upload_to_user_integration._upload_webdav") as mock_webdav:
|
||||
mock_webdav.return_value = {"status": "Completed", "webdav_url": "https://nc.example.com/Files/doc.pdf"}
|
||||
result = _upload_nextcloud(
|
||||
fp, {"url": "https://nc.example.com"}, {"username": "u", "password": "p"}, TASK_ID
|
||||
)
|
||||
|
||||
mock_webdav.assert_called_once_with(
|
||||
fp, {"url": "https://nc.example.com"}, {"username": "u", "password": "p"}, TASK_ID
|
||||
)
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_ftp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadFtp:
|
||||
"""Tests for _upload_ftp handler."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_ftp
|
||||
|
||||
return _upload_ftp(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_host_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="host"):
|
||||
self._call(fp, {}, {"password": "pass"})
|
||||
|
||||
def test_tls_upload(self, tmp_path):
|
||||
"""use_tls=True uses FTP_TLS."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_ftp_tls = MagicMock()
|
||||
mock_ftplib = MagicMock()
|
||||
mock_ftplib.FTP_TLS.return_value = mock_ftp_tls
|
||||
mock_ftplib.error_perm = Exception
|
||||
|
||||
with patch.dict("sys.modules", {"ftplib": mock_ftplib}):
|
||||
with patch("app.tasks.upload_to_user_integration.ftplib", mock_ftplib):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"host": "ftp.example.com", "port": 21, "folder": "/docs", "use_tls": True},
|
||||
{"password": "pass"},
|
||||
)
|
||||
|
||||
mock_ftplib.FTP_TLS.assert_called_once()
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
def test_plaintext_ftp_upload(self, tmp_path):
|
||||
"""use_tls=False uses plain FTP."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_ftp = MagicMock()
|
||||
mock_ftplib = MagicMock()
|
||||
mock_ftplib.FTP.return_value = mock_ftp
|
||||
mock_ftplib.error_perm = Exception
|
||||
|
||||
with patch("app.tasks.upload_to_user_integration.ftplib", mock_ftplib):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"host": "ftp.example.com", "use_tls": False},
|
||||
{"password": "pass"},
|
||||
)
|
||||
|
||||
mock_ftplib.FTP.assert_called_once()
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
def test_creates_folder_if_cwd_fails(self, tmp_path):
|
||||
"""When cwd raises error_perm, the handler creates the directory."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
class FtpPermError(Exception):
|
||||
pass
|
||||
|
||||
# cwd call sequence:
|
||||
# 1. ftp.cwd("uploads") → fails (outer try, folder_stripped="uploads")
|
||||
# 2. ftp.cwd("/uploads") → fails (inner loop, triggers mkd)
|
||||
# 3. ftp.cwd("/uploads") after mkd → succeeds
|
||||
mock_ftp = MagicMock()
|
||||
mock_ftp.cwd.side_effect = [FtpPermError("no"), FtpPermError("no"), None]
|
||||
mock_ftplib = MagicMock()
|
||||
mock_ftplib.FTP.return_value = mock_ftp
|
||||
mock_ftplib.error_perm = FtpPermError
|
||||
|
||||
with patch("app.tasks.upload_to_user_integration.ftplib", mock_ftplib):
|
||||
self._call(
|
||||
fp,
|
||||
{"host": "ftp.example.com", "folder": "/uploads", "use_tls": False},
|
||||
{"password": "p"},
|
||||
)
|
||||
|
||||
mock_ftp.mkd.assert_called_with("/uploads")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_sftp
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadSftp:
|
||||
"""Tests for _upload_sftp handler."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_sftp
|
||||
|
||||
return _upload_sftp(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_host_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="host"):
|
||||
self._call(fp, {}, {"password": "p"})
|
||||
|
||||
def test_raises_when_no_auth(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="password or private_key"):
|
||||
self._call(fp, {"host": "sftp.example.com"}, {})
|
||||
|
||||
def test_password_auth(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_ssh = MagicMock()
|
||||
mock_sftp = MagicMock()
|
||||
mock_ssh.open_sftp.return_value = mock_sftp
|
||||
|
||||
mock_paramiko = MagicMock()
|
||||
mock_paramiko.SSHClient.return_value = mock_ssh
|
||||
mock_paramiko.RejectPolicy = MagicMock
|
||||
|
||||
with patch.dict("sys.modules", {"paramiko": mock_paramiko}):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"host": "sftp.example.com", "username": "user", "folder": "/uploads"},
|
||||
{"password": "pass"},
|
||||
)
|
||||
|
||||
mock_sftp.put.assert_called_once()
|
||||
assert result["status"] == "Completed"
|
||||
assert result["sftp_host"] == "sftp.example.com"
|
||||
|
||||
def test_private_key_auth(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_ssh = MagicMock()
|
||||
mock_sftp = MagicMock()
|
||||
mock_ssh.open_sftp.return_value = mock_sftp
|
||||
mock_pkey = MagicMock()
|
||||
|
||||
mock_paramiko = MagicMock()
|
||||
mock_paramiko.SSHClient.return_value = mock_ssh
|
||||
mock_paramiko.RSAKey.from_private_key.return_value = mock_pkey
|
||||
mock_paramiko.RejectPolicy = MagicMock
|
||||
|
||||
with patch.dict("sys.modules", {"paramiko": mock_paramiko}):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"host": "sftp.example.com", "username": "user"},
|
||||
{"private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"},
|
||||
)
|
||||
|
||||
mock_paramiko.RSAKey.from_private_key.assert_called_once()
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_paperless
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadPaperless:
|
||||
"""Tests for _upload_paperless handler."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_paperless
|
||||
|
||||
return _upload_paperless(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_host_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="host"):
|
||||
self._call(fp, {}, {"api_token": "tok"})
|
||||
|
||||
def test_raises_when_api_token_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="api_token"):
|
||||
self._call(fp, {"host": "https://paperless.example.com"}, {})
|
||||
|
||||
def test_successful_upload_polls_to_success(self, tmp_path):
|
||||
"""Document is uploaded and task polling returns SUCCESS."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
# POST response
|
||||
mock_post_resp = MagicMock()
|
||||
mock_post_resp.text = '"task-uuid-123"'
|
||||
|
||||
# Poll response showing SUCCESS
|
||||
mock_poll_resp = MagicMock()
|
||||
mock_poll_resp.json.return_value = [{"status": "SUCCESS", "related_document": 42}]
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.post.return_value = mock_post_resp
|
||||
mock_requests.get.return_value = mock_poll_resp
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with patch("time.sleep", return_value=None):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"host": "https://paperless.example.com"},
|
||||
{"api_token": "tok-abc"},
|
||||
)
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
assert result["paperless_document_id"] == 42
|
||||
|
||||
def test_raises_when_paperless_task_fails(self, tmp_path):
|
||||
"""RuntimeError is raised when Paperless processing status is FAILURE."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_post_resp = MagicMock()
|
||||
mock_post_resp.text = '"task-uuid-999"'
|
||||
|
||||
mock_poll_resp = MagicMock()
|
||||
mock_poll_resp.json.return_value = [{"status": "FAILURE", "result": "OCR failed"}]
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.post.return_value = mock_post_resp
|
||||
mock_requests.get.return_value = mock_poll_resp
|
||||
|
||||
with patch.dict("sys.modules", {"requests": mock_requests}):
|
||||
with patch("time.sleep", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="Paperless processing failed"):
|
||||
self._call(
|
||||
fp,
|
||||
{"host": "https://paperless.example.com"},
|
||||
{"api_token": "tok"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadEmail:
|
||||
"""Tests for _upload_email handler."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_email
|
||||
|
||||
return _upload_email(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_host_or_recipient_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="host or recipient"):
|
||||
self._call(fp, {}, {})
|
||||
|
||||
def test_tls_email_sent(self, tmp_path):
|
||||
"""use_tls=True invokes starttls() with ssl context."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_smtp_instance = MagicMock()
|
||||
mock_smtp_class = MagicMock(return_value=mock_smtp_instance)
|
||||
mock_smtp_instance.__enter__ = MagicMock(return_value=mock_smtp_instance)
|
||||
mock_smtp_instance.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_ssl_ctx = MagicMock()
|
||||
mock_ssl = MagicMock()
|
||||
mock_ssl.create_default_context.return_value = mock_ssl_ctx
|
||||
|
||||
with patch("smtplib.SMTP", mock_smtp_class):
|
||||
with patch("ssl.create_default_context", return_value=mock_ssl_ctx):
|
||||
result = self._call(
|
||||
fp,
|
||||
{
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "u@ex.com",
|
||||
"recipient": "r@ex.com",
|
||||
"use_tls": True,
|
||||
},
|
||||
{"password": "pass"},
|
||||
)
|
||||
|
||||
mock_smtp_instance.starttls.assert_called_once_with(context=mock_ssl_ctx)
|
||||
assert result["status"] == "Completed"
|
||||
assert result["recipient"] == "r@ex.com"
|
||||
|
||||
def test_plaintext_smtp_skips_starttls(self, tmp_path):
|
||||
"""use_tls=False sends without starttls()."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_smtp_instance = MagicMock()
|
||||
mock_smtp_class = MagicMock(return_value=mock_smtp_instance)
|
||||
mock_smtp_instance.__enter__ = MagicMock(return_value=mock_smtp_instance)
|
||||
mock_smtp_instance.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("smtplib.SMTP", mock_smtp_class):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"host": "smtp.example.com", "recipient": "r@ex.com", "use_tls": False},
|
||||
{},
|
||||
)
|
||||
|
||||
mock_smtp_instance.starttls.assert_not_called()
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_rclone
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadRclone:
|
||||
"""Tests for _upload_rclone handler."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_rclone
|
||||
|
||||
return _upload_rclone(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_remote_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="remote"):
|
||||
self._call(fp, {}, {"rclone_conf": "[myremote]\ntype = s3\n"})
|
||||
|
||||
def test_raises_when_conf_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="rclone_conf"):
|
||||
self._call(fp, {"remote": "myremote:"}, {})
|
||||
|
||||
def test_raises_when_remote_unsafe(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="unsafe characters"):
|
||||
self._call(fp, {"remote": "my;remote:"}, {"rclone_conf": "[x]\ntype=s3\n"})
|
||||
|
||||
def test_raises_when_folder_unsafe(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="unsafe characters"):
|
||||
self._call(fp, {"remote": "myremote:", "folder": "docs;rm -rf /"}, {"rclone_conf": "[x]\ntype=s3\n"})
|
||||
|
||||
def test_successful_rclone_copy(self, tmp_path):
|
||||
"""rclone process is called with correct arguments and temp config file."""
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
|
||||
with patch("app.tasks.upload_to_user_integration.subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = self._call(
|
||||
fp,
|
||||
{"remote": "myremote:", "folder": "docs"},
|
||||
{"rclone_conf": "[myremote]\ntype = s3\n"},
|
||||
)
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
# Verify subprocess.run was called with rclone command
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[0] == "rclone"
|
||||
assert cmd[1] == "copyto"
|
||||
|
||||
def test_raises_on_rclone_nonzero_exit(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stderr = "rclone: command not found"
|
||||
|
||||
with patch("app.tasks.upload_to_user_integration.subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(RuntimeError, match="rclone exited 1"):
|
||||
self._call(fp, {"remote": "myremote:"}, {"rclone_conf": "[myremote]\ntype=s3\n"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_onedrive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadOneDrive:
|
||||
"""Tests for _upload_onedrive handler."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_onedrive
|
||||
|
||||
return _upload_onedrive(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_client_credentials_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="client_id or client_secret"):
|
||||
self._call(fp, {}, {})
|
||||
|
||||
def test_raises_when_token_acquisition_fails(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_msal_app = MagicMock()
|
||||
mock_msal_app.acquire_token_for_client.return_value = {
|
||||
"error": "invalid_client",
|
||||
"error_description": "AADSTS70011",
|
||||
}
|
||||
mock_msal = MagicMock()
|
||||
mock_msal.ConfidentialClientApplication.return_value = mock_msal_app
|
||||
|
||||
with patch.dict("sys.modules", {"msal": mock_msal, "requests": MagicMock()}):
|
||||
with pytest.raises(ValueError, match="token acquisition failed"):
|
||||
self._call(fp, {}, {"client_id": "cid", "client_secret": "csec"})
|
||||
|
||||
def test_successful_upload_with_refresh_token(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp, b"x" * 100)
|
||||
|
||||
mock_msal_app = MagicMock()
|
||||
mock_msal_app.acquire_token_by_refresh_token.return_value = {"access_token": "tok-abc"}
|
||||
mock_msal = MagicMock()
|
||||
mock_msal.ConfidentialClientApplication.return_value = mock_msal_app
|
||||
|
||||
# Mock POST (create upload session) and PUT (chunk upload)
|
||||
mock_post_resp = MagicMock()
|
||||
mock_post_resp.json.return_value = {"uploadUrl": "https://upload.example.com/session"}
|
||||
mock_put_resp = MagicMock()
|
||||
mock_put_resp.status_code = 201
|
||||
|
||||
mock_requests = MagicMock()
|
||||
mock_requests.post.return_value = mock_post_resp
|
||||
mock_requests.put.return_value = mock_put_resp
|
||||
|
||||
with patch.dict("sys.modules", {"msal": mock_msal, "requests": mock_requests}):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"folder_path": "Documents/DocuElevate", "tenant_id": "my-tenant"},
|
||||
{"client_id": "cid", "client_secret": "csec", "refresh_token": "rtoken"},
|
||||
)
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# finalize_document_storage - uncovered branches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFinalizeDocumentStorageUncoveredBranches:
|
||||
"""Cover branches in finalize_document_storage not exercised by the main test class."""
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
def test_resolves_owner_id_when_file_id_none(
|
||||
self,
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
tmp_path,
|
||||
):
|
||||
"""When file_id is None, the task looks up the FileRecord by local_filename."""
|
||||
from app.tasks.finalize_document_storage import finalize_document_storage
|
||||
|
||||
processed_file = str(tmp_path / "processed" / "doc.pdf")
|
||||
original_file = str(tmp_path / "original" / "orig.pdf")
|
||||
os.makedirs(os.path.dirname(processed_file), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(original_file), exist_ok=True)
|
||||
_write_file(processed_file)
|
||||
_write_file(original_file)
|
||||
|
||||
mock_file_record = MagicMock()
|
||||
mock_file_record.id = 77
|
||||
mock_file_record.owner_id = "owner@example.com"
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
mock_get_services.return_value = {}
|
||||
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
finalize_document_storage.__wrapped__(
|
||||
original_file=original_file,
|
||||
processed_file=processed_file,
|
||||
metadata={},
|
||||
file_id=None,
|
||||
)
|
||||
|
||||
# The function should have queried the DB for the file record
|
||||
mock_db.query.assert_called()
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count")
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
def test_routes_to_global_when_count_query_raises(
|
||||
self,
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify,
|
||||
tmp_path,
|
||||
):
|
||||
"""When get_user_destination_count raises, falls back to global routing."""
|
||||
from app.tasks.finalize_document_storage import finalize_document_storage
|
||||
|
||||
processed_file = str(tmp_path / "processed2" / "doc.pdf")
|
||||
original_file = str(tmp_path / "original2" / "orig.pdf")
|
||||
os.makedirs(os.path.dirname(processed_file), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(original_file), exist_ok=True)
|
||||
_write_file(processed_file)
|
||||
_write_file(original_file)
|
||||
|
||||
mock_file_record = MagicMock()
|
||||
mock_file_record.id = 88
|
||||
mock_file_record.owner_id = "owner@example.com"
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
mock_get_services.return_value = {}
|
||||
mock_get_dest_count.side_effect = Exception("DB connection error")
|
||||
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
finalize_document_storage.__wrapped__(
|
||||
original_file=original_file,
|
||||
processed_file=processed_file,
|
||||
metadata={},
|
||||
file_id=88,
|
||||
)
|
||||
|
||||
# Falls back to global since count raised
|
||||
mock_send_all.delay.assert_called_once()
|
||||
mock_send_user.delay.assert_not_called()
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Unit tests for app/tasks/upload_to_user_integration.py and related helpers."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_integration(
|
||||
int_id: int = 1,
|
||||
int_type=None,
|
||||
owner_id: str = "user@example.com",
|
||||
name: str = "My Integration",
|
||||
config_dict: dict | None = None,
|
||||
creds_dict: dict | None = None,
|
||||
):
|
||||
"""Build a MagicMock resembling a UserIntegration row."""
|
||||
from app.models import IntegrationDirection, IntegrationType
|
||||
|
||||
if int_type is None:
|
||||
int_type = IntegrationType.S3
|
||||
rec = MagicMock()
|
||||
rec.id = int_id
|
||||
rec.integration_type = int_type
|
||||
rec.owner_id = owner_id
|
||||
rec.name = name
|
||||
rec.config = json.dumps(config_dict or {})
|
||||
rec.credentials = json.dumps(creds_dict or {}) # plain JSON in tests (not encrypted)
|
||||
rec.is_active = True
|
||||
rec.direction = IntegrationDirection.DESTINATION
|
||||
# Prevent last_used_at / last_error from being MagicMock initially
|
||||
rec.last_used_at = None
|
||||
rec.last_error = None
|
||||
return rec
|
||||
|
||||
|
||||
def _run_upload_task(file_path: str, integration_id: int, file_id: int | None = None):
|
||||
"""Call the upload_to_user_integration task's __wrapped__ function directly."""
|
||||
from app.tasks.upload_to_user_integration import upload_to_user_integration
|
||||
|
||||
upload_to_user_integration.request.id = "test-task-id"
|
||||
return upload_to_user_integration.__wrapped__(
|
||||
file_path=file_path,
|
||||
integration_id=integration_id,
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for upload_to_user_integration task
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadToUserIntegration:
|
||||
"""Unit tests for the upload_to_user_integration Celery task."""
|
||||
|
||||
@patch("app.tasks.upload_to_user_integration.log_task_progress")
|
||||
@patch("app.tasks.upload_to_user_integration.SessionLocal")
|
||||
def test_raises_file_not_found(self, mock_session_local, mock_log_progress):
|
||||
"""FileNotFoundError is raised when the file does not exist."""
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
_run_upload_task("/nonexistent/file.pdf", integration_id=1)
|
||||
|
||||
@patch("app.tasks.upload_to_user_integration.log_task_progress")
|
||||
@patch("app.tasks.upload_to_user_integration.SessionLocal")
|
||||
def test_raises_value_error_when_integration_not_found(self, mock_session_local, mock_log_progress, tmp_path):
|
||||
"""ValueError is raised when the integration record does not exist."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF content")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
_run_upload_task(str(test_file), integration_id=99)
|
||||
|
||||
@patch("app.tasks.upload_to_user_integration.log_task_progress")
|
||||
@patch("app.tasks.upload_to_user_integration.SessionLocal")
|
||||
def test_skips_imap_source_type(self, mock_session_local, mock_log_progress, tmp_path):
|
||||
"""Integration types with no registered handler return status='Skipped'."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF content")
|
||||
|
||||
from app.models import IntegrationType
|
||||
|
||||
integration = _make_integration(int_id=2, int_type=IntegrationType.IMAP)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = integration
|
||||
|
||||
result = _run_upload_task(str(test_file), integration_id=2)
|
||||
|
||||
assert result["status"] == "Skipped"
|
||||
|
||||
@patch("app.tasks.upload_to_user_integration.log_task_progress")
|
||||
@patch("app.tasks.upload_to_user_integration.SessionLocal")
|
||||
def test_dispatches_to_correct_handler(self, mock_session_local, mock_log_progress, tmp_path):
|
||||
"""The correct handler is called for a given integration type."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF content")
|
||||
|
||||
from app.models import IntegrationType
|
||||
from app.tasks.upload_to_user_integration import _UPLOAD_HANDLERS
|
||||
|
||||
integration = _make_integration(
|
||||
int_id=3,
|
||||
int_type=IntegrationType.S3,
|
||||
config_dict={"bucket": "my-bucket", "region": "us-east-1"},
|
||||
creds_dict={"access_key_id": "AKI...", "secret_access_key": "secret"},
|
||||
)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = integration
|
||||
|
||||
mock_handler = MagicMock(return_value={"status": "Completed", "s3_key": "doc.pdf"})
|
||||
|
||||
with patch.dict(_UPLOAD_HANDLERS, {IntegrationType.S3: mock_handler}):
|
||||
result = _run_upload_task(str(test_file), integration_id=3, file_id=42)
|
||||
|
||||
mock_handler.assert_called_once()
|
||||
call_args = mock_handler.call_args[0]
|
||||
assert call_args[0] == str(test_file) # file_path
|
||||
assert call_args[1]["bucket"] == "my-bucket" # cfg
|
||||
assert call_args[2]["access_key_id"] == "AKI..." # creds
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
@patch("app.tasks.upload_to_user_integration.log_task_progress")
|
||||
@patch("app.tasks.upload_to_user_integration.SessionLocal")
|
||||
def test_persists_last_used_at_on_success(self, mock_session_local, mock_log_progress, tmp_path):
|
||||
"""On success, last_used_at is updated and last_error is cleared."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF content")
|
||||
|
||||
from app.models import IntegrationType
|
||||
from app.tasks.upload_to_user_integration import _UPLOAD_HANDLERS
|
||||
|
||||
integration = _make_integration(int_id=4, int_type=IntegrationType.S3)
|
||||
integration.last_error = "previous error"
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = integration
|
||||
|
||||
mock_handler = MagicMock(return_value={"status": "Completed"})
|
||||
|
||||
with patch.dict(_UPLOAD_HANDLERS, {IntegrationType.S3: mock_handler}):
|
||||
result = _run_upload_task(str(test_file), integration_id=4, file_id=10)
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
assert integration.last_used_at is not None
|
||||
assert integration.last_error is None
|
||||
|
||||
@patch("app.tasks.upload_to_user_integration.log_task_progress")
|
||||
@patch("app.tasks.upload_to_user_integration.SessionLocal")
|
||||
def test_persists_error_and_reraises_on_failure(self, mock_session_local, mock_log_progress, tmp_path):
|
||||
"""On failure, last_error is persisted on the integration and the exception is re-raised."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF content")
|
||||
|
||||
from app.models import IntegrationType
|
||||
from app.tasks.upload_to_user_integration import _UPLOAD_HANDLERS
|
||||
|
||||
integration = _make_integration(int_id=5, int_type=IntegrationType.DROPBOX)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = integration
|
||||
|
||||
mock_handler = MagicMock(side_effect=RuntimeError("Dropbox token expired"))
|
||||
|
||||
with patch.dict(_UPLOAD_HANDLERS, {IntegrationType.DROPBOX: mock_handler}):
|
||||
with pytest.raises(RuntimeError, match="Dropbox token expired"):
|
||||
_run_upload_task(str(test_file), integration_id=5, file_id=20)
|
||||
|
||||
assert integration.last_error == "Dropbox token expired"
|
||||
|
||||
@patch("app.tasks.upload_to_user_integration.log_task_progress")
|
||||
@patch("app.tasks.upload_to_user_integration.SessionLocal")
|
||||
def test_invalid_config_json_raises_value_error(self, mock_session_local, mock_log_progress, tmp_path):
|
||||
"""ValueError is raised when integration.config contains invalid JSON."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF content")
|
||||
|
||||
from app.models import IntegrationType
|
||||
|
||||
integration = _make_integration(int_id=6, int_type=IntegrationType.S3)
|
||||
integration.config = "NOT JSON" # corrupt config
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = integration
|
||||
|
||||
with pytest.raises(ValueError, match="invalid JSON in config"):
|
||||
_run_upload_task(str(test_file), integration_id=6)
|
||||
|
||||
@patch("app.tasks.upload_to_user_integration.log_task_progress")
|
||||
@patch("app.tasks.upload_to_user_integration.decrypt_value")
|
||||
@patch("app.tasks.upload_to_user_integration.SessionLocal")
|
||||
def test_uses_decrypt_value_for_credentials(self, mock_session_local, mock_decrypt, mock_log_progress, tmp_path):
|
||||
"""credentials are decrypted using decrypt_value before being parsed as JSON."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF content")
|
||||
|
||||
from app.models import IntegrationType
|
||||
from app.tasks.upload_to_user_integration import _UPLOAD_HANDLERS
|
||||
|
||||
integration = _make_integration(
|
||||
int_id=7,
|
||||
int_type=IntegrationType.S3,
|
||||
config_dict={"bucket": "b", "region": "eu-west-1"},
|
||||
)
|
||||
# Simulate encrypted credentials stored in DB
|
||||
integration.credentials = "enc:encrypted-value"
|
||||
# decrypt_value should return plain JSON
|
||||
mock_decrypt.return_value = json.dumps({"access_key_id": "AKI...", "secret_access_key": "S"})
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = integration
|
||||
|
||||
mock_handler = MagicMock(return_value={"status": "Completed"})
|
||||
|
||||
with patch.dict(_UPLOAD_HANDLERS, {IntegrationType.S3: mock_handler}):
|
||||
_run_upload_task(str(test_file), integration_id=7)
|
||||
|
||||
mock_decrypt.assert_called_once_with("enc:encrypted-value")
|
||||
# Handler should receive decrypted credentials
|
||||
_, _, creds, _ = mock_handler.call_args[0]
|
||||
assert creds["access_key_id"] == "AKI..."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for send_to_user_destinations task
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSendToUserDestinations:
|
||||
"""Unit tests for the send_to_user_destinations Celery task."""
|
||||
|
||||
def _run_task(self, file_path: str, owner_id: str, file_id: int | None = None):
|
||||
"""Call the task's __wrapped__ function directly."""
|
||||
from app.tasks.send_to_all import send_to_user_destinations
|
||||
|
||||
send_to_user_destinations.request.id = "test-task-id"
|
||||
return send_to_user_destinations.__wrapped__(
|
||||
file_path=file_path,
|
||||
owner_id=owner_id,
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
@patch("app.tasks.send_to_all.log_task_progress")
|
||||
@patch("app.tasks.send_to_all.SessionLocal")
|
||||
def test_raises_file_not_found(self, mock_session_local, mock_log_progress):
|
||||
"""FileNotFoundError is raised when file does not exist."""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
self._run_task("/nonexistent/file.pdf", owner_id="user@example.com")
|
||||
|
||||
@patch("app.tasks.send_to_all.log_task_progress")
|
||||
@patch("app.tasks.send_to_all.SessionLocal")
|
||||
def test_returns_zero_when_no_integrations(self, mock_session_local, mock_log_progress, tmp_path):
|
||||
"""Returns queued=0 when there are no active DESTINATION integrations."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = []
|
||||
|
||||
result = self._run_task(str(test_file), owner_id="nobody@example.com", file_id=1)
|
||||
|
||||
assert result["queued"] == 0
|
||||
assert result["status"] == "Queued"
|
||||
|
||||
@patch("app.tasks.send_to_all.log_task_progress")
|
||||
@patch("app.tasks.send_to_all.SessionLocal")
|
||||
def test_dispatches_one_task_per_integration(self, mock_session_local, mock_log_progress, tmp_path):
|
||||
"""One upload_to_user_integration.delay call is made per active DESTINATION integration."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF")
|
||||
|
||||
from app.models import IntegrationType
|
||||
|
||||
int1 = _make_integration(int_id=10, int_type=IntegrationType.S3, name="S3 Backup")
|
||||
int2 = _make_integration(int_id=11, int_type=IntegrationType.DROPBOX, name="Dropbox")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [int1, int2]
|
||||
|
||||
mock_celery_task = MagicMock()
|
||||
mock_celery_task.delay.return_value = MagicMock(id="celery-task-id")
|
||||
|
||||
# The lazy import inside send_to_user_destinations uses:
|
||||
# "from app.tasks.upload_to_user_integration import upload_to_user_integration"
|
||||
# We must patch at the source module so the local import picks up the mock.
|
||||
with patch(
|
||||
"app.tasks.upload_to_user_integration.upload_to_user_integration",
|
||||
mock_celery_task,
|
||||
):
|
||||
result = self._run_task(str(test_file), owner_id="user@example.com", file_id=99)
|
||||
|
||||
assert result["queued"] == 2
|
||||
assert mock_celery_task.delay.call_count == 2
|
||||
# Verify correct arguments
|
||||
mock_celery_task.delay.assert_any_call(str(test_file), 10, 99)
|
||||
mock_celery_task.delay.assert_any_call(str(test_file), 11, 99)
|
||||
|
||||
@patch("app.tasks.send_to_all.log_task_progress")
|
||||
@patch("app.tasks.send_to_all.SessionLocal")
|
||||
def test_continues_on_individual_dispatch_failure(self, mock_session_local, mock_log_progress, tmp_path):
|
||||
"""If queuing one integration fails, the others are still queued."""
|
||||
test_file = tmp_path / "doc.pdf"
|
||||
test_file.write_bytes(b"PDF")
|
||||
|
||||
from app.models import IntegrationType
|
||||
|
||||
int1 = _make_integration(int_id=20, int_type=IntegrationType.S3, name="S3")
|
||||
int2 = _make_integration(int_id=21, int_type=IntegrationType.DROPBOX, name="Dropbox")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.all.return_value = [int1, int2]
|
||||
|
||||
mock_celery_task = MagicMock()
|
||||
# First call fails, second succeeds
|
||||
mock_celery_task.delay.side_effect = [RuntimeError("connection refused"), MagicMock(id="ok")]
|
||||
|
||||
with patch(
|
||||
"app.tasks.upload_to_user_integration.upload_to_user_integration",
|
||||
mock_celery_task,
|
||||
):
|
||||
result = self._run_task(str(test_file), owner_id="user@example.com", file_id=50)
|
||||
|
||||
# Only 1 successfully queued (the second one)
|
||||
assert result["queued"] == 1
|
||||
assert "integration_20_error" in result["tasks"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for get_user_destination_count helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetUserDestinationCount:
|
||||
"""Unit tests for the get_user_destination_count helper function."""
|
||||
|
||||
@patch("app.tasks.send_to_all.SessionLocal")
|
||||
def test_returns_count_from_db(self, mock_session_local):
|
||||
"""Returns the number of active DESTINATION integrations for an owner."""
|
||||
from app.tasks.send_to_all import get_user_destination_count
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.count.return_value = 3
|
||||
|
||||
assert get_user_destination_count("user@example.com") == 3
|
||||
|
||||
@patch("app.tasks.send_to_all.SessionLocal")
|
||||
def test_returns_zero_when_no_integrations(self, mock_session_local):
|
||||
"""Returns 0 when no active DESTINATION integrations are configured."""
|
||||
from app.tasks.send_to_all import get_user_destination_count
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_db.query.return_value.filter.return_value.count.return_value = 0
|
||||
|
||||
assert get_user_destination_count("empty@example.com") == 0
|
||||
Reference in New Issue
Block a user