From 3ac5a97467d3ebae703b31d689bde2c4ca18468d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 8 Mar 2026 12:19:29 +0000
Subject: [PATCH] test: increase watch_folder_tasks coverage from 37% to 98%
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
---
tests/test_watch_folder_tasks.py | 2845 ++++++++++++++++++++++++++++++
1 file changed, 2845 insertions(+)
diff --git a/tests/test_watch_folder_tasks.py b/tests/test_watch_folder_tasks.py
index b08caec2..56e7f300 100644
--- a/tests/test_watch_folder_tasks.py
+++ b/tests/test_watch_folder_tasks.py
@@ -1,6 +1,7 @@
"""Tests for app/tasks/watch_folder_tasks.py module."""
import os
+import stat
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
@@ -777,3 +778,2847 @@ class TestScanAllWatchFoldersCloud:
assert result["status"] == "ok"
for name in provider_tasks:
mocks[name].assert_called_once()
+
+
+# ===========================================================================
+# Additional coverage tests
+# ===========================================================================
+
+
+@pytest.mark.unit
+class TestSaveCacheErrors:
+ """Tests for _save_cache error handling."""
+
+ def test_save_cache_logs_error_on_os_error(self, tmp_path):
+ """_save_cache should log an error when it cannot write the file."""
+ from app.tasks.watch_folder_tasks import _save_cache
+
+ # Use a path where the parent directory does not exist
+ bad_path = str(tmp_path / "nonexistent_dir" / "cache.json")
+ # Should not raise — just log
+ _save_cache(bad_path, {"key": "val"})
+
+
+@pytest.mark.unit
+class TestEvictOldEntriesTimezoneNaive:
+ """Additional eviction edge cases."""
+
+ def test_evict_keeps_timezone_naive_recent_entry(self):
+ """Timezone-naive timestamps should be treated as UTC and kept if recent."""
+ from app.tasks.watch_folder_tasks import _evict_old_entries
+
+ # Naive datetime string (no +00:00), recent
+ naive_recent = datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
+ result = _evict_old_entries({"recent_naive": naive_recent})
+ assert "recent_naive" in result
+
+ def test_evict_removes_timezone_naive_old_entry(self):
+ """Old timezone-naive entries should be evicted."""
+ from app.tasks.watch_folder_tasks import _evict_old_entries
+
+ old_naive = (datetime.now(timezone.utc) - timedelta(days=60)).replace(tzinfo=None).isoformat()
+ result = _evict_old_entries({"old_naive": old_naive})
+ assert "old_naive" not in result
+
+
+@pytest.mark.unit
+class TestEnqueueFileNonPdf:
+ """Tests for _enqueue_file with non-PDF files."""
+
+ def test_non_pdf_triggers_convert_to_pdf(self):
+ """Non-PDF files should be dispatched to convert_to_pdf."""
+ from app.tasks.watch_folder_tasks import _enqueue_file
+
+ with (
+ patch("app.tasks.watch_folder_tasks.convert_to_pdf") as mock_conv,
+ patch("app.tasks.watch_folder_tasks.process_document") as mock_proc,
+ ):
+ _enqueue_file("/tmp/doc.docx")
+ mock_conv.delay.assert_called_once_with("/tmp/doc.docx")
+ mock_proc.delay.assert_not_called()
+
+ def test_pdf_triggers_process_document(self):
+ """PDF files should be dispatched to process_document."""
+ from app.tasks.watch_folder_tasks import _enqueue_file
+
+ with (
+ patch("app.tasks.watch_folder_tasks.convert_to_pdf") as mock_conv,
+ patch("app.tasks.watch_folder_tasks.process_document") as mock_proc,
+ ):
+ _enqueue_file("/tmp/report.pdf")
+ mock_proc.delay.assert_called_once_with("/tmp/report.pdf")
+ mock_conv.delay.assert_not_called()
+
+ def test_pdf_detected_via_filename_param(self):
+ """When filename kwarg ends with .pdf, process_document should be called."""
+ from app.tasks.watch_folder_tasks import _enqueue_file
+
+ with (
+ patch("app.tasks.watch_folder_tasks.convert_to_pdf") as mock_conv,
+ patch("app.tasks.watch_folder_tasks.process_document") as mock_proc,
+ ):
+ _enqueue_file("/tmp/somefile", filename="renamed.pdf")
+ mock_proc.delay.assert_called_once_with("/tmp/somefile")
+ mock_conv.delay.assert_not_called()
+
+
+@pytest.mark.unit
+class TestScanLocalFolderEdgeCases:
+ """Additional edge cases for _scan_local_folder."""
+
+ def test_permission_error_returns_zero(self, tmp_path):
+ """PermissionError during scandir should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_local_folder
+
+ with patch("os.scandir", side_effect=PermissionError("denied")):
+ count = _scan_local_folder(str(tmp_path), {}, False)
+ assert count == 0
+
+ def test_copy_oserror_skips_file(self, tmp_path):
+ """If shutil.copy2 raises OSError, the file should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_local_folder
+
+ pdf_file = tmp_path / "fail.pdf"
+ pdf_file.write_bytes(b"%PDF-1.4")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("shutil.copy2", side_effect=OSError("disk full")),
+ ):
+ mock_settings.workdir = str(tmp_path / "workdir")
+ os.makedirs(mock_settings.workdir, exist_ok=True)
+ count = _scan_local_folder(str(tmp_path), cache, False)
+
+ assert count == 0
+ assert str(pdf_file) not in cache
+
+ def test_file_collision_generates_unique_name(self, tmp_path):
+ """When dest file already exists, a timestamped name should be used."""
+ from app.tasks.watch_folder_tasks import _scan_local_folder
+
+ pdf_file = tmp_path / "doc.pdf"
+ pdf_file.write_bytes(b"%PDF-1.4")
+
+ workdir = tmp_path / "workdir"
+ workdir.mkdir()
+ # Pre-create the destination to force the collision path
+ (workdir / "wf_doc.pdf").write_bytes(b"old")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.workdir = str(workdir)
+ count = _scan_local_folder(str(tmp_path), cache, False)
+
+ assert count == 1
+
+ def test_delete_after_oserror_does_not_crash(self, tmp_path):
+ """If os.remove raises OSError during delete_after, it should be logged but not crash."""
+ from app.tasks.watch_folder_tasks import _scan_local_folder
+
+ pdf_file = tmp_path / "todelete.pdf"
+ pdf_file.write_bytes(b"%PDF-1.4")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("os.remove", side_effect=OSError("busy")),
+ ):
+ mock_settings.workdir = str(tmp_path / "workdir")
+ os.makedirs(mock_settings.workdir, exist_ok=True)
+ # Bypass the shutil.copy2 to avoid hitting the real copy
+ with patch("shutil.copy2"):
+ count = _scan_local_folder(str(tmp_path), cache, delete_after=True)
+
+ assert count == 1
+
+
+@pytest.mark.unit
+class TestConnectFtpTlsFallback:
+ """Tests for the FTP TLS/plaintext fallback logic in _connect_ftp."""
+
+ def test_tls_success(self):
+ """When TLS succeeds, the FTP_TLS object should be returned."""
+ from app.tasks.watch_folder_tasks import _connect_ftp
+
+ mock_ftp_tls = MagicMock()
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("ftplib.FTP_TLS", return_value=mock_ftp_tls),
+ ):
+ mock_settings.ftp_host = "ftp.example.com"
+ mock_settings.ftp_port = 21
+ mock_settings.ftp_username = "user"
+ mock_settings.ftp_password = "pass" # noqa: S105
+ mock_settings.ftp_use_tls = True
+ mock_settings.ftp_allow_plaintext = False
+ result = _connect_ftp()
+ assert result is mock_ftp_tls
+
+ def test_tls_fails_fallback_to_plaintext(self):
+ """When TLS fails and allow_plaintext=True, should fall back to plain FTP."""
+ from app.tasks.watch_folder_tasks import _connect_ftp
+
+ mock_ftp_plain = MagicMock()
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("ftplib.FTP_TLS") as mock_ftps_cls,
+ patch("ftplib.FTP", return_value=mock_ftp_plain),
+ ):
+ mock_settings.ftp_host = "ftp.example.com"
+ mock_settings.ftp_port = 21
+ mock_settings.ftp_username = "user"
+ mock_settings.ftp_password = "pass" # noqa: S105
+ mock_settings.ftp_use_tls = True
+ mock_settings.ftp_allow_plaintext = True
+ mock_ftps_cls.return_value.connect.side_effect = ConnectionRefusedError("no tls")
+ result = _connect_ftp()
+ assert result is mock_ftp_plain
+
+ def test_tls_fails_no_plaintext_returns_none(self):
+ """When TLS fails and allow_plaintext=False, returns None."""
+ from app.tasks.watch_folder_tasks import _connect_ftp
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("ftplib.FTP_TLS") as mock_ftps_cls,
+ ):
+ mock_settings.ftp_host = "ftp.example.com"
+ mock_settings.ftp_port = 21
+ mock_settings.ftp_username = "user"
+ mock_settings.ftp_password = "pass" # noqa: S105
+ mock_settings.ftp_use_tls = True
+ mock_settings.ftp_allow_plaintext = False
+ mock_ftps_cls.return_value.connect.side_effect = ConnectionRefusedError("no tls")
+ result = _connect_ftp()
+ assert result is None
+
+ def test_plain_ftp_success(self):
+ """When use_tls=False, plain FTP is used directly."""
+ from app.tasks.watch_folder_tasks import _connect_ftp
+
+ mock_ftp = MagicMock()
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("ftplib.FTP", return_value=mock_ftp),
+ ):
+ mock_settings.ftp_host = "ftp.example.com"
+ mock_settings.ftp_port = 21
+ mock_settings.ftp_username = "user"
+ mock_settings.ftp_password = "pass" # noqa: S105
+ mock_settings.ftp_use_tls = False
+ mock_settings.ftp_allow_plaintext = True
+ result = _connect_ftp()
+ assert result is mock_ftp
+
+
+@pytest.mark.unit
+class TestScanFtpFolderEdgeCases:
+ """Additional edge cases for _scan_ftp_folder."""
+
+ def test_nlst_failure_returns_zero(self):
+ """When NLST raises an exception, return 0."""
+ from app.tasks.watch_folder_tasks import _scan_ftp_folder
+
+ mock_ftp = MagicMock()
+ mock_ftp.cwd.return_value = None
+ mock_ftp.nlst.side_effect = Exception("NLST failed")
+ count = _scan_ftp_folder(mock_ftp, "/inbox", {}, False)
+ assert count == 0
+
+ def test_file_collision_generates_unique_name(self, tmp_path):
+ """When dest path already exists, a timestamped path should be created."""
+ from app.tasks.watch_folder_tasks import _scan_ftp_folder
+
+ mock_ftp = MagicMock()
+ mock_ftp.cwd.return_value = None
+ mock_ftp.nlst.return_value = ["doc.pdf"]
+ # Pre-create the dest file
+ (tmp_path / "ftp_doc.pdf").write_bytes(b"old")
+
+ def fake_retrbinary(cmd, callback):
+ callback(b"%PDF-1.4")
+
+ mock_ftp.retrbinary.side_effect = fake_retrbinary
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_ftp_folder(mock_ftp, "/inbox", cache, False)
+ assert count == 1
+
+ def test_download_failure_skips_file(self, tmp_path):
+ """When retrbinary raises, the file should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_ftp_folder
+
+ mock_ftp = MagicMock()
+ mock_ftp.cwd.return_value = None
+ mock_ftp.nlst.return_value = ["broken.pdf"]
+ mock_ftp.retrbinary.side_effect = Exception("transfer failed")
+
+ cache: dict = {}
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_ftp_folder(mock_ftp, "/inbox", cache, False)
+ assert count == 0
+ assert "ftp:/inbox/broken.pdf" not in cache
+
+ def test_delete_after_ftp_success(self, tmp_path):
+ """When delete_after=True and delete succeeds, no exception should occur."""
+ from app.tasks.watch_folder_tasks import _scan_ftp_folder
+
+ mock_ftp = MagicMock()
+ mock_ftp.cwd.return_value = None
+ mock_ftp.nlst.return_value = ["file.pdf"]
+
+ def fake_retrbinary(cmd, callback):
+ callback(b"%PDF-1.4")
+
+ mock_ftp.retrbinary.side_effect = fake_retrbinary
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_ftp_folder(mock_ftp, "/inbox", cache, delete_after=True)
+
+ assert count == 1
+ mock_ftp.delete.assert_called_once_with("file.pdf")
+
+ def test_delete_after_ftp_failure_does_not_crash(self, tmp_path):
+ """When delete raises, it should log a warning but not crash."""
+ from app.tasks.watch_folder_tasks import _scan_ftp_folder
+
+ mock_ftp = MagicMock()
+ mock_ftp.cwd.return_value = None
+ mock_ftp.nlst.return_value = ["file.pdf"]
+
+ def fake_retrbinary(cmd, callback):
+ callback(b"%PDF-1.4")
+
+ mock_ftp.retrbinary.side_effect = fake_retrbinary
+ mock_ftp.delete.side_effect = Exception("permission denied")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_ftp_folder(mock_ftp, "/inbox", cache, delete_after=True)
+ assert count == 1
+
+
+@pytest.mark.unit
+class TestGetSftpConnection:
+ """Tests for _get_sftp_connection."""
+
+ def test_returns_none_when_host_missing(self):
+ """Returns (None, None) when host is not configured."""
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.sftp_host = None
+ mock_settings.sftp_username = "user"
+ from app.tasks.watch_folder_tasks import _get_sftp_connection
+
+ ssh, sftp = _get_sftp_connection()
+ assert ssh is None
+ assert sftp is None
+
+ def test_returns_none_when_no_auth(self):
+ """Returns (None, None) when neither password nor key is set."""
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.sftp_host = "sftp.example.com"
+ mock_settings.sftp_port = 22
+ mock_settings.sftp_username = "user"
+ mock_settings.sftp_disable_host_key_verification = False
+ mock_settings.sftp_private_key = None
+ mock_settings.sftp_password = None
+
+ import paramiko
+
+ mock_ssh = MagicMock(spec=paramiko.SSHClient)
+
+ with patch("paramiko.SSHClient", return_value=mock_ssh):
+ from app.tasks.watch_folder_tasks import _get_sftp_connection
+
+ ssh, sftp = _get_sftp_connection()
+ assert ssh is None
+ assert sftp is None
+
+ def test_connects_with_password(self):
+ """Returns (ssh, sftp) when password auth succeeds."""
+ import paramiko
+
+ mock_ssh = MagicMock(spec=paramiko.SSHClient)
+ mock_sftp = MagicMock()
+ mock_ssh.open_sftp.return_value = mock_sftp
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("paramiko.SSHClient", return_value=mock_ssh),
+ ):
+ mock_settings.sftp_host = "sftp.example.com"
+ mock_settings.sftp_port = 22
+ mock_settings.sftp_username = "user"
+ mock_settings.sftp_disable_host_key_verification = False
+ mock_settings.sftp_private_key = None
+ mock_settings.sftp_password = "secret" # noqa: S105
+ mock_settings.sftp_private_key_passphrase = None
+
+ from app.tasks.watch_folder_tasks import _get_sftp_connection
+
+ ssh, sftp = _get_sftp_connection()
+
+ assert ssh is mock_ssh
+ assert sftp is mock_sftp
+
+ def test_connects_with_key_file(self, tmp_path):
+ """Returns (ssh, sftp) when key-based auth succeeds."""
+ import paramiko
+
+ key_file = tmp_path / "id_rsa"
+ key_file.write_text("fake key")
+
+ mock_ssh = MagicMock(spec=paramiko.SSHClient)
+ mock_sftp = MagicMock()
+ mock_ssh.open_sftp.return_value = mock_sftp
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("paramiko.SSHClient", return_value=mock_ssh),
+ ):
+ mock_settings.sftp_host = "sftp.example.com"
+ mock_settings.sftp_port = 22
+ mock_settings.sftp_username = "user"
+ mock_settings.sftp_disable_host_key_verification = False
+ mock_settings.sftp_private_key = str(key_file)
+ mock_settings.sftp_private_key_passphrase = "mypassphrase"
+ mock_settings.sftp_password = None
+
+ from app.tasks.watch_folder_tasks import _get_sftp_connection
+
+ ssh, sftp = _get_sftp_connection()
+
+ assert ssh is mock_ssh
+ assert sftp is mock_sftp
+
+ def test_connection_failure_returns_none(self):
+ """Returns (None, None) when connect() raises."""
+ import paramiko
+
+ mock_ssh = MagicMock(spec=paramiko.SSHClient)
+ mock_ssh.connect.side_effect = Exception("refused")
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("paramiko.SSHClient", return_value=mock_ssh),
+ ):
+ mock_settings.sftp_host = "sftp.example.com"
+ mock_settings.sftp_port = 22
+ mock_settings.sftp_username = "user"
+ mock_settings.sftp_disable_host_key_verification = False
+ mock_settings.sftp_private_key = None
+ mock_settings.sftp_password = "secret" # noqa: S105
+ mock_settings.sftp_private_key_passphrase = None
+
+ from app.tasks.watch_folder_tasks import _get_sftp_connection
+
+ ssh, sftp = _get_sftp_connection()
+
+ assert ssh is None
+ assert sftp is None
+
+ def test_disable_host_key_verification_path(self):
+ """When sftp_disable_host_key_verification=True, AutoAddPolicy is set."""
+ import paramiko
+
+ mock_ssh = MagicMock(spec=paramiko.SSHClient)
+ mock_sftp = MagicMock()
+ mock_ssh.open_sftp.return_value = mock_sftp
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("paramiko.SSHClient", return_value=mock_ssh),
+ patch("paramiko.AutoAddPolicy"),
+ ):
+ mock_settings.sftp_host = "sftp.example.com"
+ mock_settings.sftp_port = 22
+ mock_settings.sftp_username = "user"
+ mock_settings.sftp_disable_host_key_verification = True
+ mock_settings.sftp_private_key = None
+ mock_settings.sftp_password = "secret" # noqa: S105
+ mock_settings.sftp_private_key_passphrase = None
+
+ from app.tasks.watch_folder_tasks import _get_sftp_connection
+
+ _get_sftp_connection()
+
+ mock_ssh.set_missing_host_key_policy.assert_called()
+
+ def test_connection_failure_close_raises(self):
+ """When connect() fails and ssh.close() also raises, returns (None, None)."""
+ import paramiko
+
+ mock_ssh = MagicMock(spec=paramiko.SSHClient)
+ mock_ssh.connect.side_effect = Exception("refused")
+ mock_ssh.close.side_effect = Exception("close also failed")
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("paramiko.SSHClient", return_value=mock_ssh),
+ ):
+ mock_settings.sftp_host = "sftp.example.com"
+ mock_settings.sftp_port = 22
+ mock_settings.sftp_username = "user"
+ mock_settings.sftp_disable_host_key_verification = False
+ mock_settings.sftp_private_key = None
+ mock_settings.sftp_password = "secret" # noqa: S105
+ mock_settings.sftp_private_key_passphrase = None
+
+ from app.tasks.watch_folder_tasks import _get_sftp_connection
+
+ ssh, sftp = _get_sftp_connection()
+
+ assert ssh is None
+ assert sftp is None
+
+
+@pytest.mark.unit
+class TestScanSftpFolderEdgeCases:
+ """Additional edge cases for _scan_sftp_folder."""
+
+ def test_listdir_failure_returns_zero(self):
+ """When listdir_attr raises, return 0."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ mock_sftp.listdir_attr.side_effect = Exception("permission denied")
+ count = _scan_sftp_folder(mock_sftp, "/upload", {}, False)
+ assert count == 0
+
+ def test_directory_entry_is_skipped(self, tmp_path):
+ """Directory entries should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ dir_attr = MagicMock()
+ dir_attr.st_mode = stat.S_IFDIR | 0o755
+ dir_attr.filename = "subdir"
+ mock_sftp.listdir_attr.return_value = [dir_attr]
+
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_sftp_folder(mock_sftp, "/upload", {}, False)
+ assert count == 0
+
+ def test_download_new_file(self, tmp_path):
+ """A new allowed file should be downloaded and enqueued."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ file_attr = MagicMock()
+ file_attr.st_mode = stat.S_IFREG | 0o644
+ file_attr.filename = "scan.pdf"
+ mock_sftp.listdir_attr.return_value = [file_attr]
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)
+
+ assert count == 1
+ assert "sftp:/upload/scan.pdf" in cache
+
+ def test_file_collision_generates_unique_name(self, tmp_path):
+ """When dest file already exists, a unique timestamped name is used."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ file_attr = MagicMock()
+ file_attr.st_mode = stat.S_IFREG | 0o644
+ file_attr.filename = "doc.pdf"
+ mock_sftp.listdir_attr.return_value = [file_attr]
+ # Pre-create the collision file
+ (tmp_path / "sftp_doc.pdf").write_bytes(b"old")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)
+ assert count == 1
+
+ def test_download_failure_skips_file(self, tmp_path):
+ """When sftp.get raises, the file is skipped."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ file_attr = MagicMock()
+ file_attr.st_mode = stat.S_IFREG | 0o644
+ file_attr.filename = "broken.pdf"
+ mock_sftp.listdir_attr.return_value = [file_attr]
+ mock_sftp.get.side_effect = Exception("IO error")
+
+ cache: dict = {}
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)
+ assert count == 0
+ assert "sftp:/upload/broken.pdf" not in cache
+
+ def test_delete_after_success(self, tmp_path):
+ """delete_after=True should call sftp.remove."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ file_attr = MagicMock()
+ file_attr.st_mode = stat.S_IFREG | 0o644
+ file_attr.filename = "file.pdf"
+ mock_sftp.listdir_attr.return_value = [file_attr]
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ _scan_sftp_folder(mock_sftp, "/upload", cache, delete_after=True)
+
+ mock_sftp.remove.assert_called_once_with("/upload/file.pdf")
+
+ def test_delete_after_failure_does_not_crash(self, tmp_path):
+ """delete_after failure should be logged but not crash."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ file_attr = MagicMock()
+ file_attr.st_mode = stat.S_IFREG | 0o644
+ file_attr.filename = "file.pdf"
+ mock_sftp.listdir_attr.return_value = [file_attr]
+ mock_sftp.remove.side_effect = Exception("permission denied")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_sftp_folder(mock_sftp, "/upload", cache, delete_after=True)
+ assert count == 1
+
+ def test_already_cached_sftp_file_skipped(self, tmp_path):
+ """Files already in cache should not be re-downloaded."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ file_attr = MagicMock()
+ file_attr.st_mode = stat.S_IFREG | 0o644
+ file_attr.filename = "cached.pdf"
+ mock_sftp.listdir_attr.return_value = [file_attr]
+
+ cache = {"sftp:/upload/cached.pdf": datetime.now(timezone.utc).isoformat()}
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)
+ assert count == 0
+
+ def test_unsupported_type_sftp_skipped(self, tmp_path):
+ """Non-allowed file types should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ file_attr = MagicMock()
+ file_attr.st_mode = stat.S_IFREG | 0o644
+ file_attr.filename = "script.sh"
+ mock_sftp.listdir_attr.return_value = [file_attr]
+
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_sftp_folder(mock_sftp, "/upload", {}, False)
+ assert count == 0
+
+
+@pytest.mark.unit
+class TestScanDropboxFolder:
+ """Tests for _scan_dropbox_folder."""
+
+ def test_auth_failure_returns_zero(self):
+ """If get_dropbox_client raises, return 0."""
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ with patch("app.tasks.upload_to_dropbox.get_dropbox_client", side_effect=Exception("auth failed")):
+ result = _scan_dropbox_folder("/Inbox", {}, False)
+ assert result == 0
+
+ def test_list_folder_failure_returns_zero(self):
+ """If files_list_folder raises, return 0."""
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ mock_dbx.files_list_folder.side_effect = Exception("network error")
+
+ with patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx):
+ result = _scan_dropbox_folder("/Inbox", {}, False)
+ assert result == 0
+
+ def test_downloads_new_file(self, tmp_path):
+ """New allowed files should be downloaded and enqueued."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file_entry.name = "receipt.pdf"
+ file_entry.id = "id:abc123"
+ file_entry.path_lower = "/inbox/receipt.pdf"
+
+ mock_result = MagicMock()
+ mock_result.entries = [file_entry]
+ mock_result.has_more = False
+ mock_dbx.files_list_folder.return_value = mock_result
+
+ mock_response = MagicMock()
+ mock_response.content = b"%PDF-1.4"
+ mock_dbx.files_download.return_value = (MagicMock(), mock_response)
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_dropbox_folder("/Inbox", cache, False)
+
+ assert count == 1
+ assert "dropbox:id:abc123" in cache
+
+ def test_skips_non_file_metadata(self, tmp_path):
+ """Non-FileMetadata entries (folders) should be skipped."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ folder_entry = MagicMock(spec=dropbox_module.files.FolderMetadata)
+ folder_entry.name = "subfolder"
+
+ mock_result = MagicMock()
+ mock_result.entries = [folder_entry]
+ mock_result.has_more = False
+ mock_dbx.files_list_folder.return_value = mock_result
+
+ cache: dict = {}
+ with patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx):
+ count = _scan_dropbox_folder("/Inbox", cache, False)
+ assert count == 0
+
+ def test_skips_already_cached_file(self, tmp_path):
+ """Files already in cache should not be re-downloaded."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file_entry.name = "cached.pdf"
+ file_entry.id = "id:cached"
+ file_entry.path_lower = "/inbox/cached.pdf"
+
+ mock_result = MagicMock()
+ mock_result.entries = [file_entry]
+ mock_result.has_more = False
+ mock_dbx.files_list_folder.return_value = mock_result
+
+ cache = {"dropbox:id:cached": datetime.now(timezone.utc).isoformat()}
+ with patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx):
+ count = _scan_dropbox_folder("/Inbox", cache, False)
+ assert count == 0
+
+ def test_skips_unsupported_file_type(self, tmp_path):
+ """Non-allowed file types should be skipped."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file_entry.name = "script.exe"
+ file_entry.id = "id:exe1"
+ file_entry.path_lower = "/inbox/script.exe"
+
+ mock_result = MagicMock()
+ mock_result.entries = [file_entry]
+ mock_result.has_more = False
+ mock_dbx.files_list_folder.return_value = mock_result
+
+ with patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx):
+ count = _scan_dropbox_folder("/Inbox", {}, False)
+ assert count == 0
+
+ def test_download_failure_skips_file(self, tmp_path):
+ """If files_download raises, the file should be skipped."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file_entry.name = "fail.pdf"
+ file_entry.id = "id:fail1"
+ file_entry.path_lower = "/inbox/fail.pdf"
+
+ mock_result = MagicMock()
+ mock_result.entries = [file_entry]
+ mock_result.has_more = False
+ mock_dbx.files_list_folder.return_value = mock_result
+ mock_dbx.files_download.side_effect = Exception("download failed")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_dropbox_folder("/Inbox", cache, False)
+ assert count == 0
+ assert "dropbox:id:fail1" not in cache
+
+ def test_delete_after_success(self, tmp_path):
+ """delete_after=True should call files_delete_v2."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file_entry.name = "invoice.pdf"
+ file_entry.id = "id:inv1"
+ file_entry.path_lower = "/inbox/invoice.pdf"
+
+ mock_result = MagicMock()
+ mock_result.entries = [file_entry]
+ mock_result.has_more = False
+ mock_dbx.files_list_folder.return_value = mock_result
+
+ mock_response = MagicMock()
+ mock_response.content = b"%PDF-1.4"
+ mock_dbx.files_download.return_value = (MagicMock(), mock_response)
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ _scan_dropbox_folder("/Inbox", cache, delete_after=True)
+
+ mock_dbx.files_delete_v2.assert_called_once_with("/inbox/invoice.pdf")
+
+ def test_delete_after_failure_does_not_crash(self, tmp_path):
+ """delete_after failure should log warning but not crash."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file_entry.name = "doc.pdf"
+ file_entry.id = "id:doc1"
+ file_entry.path_lower = "/inbox/doc.pdf"
+
+ mock_result = MagicMock()
+ mock_result.entries = [file_entry]
+ mock_result.has_more = False
+ mock_dbx.files_list_folder.return_value = mock_result
+ mock_dbx.files_delete_v2.side_effect = Exception("permission denied")
+
+ mock_response = MagicMock()
+ mock_response.content = b"%PDF-1.4"
+ mock_dbx.files_download.return_value = (MagicMock(), mock_response)
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_dropbox_folder("/Inbox", cache, delete_after=True)
+ assert count == 1
+
+ def test_pagination(self, tmp_path):
+ """When has_more=True, should paginate to get all files."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+
+ file1 = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file1.name = "page1.pdf"
+ file1.id = "id:page1"
+ file1.path_lower = "/inbox/page1.pdf"
+
+ file2 = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file2.name = "page2.pdf"
+ file2.id = "id:page2"
+ file2.path_lower = "/inbox/page2.pdf"
+
+ result1 = MagicMock()
+ result1.entries = [file1]
+ result1.has_more = True
+ result1.cursor = "cursor1"
+
+ result2 = MagicMock()
+ result2.entries = [file2]
+ result2.has_more = False
+
+ mock_dbx.files_list_folder.return_value = result1
+ mock_dbx.files_list_folder_continue.return_value = result2
+
+ mock_response = MagicMock()
+ mock_response.content = b"%PDF-1.4"
+ mock_dbx.files_download.return_value = (MagicMock(), mock_response)
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_dropbox_folder("/Inbox", cache, False)
+
+ assert count == 2
+
+ def test_file_collision_generates_unique_name(self, tmp_path):
+ """When dest file already exists, a timestamped path is used."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file_entry.name = "collision.pdf"
+ file_entry.id = "id:coll1"
+ file_entry.path_lower = "/inbox/collision.pdf"
+
+ mock_result = MagicMock()
+ mock_result.entries = [file_entry]
+ mock_result.has_more = False
+ mock_dbx.files_list_folder.return_value = mock_result
+
+ mock_response = MagicMock()
+ mock_response.content = b"%PDF-1.4"
+ mock_dbx.files_download.return_value = (MagicMock(), mock_response)
+
+ (tmp_path / "dropbox_collision.pdf").write_bytes(b"old")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_dropbox_folder("/Inbox", cache, False)
+ assert count == 1
+
+
+@pytest.mark.unit
+class TestScanGoogleDriveFolder:
+ """Tests for _scan_google_drive_folder."""
+
+ def test_service_none_returns_zero(self):
+ """If get_google_drive_service returns None, return 0."""
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ with patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=None):
+ result = _scan_google_drive_folder("folder123", {}, False)
+ assert result == 0
+
+ def test_listing_failure_breaks_loop(self):
+ """If the API listing call raises, break and return current count (0)."""
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.side_effect = Exception("API error")
+
+ with patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service):
+ result = _scan_google_drive_folder("folder123", {}, False)
+ assert result == 0
+
+ def test_downloads_new_file(self, tmp_path):
+ """New allowed files should be downloaded and enqueued."""
+ import io
+
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "gid1", "name": "invoice.pdf", "mimeType": "application/pdf"}],
+ }
+
+ mock_downloader = MagicMock()
+ mock_downloader.next_chunk.return_value = (None, True)
+
+ with (
+ patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
+ patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_google_drive_folder("folder123", {}, False)
+
+ assert count == 1
+
+ def test_skips_unsupported_type(self, tmp_path):
+ """Non-allowed file types should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "gid2", "name": "script.exe", "mimeType": "application/octet-stream"}],
+ }
+
+ with (
+ patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_google_drive_folder("folder123", {}, False)
+ assert count == 0
+
+ def test_skips_already_cached(self, tmp_path):
+ """Already cached files should not be re-downloaded."""
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "gid3", "name": "cached.pdf", "mimeType": "application/pdf"}],
+ }
+
+ cache = {"gdrive:gid3": datetime.now(timezone.utc).isoformat()}
+ with (
+ patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_google_drive_folder("folder123", cache, False)
+ assert count == 0
+
+ def test_download_failure_skips_file(self, tmp_path):
+ """Download failure should skip the file."""
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "gid4", "name": "fail.pdf", "mimeType": "application/pdf"}],
+ }
+ mock_service.files.return_value.get_media.side_effect = Exception("download error")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_google_drive_folder("folder123", cache, False)
+ assert count == 0
+
+ def test_delete_after_success(self, tmp_path):
+ """delete_after=True should call service.files().delete()."""
+ import io
+
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "gid5", "name": "todelete.pdf", "mimeType": "application/pdf"}],
+ }
+
+ mock_downloader = MagicMock()
+ mock_downloader.next_chunk.return_value = (None, True)
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
+ patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ _scan_google_drive_folder("folder123", cache, delete_after=True)
+
+ mock_service.files.return_value.delete.assert_called()
+
+ def test_pagination(self, tmp_path):
+ """Should follow nextPageToken for pagination."""
+ import io
+
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.side_effect = [
+ {
+ "files": [{"id": "gid10", "name": "page1.pdf", "mimeType": "application/pdf"}],
+ "nextPageToken": "tok1",
+ },
+ {
+ "files": [{"id": "gid11", "name": "page2.pdf", "mimeType": "application/pdf"}],
+ },
+ ]
+
+ mock_downloader = MagicMock()
+ mock_downloader.next_chunk.return_value = (None, True)
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
+ patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_google_drive_folder("folder123", cache, False)
+
+ assert count == 2
+
+ def test_delete_after_failure_does_not_crash(self, tmp_path):
+ """delete_after failure is logged but does not crash."""
+ import io
+
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "gid6", "name": "del.pdf", "mimeType": "application/pdf"}],
+ }
+ mock_service.files.return_value.delete.return_value.execute.side_effect = Exception("forbidden")
+
+ mock_downloader = MagicMock()
+ mock_downloader.next_chunk.return_value = (None, True)
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
+ patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_google_drive_folder("folder123", cache, delete_after=True)
+ assert count == 1
+
+ def test_file_collision_generates_unique_name(self, tmp_path):
+ """When dest file already exists, a timestamped path is used."""
+ import io
+
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "gid7", "name": "coll.pdf", "mimeType": "application/pdf"}],
+ }
+
+ (tmp_path / "gdrive_coll.pdf").write_bytes(b"old")
+
+ mock_downloader = MagicMock()
+ mock_downloader.next_chunk.return_value = (None, True)
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ patch("googleapiclient.http.MediaIoBaseDownload", return_value=mock_downloader),
+ patch("io.BytesIO", return_value=io.BytesIO(b"%PDF-1.4")),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_google_drive_folder("folder123", cache, False)
+ assert count == 1
+
+
+@pytest.mark.unit
+class TestScanOneDriveFolder:
+ """Tests for _scan_onedrive_folder."""
+
+ def test_auth_failure_returns_zero(self):
+ """If get_onedrive_token raises, return 0."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ with patch("app.tasks.upload_to_onedrive.get_onedrive_token", side_effect=Exception("auth failed")):
+ result = _scan_onedrive_folder("/Inbox", {}, False)
+ assert result == 0
+
+ def test_listing_failure_breaks_loop(self, tmp_path):
+ """When the listing request raises, break and return 0."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", side_effect=Exception("network error")),
+ ):
+ mock_settings.http_request_timeout = 30
+ result = _scan_onedrive_folder("/Inbox", {}, False)
+ assert result == 0
+
+ def test_downloads_new_file(self, tmp_path):
+ """New allowed files should be downloaded and enqueued."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "report.pdf",
+ "id": "item1",
+ "@microsoft.graph.downloadUrl": "https://example.com/dl/report.pdf",
+ }
+ ]
+ }
+ list_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", side_effect=[list_resp, dl_resp]),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, False)
+
+ assert count == 1
+ assert "onedrive:item1" in cache
+
+ def test_skips_folder_items(self, tmp_path):
+ """Items with 'folder' key should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {
+ "value": [
+ {"name": "Subfolder", "id": "folder1", "folder": {"childCount": 2}},
+ ]
+ }
+ list_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", return_value=list_resp),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, False)
+ assert count == 0
+
+ def test_skips_unsupported_type(self, tmp_path):
+ """Non-allowed types are skipped."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {
+ "value": [{"name": "script.exe", "id": "exe1", "@microsoft.graph.downloadUrl": "https://x.com/exe"}]
+ }
+ list_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", return_value=list_resp),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, False)
+ assert count == 0
+
+ def test_skips_already_cached(self, tmp_path):
+ """Already cached files are not re-downloaded."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "cached.pdf",
+ "id": "cacheditem",
+ "@microsoft.graph.downloadUrl": "https://x.com/cached",
+ }
+ ]
+ }
+ list_resp.raise_for_status.return_value = None
+
+ cache = {"onedrive:cacheditem": datetime.now(timezone.utc).isoformat()}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", return_value=list_resp),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, False)
+ assert count == 0
+
+ def test_skips_no_download_url(self, tmp_path):
+ """Items without @microsoft.graph.downloadUrl are skipped."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {"value": [{"name": "nurl.pdf", "id": "nurl1"}]}
+ list_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", return_value=list_resp),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, False)
+ assert count == 0
+
+ def test_download_failure_skips_file(self, tmp_path):
+ """Download failure skips the file."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "fail.pdf",
+ "id": "fail1",
+ "@microsoft.graph.downloadUrl": "https://x.com/fail",
+ }
+ ]
+ }
+ list_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.raise_for_status.side_effect = Exception("dl failed")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", side_effect=[list_resp, dl_resp]),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, False)
+ assert count == 0
+ assert "onedrive:fail1" not in cache
+
+ def test_delete_after_success(self, tmp_path):
+ """delete_after=True should call DELETE on the item."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "del.pdf",
+ "id": "del1",
+ "@microsoft.graph.downloadUrl": "https://x.com/del",
+ }
+ ]
+ }
+ list_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ del_resp = MagicMock()
+ del_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", side_effect=[list_resp, dl_resp]),
+ patch("requests.delete", return_value=del_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, delete_after=True)
+
+ assert count == 1
+
+ def test_delete_after_failure_does_not_crash(self, tmp_path):
+ """delete_after failure is logged but doesn't crash."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "del2.pdf",
+ "id": "del2",
+ "@microsoft.graph.downloadUrl": "https://x.com/del2",
+ }
+ ]
+ }
+ list_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ del_resp = MagicMock()
+ del_resp.raise_for_status.side_effect = Exception("forbidden")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", side_effect=[list_resp, dl_resp]),
+ patch("requests.delete", return_value=del_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, delete_after=True)
+ assert count == 1
+
+ def test_pagination(self, tmp_path):
+ """Should follow @odata.nextLink for pagination."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ page1_resp = MagicMock()
+ page1_resp.json.return_value = {
+ "value": [
+ {
+ "name": "p1.pdf",
+ "id": "p1",
+ "@microsoft.graph.downloadUrl": "https://x.com/p1",
+ }
+ ],
+ "@odata.nextLink": "https://graph.microsoft.com/v1.0/next",
+ }
+ page1_resp.raise_for_status.return_value = None
+
+ page2_resp = MagicMock()
+ page2_resp.json.return_value = {
+ "value": [
+ {
+ "name": "p2.pdf",
+ "id": "p2",
+ "@microsoft.graph.downloadUrl": "https://x.com/p2",
+ }
+ ]
+ }
+ page2_resp.raise_for_status.return_value = None
+
+ dl1 = MagicMock()
+ dl1.content = b"%PDF-1.4"
+ dl1.raise_for_status.return_value = None
+
+ dl2 = MagicMock()
+ dl2.content = b"%PDF-1.4"
+ dl2.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", side_effect=[page1_resp, dl1, page2_resp, dl2]),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, False)
+
+ assert count == 2
+
+ def test_file_collision_generates_unique_name(self, tmp_path):
+ """When dest file already exists, a timestamped path is used."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "coll.pdf",
+ "id": "coll1",
+ "@microsoft.graph.downloadUrl": "https://x.com/coll",
+ }
+ ]
+ }
+ list_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ (tmp_path / "onedrive_coll.pdf").write_bytes(b"old")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", side_effect=[list_resp, dl_resp]),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, False)
+ assert count == 1
+
+
+@pytest.mark.unit
+class TestScanNextcloudFolder:
+ """Tests for _scan_nextcloud_folder."""
+
+ def test_incomplete_settings_returns_zero(self):
+ """Returns 0 if Nextcloud connection settings are missing."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.nextcloud_upload_url = None
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ result = _scan_nextcloud_folder("/Scans", {}, False)
+ assert result == 0
+
+ def test_propfind_failure_returns_zero(self):
+ """Returns 0 if PROPFIND request fails."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", side_effect=Exception("connection refused")),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ result = _scan_nextcloud_folder("/Scans", {}, False)
+ assert result == 0
+
+ def test_xml_parse_failure_returns_zero(self):
+ """Returns 0 if XML parsing fails."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ mock_resp = MagicMock()
+ mock_resp.text = "invalid xml <<<"
+ mock_resp.raise_for_status.return_value = None
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=mock_resp),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ result = _scan_nextcloud_folder("/Scans", {}, False)
+ assert result == 0
+
+ def _build_propfind_xml(self, hrefs):
+ responses = "".join(
+ f"{href}" for href in hrefs
+ )
+ return f'{responses}'
+
+ def test_downloads_new_file(self, tmp_path):
+ """New allowed files should be downloaded and enqueued."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = self._build_propfind_xml(["/remote.php/webdav/Scans/", "/remote.php/webdav/Scans/invoice.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", cache, False)
+
+ assert count == 1
+
+ def test_skips_already_cached(self, tmp_path):
+ """Cached files should not be re-downloaded."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = self._build_propfind_xml(["/remote.php/webdav/Scans/cached.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ cache = {"nextcloud:/remote.php/webdav/Scans/cached.pdf": datetime.now(timezone.utc).isoformat()}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", cache, False)
+ assert count == 0
+
+ def test_skips_unsupported_type(self, tmp_path):
+ """Non-allowed types should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = self._build_propfind_xml(["/remote.php/webdav/Scans/script.exe"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", {}, False)
+ assert count == 0
+
+ def test_download_failure_skips_file(self, tmp_path):
+ """Download failure skips the file."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = self._build_propfind_xml(["/remote.php/webdav/Scans/fail.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.raise_for_status.side_effect = Exception("download failed")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", cache, False)
+ assert count == 0
+
+ def test_delete_after_success(self, tmp_path):
+ """delete_after=True should call DELETE on the file URL."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = self._build_propfind_xml(["/remote.php/webdav/Scans/del.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ del_resp = MagicMock()
+ del_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", side_effect=[propfind_resp, del_resp]),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", cache, delete_after=True)
+ assert count == 1
+
+ def test_delete_after_failure_does_not_crash(self, tmp_path):
+ """delete_after failure is logged but doesn't crash."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = self._build_propfind_xml(["/remote.php/webdav/Scans/del2.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ del_resp = MagicMock()
+ del_resp.raise_for_status.side_effect = Exception("forbidden")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", side_effect=[propfind_resp, del_resp]),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", cache, delete_after=True)
+ assert count == 1
+
+ def test_file_collision_generates_unique_name(self, tmp_path):
+ """When dest file already exists, a timestamped path is used."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = self._build_propfind_xml(["/remote.php/webdav/Scans/coll.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ (tmp_path / "nc_coll.pdf").write_bytes(b"old")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", cache, False)
+ assert count == 1
+
+ def test_absolute_href_used_directly(self, tmp_path):
+ """When href is already an absolute URL, it should be used as-is."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = self._build_propfind_xml(["https://cloud.example.com/remote.php/webdav/Scans/abs.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", cache, False)
+ assert count == 1
+
+
+@pytest.mark.unit
+class TestScanS3Prefix:
+ """Tests for _scan_s3_prefix."""
+
+ def test_missing_bucket_returns_zero(self):
+ """Returns 0 when S3_BUCKET_NAME is not configured."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.s3_bucket_name = None
+ result = _scan_s3_prefix("inbox/", {}, False)
+ assert result == 0
+
+ def test_downloads_new_object(self, tmp_path):
+ """New allowed S3 objects should be downloaded and enqueued."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/invoice.pdf"}]}]
+
+ cache: dict = {}
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_s3_prefix("inbox/", cache, False)
+
+ assert count == 1
+ assert "s3:mybucket/inbox/invoice.pdf" in cache
+
+ def test_skips_empty_key(self, tmp_path):
+ """Objects with empty filename (folder markers) should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/"}]}]
+
+ cache: dict = {}
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_s3_prefix("inbox/", cache, False)
+ assert count == 0
+
+ def test_skips_unsupported_type(self, tmp_path):
+ """Non-allowed types should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/script.exe"}]}]
+
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_s3_prefix("inbox/", {}, False)
+ assert count == 0
+
+ def test_skips_already_cached(self, tmp_path):
+ """Already cached objects should not be re-downloaded."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/cached.pdf"}]}]
+
+ cache = {"s3:mybucket/inbox/cached.pdf": datetime.now(timezone.utc).isoformat()}
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_s3_prefix("inbox/", cache, False)
+ assert count == 0
+
+ def test_download_failure_skips_file(self, tmp_path):
+ """ClientError during download should skip the file."""
+ from botocore.exceptions import ClientError
+
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/fail.pdf"}]}]
+ mock_s3.download_file.side_effect = ClientError(
+ {"Error": {"Code": "NoSuchKey", "Message": "not found"}}, "GetObject"
+ )
+
+ cache: dict = {}
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_s3_prefix("inbox/", cache, False)
+ assert count == 0
+ assert "s3:mybucket/inbox/fail.pdf" not in cache
+
+ def test_delete_after_success(self, tmp_path):
+ """delete_after=True should call s3.delete_object."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/del.pdf"}]}]
+
+ cache: dict = {}
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_s3_prefix("inbox/", cache, delete_after=True)
+
+ assert count == 1
+ mock_s3.delete_object.assert_called_once_with(Bucket="mybucket", Key="inbox/del.pdf")
+
+ def test_delete_after_failure_does_not_crash(self, tmp_path):
+ """delete_after failure is logged but doesn't crash."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/del2.pdf"}]}]
+ mock_s3.delete_object.side_effect = Exception("access denied")
+
+ cache: dict = {}
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_s3_prefix("inbox/", cache, delete_after=True)
+ assert count == 1
+
+ def test_file_collision_generates_unique_name(self, tmp_path):
+ """When dest file already exists, a timestamped path is used."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/coll.pdf"}]}]
+
+ (tmp_path / "s3_coll.pdf").write_bytes(b"old")
+
+ cache: dict = {}
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_s3_prefix("inbox/", cache, False)
+ assert count == 1
+
+
+@pytest.mark.unit
+class TestScanWebdavFolder:
+ """Tests for _scan_webdav_folder."""
+
+ def test_missing_url_returns_zero(self):
+ """Returns 0 when WEBDAV_URL is not configured."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.webdav_url = None
+ mock_settings.webdav_username = None
+ mock_settings.webdav_password = None # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ result = _scan_webdav_folder("/Inbox", {}, False)
+ assert result == 0
+
+ def test_propfind_failure_returns_zero(self):
+ """Returns 0 if PROPFIND fails."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", side_effect=Exception("connection refused")),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ result = _scan_webdav_folder("/Inbox", {}, False)
+ assert result == 0
+
+ def test_xml_parse_failure_returns_zero(self):
+ """Returns 0 if XML parsing fails."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ mock_resp = MagicMock()
+ mock_resp.text = "invalid <<< xml"
+ mock_resp.raise_for_status.return_value = None
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=mock_resp),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ result = _scan_webdav_folder("/Inbox", {}, False)
+ assert result == 0
+
+ def _build_webdav_xml(self, hrefs):
+ responses = "".join(f"{href}" for href in hrefs)
+ return f'{responses}'
+
+ def test_skips_directory_entries(self, tmp_path):
+ """Entries ending with '/' should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["/dav/Inbox/"])
+ mock_resp = MagicMock()
+ mock_resp.text = xml
+ mock_resp.raise_for_status.return_value = None
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=mock_resp),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", {}, False)
+ assert count == 0
+
+ def test_downloads_new_file(self, tmp_path):
+ """New allowed files should be downloaded and enqueued."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["/dav/Inbox/invoice.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", cache, False)
+
+ assert count == 1
+ assert "webdav:/dav/Inbox/invoice.pdf" in cache
+
+ def test_skips_already_cached(self, tmp_path):
+ """Cached files should not be re-downloaded."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["/dav/Inbox/cached.pdf"])
+ mock_resp = MagicMock()
+ mock_resp.text = xml
+ mock_resp.raise_for_status.return_value = None
+
+ cache = {"webdav:/dav/Inbox/cached.pdf": datetime.now(timezone.utc).isoformat()}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=mock_resp),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", cache, False)
+ assert count == 0
+
+ def test_skips_unsupported_type(self, tmp_path):
+ """Non-allowed types should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["/dav/Inbox/script.exe"])
+ mock_resp = MagicMock()
+ mock_resp.text = xml
+ mock_resp.raise_for_status.return_value = None
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=mock_resp),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", {}, False)
+ assert count == 0
+
+ def test_download_failure_skips_file(self, tmp_path):
+ """Download failure skips the file."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["/dav/Inbox/fail.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.raise_for_status.side_effect = Exception("dl failed")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", cache, False)
+ assert count == 0
+
+ def test_delete_after_success(self, tmp_path):
+ """delete_after=True should issue a DELETE request."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["/dav/Inbox/del.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ del_resp = MagicMock()
+ del_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", side_effect=[propfind_resp, del_resp]),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", cache, delete_after=True)
+ assert count == 1
+
+ def test_delete_after_failure_does_not_crash(self, tmp_path):
+ """delete_after failure is logged but doesn't crash."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["/dav/Inbox/del2.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ del_resp = MagicMock()
+ del_resp.raise_for_status.side_effect = Exception("forbidden")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", side_effect=[propfind_resp, del_resp]),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", cache, delete_after=True)
+ assert count == 1
+
+ def test_file_collision_generates_unique_name(self, tmp_path):
+ """When dest file already exists, a timestamped path is used."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["/dav/Inbox/coll.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ (tmp_path / "webdav_coll.pdf").write_bytes(b"old")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", cache, False)
+ assert count == 1
+
+ def test_absolute_href_used_directly(self, tmp_path):
+ """When href is already an absolute URL, it should be used as-is."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["https://dav.example.com/dav/Inbox/abs.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", cache, False)
+ assert count == 1
+
+ def test_no_auth_uses_none(self, tmp_path):
+ """When webdav_username is None, auth=None should be used."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = self._build_webdav_xml(["/dav/Inbox/noauth.pdf"])
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.content = b"%PDF-1.4"
+ dl_resp.raise_for_status.return_value = None
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ patch("app.tasks.watch_folder_tasks.process_document"),
+ ):
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = None
+ mock_settings.webdav_password = None # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_webdav_folder("/Inbox", cache, False)
+ assert count == 1
+
+
+@pytest.mark.unit
+class TestScanFtpWatchFolderQuitError:
+ """Test that FTP quit errors during scan are silently handled."""
+
+ def test_ftp_quit_error_does_not_crash(self):
+ """If ftp.quit() raises, it should be logged but not crash."""
+ from app.tasks.watch_folder_tasks import scan_ftp_watch_folder
+
+ mock_ftp = MagicMock()
+ mock_ftp.quit.side_effect = Exception("quit error")
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._connect_ftp", return_value=mock_ftp),
+ patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
+ patch("app.tasks.watch_folder_tasks._save_cache"),
+ patch("app.tasks.watch_folder_tasks._scan_ftp_folder", return_value=0),
+ ):
+ mock_settings.ftp_ingest_enabled = True
+ mock_settings.ftp_ingest_folder = "/inbox"
+ mock_settings.ftp_ingest_delete_after_process = False
+ result = scan_ftp_watch_folder()
+
+ assert result["status"] == "ok"
+
+
+@pytest.mark.unit
+class TestScanSftpWatchFolderCloseErrors:
+ """Test that SFTP close errors during scan are silently handled."""
+
+ def test_sftp_close_error_does_not_crash(self):
+ """If sftp.close() raises, it should be logged but not crash."""
+ from app.tasks.watch_folder_tasks import scan_sftp_watch_folder
+
+ mock_ssh = MagicMock()
+ mock_sftp = MagicMock()
+ mock_sftp.close.side_effect = Exception("sftp close error")
+ mock_ssh.close.side_effect = Exception("ssh close error")
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._get_sftp_connection", return_value=(mock_ssh, mock_sftp)),
+ patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
+ patch("app.tasks.watch_folder_tasks._save_cache"),
+ patch("app.tasks.watch_folder_tasks._scan_sftp_folder", return_value=0),
+ ):
+ mock_settings.sftp_ingest_enabled = True
+ mock_settings.sftp_ingest_folder = "/upload"
+ mock_settings.sftp_ingest_delete_after_process = False
+ result = scan_sftp_watch_folder()
+
+ assert result["status"] == "ok"
+
+
+@pytest.mark.unit
+class TestScanLocalWatchFoldersMultipleFolders:
+ """Test scan_local_watch_folders with multiple comma-separated folders."""
+
+ def test_multiple_folders_scanned(self, tmp_path):
+ """All folders in comma-separated WATCH_FOLDERS should be scanned."""
+ from app.tasks.watch_folder_tasks import scan_local_watch_folders
+
+ folder1 = str(tmp_path / "folder1")
+ folder2 = str(tmp_path / "folder2")
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._load_cache", return_value={}),
+ patch("app.tasks.watch_folder_tasks._save_cache"),
+ patch("app.tasks.watch_folder_tasks._scan_local_folder", return_value=1) as mock_scan,
+ ):
+ mock_settings.watch_folders = f"{folder1},{folder2}"
+ mock_settings.watch_folder_delete_after_process = False
+ result = scan_local_watch_folders()
+
+ assert result["status"] == "ok"
+ assert result["folders_scanned"] == 2
+ assert result["files_enqueued"] == 2
+ assert mock_scan.call_count == 2
+
+ def test_whitespace_only_entries_are_ignored(self):
+ """Entries that are whitespace-only should be filtered out."""
+ from app.tasks.watch_folder_tasks import scan_local_watch_folders
+
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.watch_folders = " , , "
+ result = scan_local_watch_folders()
+
+ assert result["status"] == "skipped"
+ assert result["reason"] == "WATCH_FOLDERS is empty"
+
+
+@pytest.mark.unit
+class TestPartialFileCleanup:
+ """Tests for partial file cleanup on download failures."""
+
+ def test_ftp_download_failure_removes_partial_file(self, tmp_path):
+ """When FTP download fails and partial file exists, it should be removed."""
+ from app.tasks.watch_folder_tasks import _scan_ftp_folder
+
+ mock_ftp = MagicMock()
+ mock_ftp.cwd.return_value = None
+ mock_ftp.nlst.return_value = ["partial.pdf"]
+
+ # retrbinary creates a partial file then raises
+ dest = tmp_path / "ftp_partial.pdf"
+
+ def fake_retrbinary(cmd, callback):
+ dest.write_bytes(b"%PDF partial")
+ raise Exception("transfer interrupted")
+
+ mock_ftp.retrbinary.side_effect = fake_retrbinary
+
+ cache: dict = {}
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_ftp_folder(mock_ftp, "/inbox", cache, False)
+
+ assert count == 0
+ assert not dest.exists()
+
+ def test_sftp_download_failure_removes_partial_file(self, tmp_path):
+ """When SFTP get fails and partial file exists, it should be removed."""
+ from app.tasks.watch_folder_tasks import _scan_sftp_folder
+
+ mock_sftp = MagicMock()
+ file_attr = MagicMock()
+ file_attr.st_mode = stat.S_IFREG | 0o644
+ file_attr.filename = "partial.pdf"
+ mock_sftp.listdir_attr.return_value = [file_attr]
+
+ # sftp.get creates a partial file then raises
+ dest = tmp_path / "sftp_partial.pdf"
+
+ def fake_get(remote, local):
+ (tmp_path / "sftp_partial.pdf").write_bytes(b"partial")
+ raise Exception("connection dropped")
+
+ mock_sftp.get.side_effect = fake_get
+
+ cache: dict = {}
+ with patch("app.tasks.watch_folder_tasks.settings") as mock_settings:
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_sftp_folder(mock_sftp, "/upload", cache, False)
+
+ assert count == 0
+ assert not dest.exists()
+
+ def test_dropbox_download_failure_removes_partial_file(self, tmp_path):
+ """When Dropbox download fails and partial file exists, it should be removed."""
+ import dropbox as dropbox_module
+
+ from app.tasks.watch_folder_tasks import _scan_dropbox_folder
+
+ mock_dbx = MagicMock()
+ file_entry = MagicMock(spec=dropbox_module.files.FileMetadata)
+ file_entry.name = "partial.pdf"
+ file_entry.id = "id:partial"
+ file_entry.path_lower = "/inbox/partial.pdf"
+
+ mock_result = MagicMock()
+ mock_result.entries = [file_entry]
+ mock_result.has_more = False
+ mock_dbx.files_list_folder.return_value = mock_result
+
+ dest = tmp_path / "dropbox_partial.pdf"
+
+ def fake_download(path):
+ dest.write_bytes(b"partial")
+ raise Exception("download aborted")
+
+ mock_dbx.files_download.side_effect = fake_download
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_dropbox.get_dropbox_client", return_value=mock_dbx),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_dropbox_folder("/Inbox", cache, False)
+
+ assert count == 0
+ assert not dest.exists()
+
+ def test_google_drive_download_failure_removes_partial_file(self, tmp_path):
+ """When Google Drive download fails and partial file exists, it should be removed."""
+
+ from app.tasks.watch_folder_tasks import _scan_google_drive_folder
+
+ mock_service = MagicMock()
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "gid_partial", "name": "partial.pdf", "mimeType": "application/pdf"}],
+ }
+
+ dest = tmp_path / "gdrive_partial.pdf"
+
+ def fake_downloader(*args, **kwargs):
+ dest.write_bytes(b"partial")
+ raise Exception("quota exceeded")
+
+ mock_service.files.return_value.get_media.side_effect = fake_downloader
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_google_drive.get_google_drive_service", return_value=mock_service),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_google_drive_folder("folder123", cache, False)
+
+ assert count == 0
+ assert not dest.exists()
+
+ def test_onedrive_download_failure_removes_partial_file(self, tmp_path):
+ """When OneDrive download fails and partial file exists, it should be removed."""
+ from app.tasks.watch_folder_tasks import _scan_onedrive_folder
+
+ list_resp = MagicMock()
+ list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "partial.pdf",
+ "id": "partial1",
+ "@microsoft.graph.downloadUrl": "https://x.com/partial",
+ }
+ ]
+ }
+ list_resp.raise_for_status.return_value = None
+
+ dest = tmp_path / "onedrive_partial.pdf"
+
+ dl_call_count = 0
+
+ def fake_get(url, **kwargs):
+ nonlocal dl_call_count
+ dl_call_count += 1
+ if dl_call_count == 1:
+ # First call is the listing request
+ return list_resp
+ # Second call is the download — create partial file then raise
+ dest.write_bytes(b"partial")
+ r = MagicMock()
+ r.raise_for_status.side_effect = Exception("timeout")
+ return r
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.upload_to_onedrive.get_onedrive_token", return_value="tok"),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.get", side_effect=fake_get),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ count = _scan_onedrive_folder("/Inbox", cache, False)
+
+ assert count == 0
+ assert not dest.exists()
+
+
+@pytest.mark.unit
+class TestSftpKeyWithoutPassphrase:
+ """Test SFTP key-based auth without passphrase."""
+
+ def test_connects_with_key_no_passphrase(self, tmp_path):
+ """Key file with no passphrase should still connect successfully."""
+ import paramiko
+
+ key_file = tmp_path / "id_rsa"
+ key_file.write_text("fake key")
+
+ mock_ssh = MagicMock(spec=paramiko.SSHClient)
+ mock_sftp = MagicMock()
+ mock_ssh.open_sftp.return_value = mock_sftp
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("paramiko.SSHClient", return_value=mock_ssh),
+ ):
+ mock_settings.sftp_host = "sftp.example.com"
+ mock_settings.sftp_port = 22
+ mock_settings.sftp_username = "user"
+ mock_settings.sftp_disable_host_key_verification = False
+ mock_settings.sftp_private_key = str(key_file)
+ mock_settings.sftp_private_key_passphrase = None # No passphrase
+ mock_settings.sftp_password = None
+
+ from app.tasks.watch_folder_tasks import _get_sftp_connection
+
+ ssh, sftp = _get_sftp_connection()
+
+ assert ssh is mock_ssh
+ assert sftp is mock_sftp
+ # Verify passphrase was NOT added to connect kwargs
+ call_kwargs = mock_ssh.connect.call_args[1]
+ assert "passphrase" not in call_kwargs
+
+
+@pytest.mark.unit
+class TestS3ClientAndPaginatorFailures:
+ """Tests for S3 client creation and paginator failures."""
+
+ def test_s3_client_creation_failure_returns_zero(self, tmp_path):
+ """When boto3.client() raises, return 0."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ with (
+ patch("boto3.client", side_effect=Exception("invalid credentials")),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ result = _scan_s3_prefix("inbox/", {}, False)
+
+ assert result == 0
+
+ def test_s3_paginate_failure_returns_zero(self, tmp_path):
+ """When paginator.paginate() raises, return 0."""
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.side_effect = Exception("ListObjectsV2 failed")
+
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ result = _scan_s3_prefix("inbox/", {}, False)
+
+ assert result == 0
+
+
+@pytest.mark.unit
+class TestHrefNullBranches:
+ """Tests for null href branches in Nextcloud and WebDAV scanning."""
+
+ def _nc_settings(self, mock_settings):
+ """Configure mock Nextcloud settings."""
+ mock_settings.nextcloud_upload_url = "https://cloud.example.com/remote.php/webdav"
+ mock_settings.nextcloud_username = "user"
+ mock_settings.nextcloud_password = "pass" # noqa: S105
+ mock_settings.http_request_timeout = 30
+
+ def _dav_settings(self, mock_settings, tmp_path):
+ """Configure mock WebDAV settings."""
+ mock_settings.webdav_url = "https://dav.example.com"
+ mock_settings.webdav_username = "user"
+ mock_settings.webdav_password = "pass" # noqa: S105
+ mock_settings.webdav_verify_ssl = True
+ mock_settings.http_request_timeout = 30
+ mock_settings.workdir = str(tmp_path)
+
+ def test_nextcloud_response_with_no_href_element(self, tmp_path):
+ """Response elements without d:href should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = ''
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ ):
+ self._nc_settings(mock_settings)
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", {}, False)
+ assert count == 0
+
+ def test_nextcloud_response_with_empty_href_text(self, tmp_path):
+ """Response elements with empty d:href text should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = ''
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ ):
+ self._nc_settings(mock_settings)
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", {}, False)
+ assert count == 0
+
+ def test_webdav_response_with_no_href_element(self, tmp_path):
+ """WebDAV response elements without d:href should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = ''
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ ):
+ self._dav_settings(mock_settings, tmp_path)
+ count = _scan_webdav_folder("/Inbox", {}, False)
+ assert count == 0
+
+ def test_webdav_response_with_empty_href_text(self, tmp_path):
+ """WebDAV response elements with empty d:href text should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = ''
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ ):
+ self._dav_settings(mock_settings, tmp_path)
+ count = _scan_webdav_folder("/Inbox", {}, False)
+ assert count == 0
+
+ def test_nextcloud_download_failure_no_partial_file(self, tmp_path):
+ """When Nextcloud download fails but no partial file created, skip gracefully."""
+ from app.tasks.watch_folder_tasks import _scan_nextcloud_folder
+
+ xml = '/remote.php/webdav/Scans/nopart.pdf'
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.raise_for_status.side_effect = Exception("network error")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ ):
+ self._nc_settings(mock_settings)
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_nextcloud_folder("Scans", cache, False)
+ assert count == 0
+
+ def test_webdav_download_failure_no_partial_file(self, tmp_path):
+ """When WebDAV download fails but no partial file exists, skip gracefully."""
+ from app.tasks.watch_folder_tasks import _scan_webdav_folder
+
+ xml = '/dav/Inbox/nopart.pdf'
+ propfind_resp = MagicMock()
+ propfind_resp.text = xml
+ propfind_resp.raise_for_status.return_value = None
+
+ dl_resp = MagicMock()
+ dl_resp.raise_for_status.side_effect = Exception("network error")
+
+ cache: dict = {}
+ with (
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("requests.request", return_value=propfind_resp),
+ patch("requests.get", return_value=dl_resp),
+ ):
+ self._dav_settings(mock_settings, tmp_path)
+ count = _scan_webdav_folder("/Inbox", cache, False)
+ assert count == 0
+
+
+@pytest.mark.unit
+class TestS3DownloadFailureNoPartialFile:
+ """Test S3 download failure where no partial file exists."""
+
+ def test_s3_download_failure_no_partial_file(self, tmp_path):
+ """When S3 ClientError occurs before file is created, skip gracefully."""
+ from botocore.exceptions import ClientError
+
+ from app.tasks.watch_folder_tasks import _scan_s3_prefix
+
+ mock_s3 = MagicMock()
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/nopart.pdf"}]}]
+ mock_s3.download_file.side_effect = ClientError(
+ {"Error": {"Code": "NoSuchKey", "Message": "not found"}}, "GetObject"
+ )
+
+ cache: dict = {}
+ with (
+ patch("boto3.client", return_value=mock_s3),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ ):
+ mock_settings.s3_bucket_name = "mybucket"
+ mock_settings.aws_region = "us-east-1"
+ mock_settings.aws_access_key_id = "key"
+ mock_settings.aws_secret_access_key = "secret" # noqa: S105
+ mock_settings.workdir = str(tmp_path)
+ count = _scan_s3_prefix("inbox/", cache, False)
+ assert count == 0