diff --git a/tests/test_imap_tasks.py b/tests/test_imap_tasks.py
index 9c46d1a4..b35363bc 100644
--- a/tests/test_imap_tasks.py
+++ b/tests/test_imap_tasks.py
@@ -1584,6 +1584,55 @@ class TestPullUserIntegrationImap:
assert mock_integ.last_error is None
assert mock_integ.last_used_at is not None
+ @patch("app.tasks.imap_tasks._get_db_session")
+ @patch("app.tasks.imap_tasks.pull_inbox")
+ def test_passes_gmail_apply_labels_config_to_pull_inbox(self, mock_pull, mock_session_factory):
+ """gmail_apply_labels config should be forwarded to pull_inbox."""
+ from app.tasks.imap_tasks import _pull_user_integration_imap
+
+ mock_integ = MagicMock()
+ mock_integ.id = 14
+ mock_integ.owner_id = "owner-gmail"
+ mock_integ.config = (
+ '{"host": "imap.gmail.com", "port": 993, "username": "u@gmail.com",'
+ ' "use_ssl": true, "gmail_apply_labels": false}'
+ )
+ mock_integ.credentials = "enc:encrypted"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
+ mock_session_factory.return_value = mock_db
+
+ with patch("app.utils.encryption.decrypt_value", return_value='{"password": "p"}'):
+ _pull_user_integration_imap()
+
+ mock_pull.assert_called_once()
+ call_kwargs = mock_pull.call_args
+ assert call_kwargs.kwargs.get("gmail_apply_labels") is False
+
+ @patch("app.tasks.imap_tasks._get_db_session")
+ @patch("app.tasks.imap_tasks.pull_inbox")
+ def test_gmail_apply_labels_defaults_to_true(self, mock_pull, mock_session_factory):
+ """Config without gmail_apply_labels should default to True."""
+ from app.tasks.imap_tasks import _pull_user_integration_imap
+
+ mock_integ = MagicMock()
+ mock_integ.id = 15
+ mock_integ.owner_id = "owner-gmail2"
+ mock_integ.config = '{"host": "imap.gmail.com", "port": 993, "username": "u@gmail.com", "use_ssl": true}'
+ mock_integ.credentials = "enc:encrypted"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
+ mock_session_factory.return_value = mock_db
+
+ with patch("app.utils.encryption.decrypt_value", return_value='{"password": "p"}'):
+ _pull_user_integration_imap()
+
+ mock_pull.assert_called_once()
+ call_kwargs = mock_pull.call_args
+ assert call_kwargs.kwargs.get("gmail_apply_labels") is True
+
@pytest.mark.unit
class TestPullAllInboxesCallsIntegrations:
diff --git a/tests/test_watch_folder_tasks.py b/tests/test_watch_folder_tasks.py
index 40bc2d22..46aa5444 100644
--- a/tests/test_watch_folder_tasks.py
+++ b/tests/test_watch_folder_tasks.py
@@ -3973,6 +3973,1857 @@ class TestPullUserIntegrationWatchFolders:
assert mock_scan_local.call_args[0][0] == "/data/scans"
assert mock_scan_local.call_args[0][3] == "owner-local"
+ @patch("app.tasks.watch_folder_tasks._get_db_session")
+ @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
+ @patch("app.tasks.watch_folder_tasks._save_cache")
+ def test_dispatches_google_drive_source_type(self, mock_save, mock_load, mock_session_factory):
+ """WATCH_FOLDER with source_type 'google_drive' should dispatch to handler."""
+ from app.tasks.watch_folder_tasks import (
+ _USER_WF_CLOUD_HANDLERS,
+ _pull_user_integration_watch_folders,
+ )
+
+ mock_integ = MagicMock()
+ mock_integ.id = 34
+ mock_integ.owner_id = "owner-gd"
+ mock_integ.config = '{"source_type": "google_drive", "folder_id": "abc123", "delete_after_process": false}'
+ mock_integ.is_active = True
+ mock_integ.credentials = "encrypted-gd-creds"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
+ mock_session_factory.return_value = mock_db
+
+ mock_gd_handler = MagicMock(return_value=2)
+ original_handler = _USER_WF_CLOUD_HANDLERS.get("google_drive")
+ _USER_WF_CLOUD_HANDLERS["google_drive"] = mock_gd_handler
+ try:
+ with patch(
+ "app.utils.encryption.decrypt_value",
+ return_value='{"credentials_json": "{}"}',
+ ):
+ result = _pull_user_integration_watch_folders()
+
+ assert result["status"] == "ok"
+ assert result["files_enqueued"] == 2
+ mock_gd_handler.assert_called_once()
+ args = mock_gd_handler.call_args
+ assert args[0][0]["source_type"] == "google_drive"
+ assert args[0][4] == "owner-gd"
+ finally:
+ if original_handler is not None:
+ _USER_WF_CLOUD_HANDLERS["google_drive"] = original_handler
+
+ @patch("app.tasks.watch_folder_tasks._get_db_session")
+ @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
+ @patch("app.tasks.watch_folder_tasks._save_cache")
+ def test_dispatches_onedrive_source_type(self, mock_save, mock_load, mock_session_factory):
+ """WATCH_FOLDER with source_type 'onedrive' should dispatch to handler."""
+ from app.tasks.watch_folder_tasks import (
+ _USER_WF_CLOUD_HANDLERS,
+ _pull_user_integration_watch_folders,
+ )
+
+ mock_integ = MagicMock()
+ mock_integ.id = 35
+ mock_integ.owner_id = "owner-od"
+ mock_integ.config = '{"source_type": "onedrive", "folder_path": "/Documents", "delete_after_process": false}'
+ mock_integ.is_active = True
+ mock_integ.credentials = "encrypted-od-creds"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
+ mock_session_factory.return_value = mock_db
+
+ mock_od_handler = MagicMock(return_value=7)
+ original_handler = _USER_WF_CLOUD_HANDLERS.get("onedrive")
+ _USER_WF_CLOUD_HANDLERS["onedrive"] = mock_od_handler
+ try:
+ with patch(
+ "app.utils.encryption.decrypt_value",
+ return_value='{"refresh_token": "rt", "client_id": "ci", "client_secret": "cs"}',
+ ):
+ result = _pull_user_integration_watch_folders()
+
+ assert result["status"] == "ok"
+ assert result["files_enqueued"] == 7
+ mock_od_handler.assert_called_once()
+ args = mock_od_handler.call_args
+ assert args[0][0]["source_type"] == "onedrive"
+ assert args[0][4] == "owner-od"
+ finally:
+ if original_handler is not None:
+ _USER_WF_CLOUD_HANDLERS["onedrive"] = original_handler
+
+ @patch("app.tasks.watch_folder_tasks._get_db_session")
+ @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
+ @patch("app.tasks.watch_folder_tasks._save_cache")
+ def test_dispatches_nextcloud_source_type(self, mock_save, mock_load, mock_session_factory):
+ """WATCH_FOLDER with source_type 'nextcloud' should dispatch to handler."""
+ from app.tasks.watch_folder_tasks import (
+ _USER_WF_CLOUD_HANDLERS,
+ _pull_user_integration_watch_folders,
+ )
+
+ mock_integ = MagicMock()
+ mock_integ.id = 36
+ mock_integ.owner_id = "owner-nc"
+ mock_integ.config = (
+ '{"source_type": "nextcloud", "url": "https://nc.example.com",'
+ ' "folder_path": "/inbox", "delete_after_process": false}'
+ )
+ mock_integ.is_active = True
+ mock_integ.credentials = "encrypted-nc-creds"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
+ mock_session_factory.return_value = mock_db
+
+ mock_nc_handler = MagicMock(return_value=1)
+ original_handler = _USER_WF_CLOUD_HANDLERS.get("nextcloud")
+ _USER_WF_CLOUD_HANDLERS["nextcloud"] = mock_nc_handler
+ try:
+ with patch(
+ "app.utils.encryption.decrypt_value",
+ return_value='{"username": "u", "password": "p"}',
+ ):
+ result = _pull_user_integration_watch_folders()
+
+ assert result["status"] == "ok"
+ assert result["files_enqueued"] == 1
+ mock_nc_handler.assert_called_once()
+ args = mock_nc_handler.call_args
+ assert args[0][0]["source_type"] == "nextcloud"
+ assert args[0][4] == "owner-nc"
+ finally:
+ if original_handler is not None:
+ _USER_WF_CLOUD_HANDLERS["nextcloud"] = original_handler
+
+ @patch("app.tasks.watch_folder_tasks._get_db_session")
+ @patch("app.tasks.watch_folder_tasks._load_cache", return_value={})
+ @patch("app.tasks.watch_folder_tasks._save_cache")
+ def test_dispatches_webdav_source_type(self, mock_save, mock_load, mock_session_factory):
+ """WATCH_FOLDER with source_type 'webdav' should dispatch to handler."""
+ from app.tasks.watch_folder_tasks import (
+ _USER_WF_CLOUD_HANDLERS,
+ _pull_user_integration_watch_folders,
+ )
+
+ mock_integ = MagicMock()
+ mock_integ.id = 37
+ mock_integ.owner_id = "owner-dav"
+ mock_integ.config = (
+ '{"source_type": "webdav", "url": "https://dav.example.com",'
+ ' "folder_path": "/inbox", "delete_after_process": false}'
+ )
+ mock_integ.is_active = True
+ mock_integ.credentials = "encrypted-dav-creds"
+
+ mock_db = MagicMock()
+ mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
+ mock_session_factory.return_value = mock_db
+
+ mock_dav_handler = MagicMock(return_value=6)
+ original_handler = _USER_WF_CLOUD_HANDLERS.get("webdav")
+ _USER_WF_CLOUD_HANDLERS["webdav"] = mock_dav_handler
+ try:
+ with patch(
+ "app.utils.encryption.decrypt_value",
+ return_value='{"username": "u", "password": "p"}',
+ ):
+ result = _pull_user_integration_watch_folders()
+
+ assert result["status"] == "ok"
+ assert result["files_enqueued"] == 6
+ mock_dav_handler.assert_called_once()
+ args = mock_dav_handler.call_args
+ assert args[0][0]["source_type"] == "webdav"
+ assert args[0][4] == "owner-dav"
+ finally:
+ if original_handler is not None:
+ _USER_WF_CLOUD_HANDLERS["webdav"] = original_handler
+
+
+@pytest.mark.unit
+class TestScanUserS3Folder:
+ """Tests for _scan_user_s3_folder per-user S3 scanning."""
+
+ def test_returns_zero_when_bucket_not_configured(self):
+ """Empty bucket config should return 0 immediately."""
+ mock_boto3 = MagicMock()
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+ with patch.dict(
+ "sys.modules", {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc}
+ ):
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"prefix": "inbox/"},
+ creds={"access_key_id": "AKI", "secret_access_key": "SK"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_credentials_incomplete(self):
+ """Missing access_key_id or secret_access_key should return 0."""
+ mock_boto3 = MagicMock()
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+ with patch.dict(
+ "sys.modules", {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc}
+ ):
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"bucket": "my-bucket"},
+ creds={"access_key_id": "AKI"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_client_creation_fails(self):
+ """boto3.client raising should return 0."""
+ mock_boto3 = MagicMock()
+ mock_boto3.client.side_effect = Exception("Bad credentials")
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+ with patch.dict(
+ "sys.modules", {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc}
+ ):
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"bucket": "my-bucket"},
+ creds={"access_key_id": "AKI", "secret_access_key": "SK"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_pagination_fails(self):
+ """Paginator raising should return 0."""
+ mock_boto3 = MagicMock()
+ mock_s3 = MagicMock()
+ mock_boto3.client.return_value = mock_s3
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.side_effect = Exception("Access denied")
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+ with patch.dict(
+ "sys.modules", {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc}
+ ):
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"bucket": "my-bucket"},
+ creds={"access_key_id": "AKI", "secret_access_key": "SK"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_downloads_and_enqueues_new_file(self, tmp_path):
+ """Successful S3 download should enqueue file and return 1."""
+ mock_boto3 = MagicMock()
+ mock_s3 = MagicMock()
+ mock_boto3.client.return_value = mock_s3
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+ mock_botocore_exc.ClientError = type("ClientError", (Exception,), {})
+
+ with (
+ patch.dict(
+ "sys.modules",
+ {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
+ ),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"bucket": "my-bucket", "prefix": "inbox/"},
+ creds={"access_key_id": "AKI", "secret_access_key": "SK"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert result == 1
+ mock_enqueue.assert_called_once()
+ mock_mark.assert_called_once()
+
+ def test_skips_cached_file(self, tmp_path):
+ """Files already in cache should be skipped."""
+ mock_boto3 = MagicMock()
+ mock_s3 = MagicMock()
+ mock_boto3.client.return_value = mock_s3
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+
+ with (
+ patch.dict(
+ "sys.modules",
+ {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
+ ),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"bucket": "my-bucket", "prefix": "inbox/"},
+ creds={"access_key_id": "AKI", "secret_access_key": "SK"},
+ cache={"s3:my-bucket/inbox/doc.pdf": "done"},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert result == 0
+ mock_enqueue.assert_not_called()
+
+ def test_skips_disallowed_file_type(self, tmp_path):
+ """Non-allowed files (e.g. .exe) should be skipped."""
+ mock_boto3 = MagicMock()
+ mock_s3 = MagicMock()
+ mock_boto3.client.return_value = mock_s3
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/virus.exe"}]}]
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+
+ with (
+ patch.dict(
+ "sys.modules",
+ {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
+ ),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=False),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"bucket": "my-bucket", "prefix": "inbox/"},
+ creds={"access_key_id": "AKI", "secret_access_key": "SK"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert result == 0
+ mock_enqueue.assert_not_called()
+
+ def test_deletes_after_download(self, tmp_path):
+ """delete_after=True should call delete_object on the S3 key."""
+ mock_boto3 = MagicMock()
+ mock_s3 = MagicMock()
+ mock_boto3.client.return_value = mock_s3
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+ mock_botocore_exc.ClientError = type("ClientError", (Exception,), {})
+
+ with (
+ patch.dict(
+ "sys.modules",
+ {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
+ ),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file"),
+ patch("app.tasks.watch_folder_tasks._mark_processed"),
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"bucket": "my-bucket", "prefix": "inbox/"},
+ creds={"access_key_id": "AKI", "secret_access_key": "SK"},
+ cache={},
+ delete_after=True,
+ owner_id="u1",
+ )
+
+ assert result == 1
+ mock_s3.delete_object.assert_called_once_with(Bucket="my-bucket", Key="inbox/doc.pdf")
+
+ def test_handles_download_failure(self, tmp_path):
+ """ClientError on download_file should skip the file and continue."""
+ mock_boto3 = MagicMock()
+ mock_s3 = MagicMock()
+ mock_boto3.client.return_value = mock_s3
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]
+
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+ FakeClientError = type("ClientError", (Exception,), {})
+ mock_botocore_exc.ClientError = FakeClientError
+ mock_s3.download_file.side_effect = FakeClientError("403 Forbidden")
+
+ with (
+ patch.dict(
+ "sys.modules",
+ {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
+ ),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"bucket": "my-bucket", "prefix": "inbox/"},
+ creds={"access_key_id": "AKI", "secret_access_key": "SK"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert result == 0
+ mock_enqueue.assert_not_called()
+
+ def test_handles_delete_failure_gracefully(self, tmp_path):
+ """delete_object raising should log warning but not crash."""
+ mock_boto3 = MagicMock()
+ mock_s3 = MagicMock()
+ mock_boto3.client.return_value = mock_s3
+ mock_paginator = MagicMock()
+ mock_s3.get_paginator.return_value = mock_paginator
+ mock_paginator.paginate.return_value = [{"Contents": [{"Key": "inbox/doc.pdf"}]}]
+ mock_s3.delete_object.side_effect = Exception("Delete denied")
+ mock_botocore = MagicMock()
+ mock_botocore_exc = MagicMock()
+ mock_botocore_exc.ClientError = type("ClientError", (Exception,), {})
+
+ with (
+ patch.dict(
+ "sys.modules",
+ {"boto3": mock_boto3, "botocore": mock_botocore, "botocore.exceptions": mock_botocore_exc},
+ ),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file"),
+ patch("app.tasks.watch_folder_tasks._mark_processed"),
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_s3_folder
+
+ result = _scan_user_s3_folder(
+ cfg={"bucket": "my-bucket", "prefix": "inbox/"},
+ creds={"access_key_id": "AKI", "secret_access_key": "SK"},
+ cache={},
+ delete_after=True,
+ owner_id="u1",
+ )
+
+ assert result == 1
+
+
+@pytest.mark.unit
+class TestScanUserDropboxFolder:
+ """Tests for _scan_user_dropbox_folder per-user Dropbox scanning."""
+
+ def _make_dropbox_mocks(self):
+ """Create standard dropbox mock module and helper types."""
+ mock_dropbox_mod = MagicMock()
+ FakeFileMeta = type("FileMetadata", (), {})
+ FakeFolderMeta = type("FolderMetadata", (), {})
+ mock_dropbox_mod.files.FileMetadata = FakeFileMeta
+ mock_dropbox_mod.files.FolderMetadata = FakeFolderMeta
+ return mock_dropbox_mod, FakeFileMeta, FakeFolderMeta
+
+ def test_returns_zero_when_credentials_incomplete(self):
+ """Missing refresh_token/app_key/app_secret should return 0."""
+ mock_dropbox_mod, _, _ = self._make_dropbox_mocks()
+ with patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}):
+ from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder
+
+ result = _scan_user_dropbox_folder(
+ cfg={"folder_path": "/inbox"},
+ creds={"refresh_token": "tok", "app_key": ""},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_folder_path_empty(self):
+ """Empty folder_path should return 0."""
+ mock_dropbox_mod, _, _ = self._make_dropbox_mocks()
+ with patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}):
+ from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder
+
+ result = _scan_user_dropbox_folder(
+ cfg={"folder_path": ""},
+ creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_auth_fails(self):
+ """Dropbox constructor raising should return 0."""
+ mock_dropbox_mod, _, _ = self._make_dropbox_mocks()
+ mock_dropbox_mod.Dropbox.side_effect = Exception("Auth error")
+ with patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}):
+ from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder
+
+ result = _scan_user_dropbox_folder(
+ cfg={"folder_path": "/inbox"},
+ creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_listing_fails(self):
+ """files_list_folder raising should return 0."""
+ mock_dropbox_mod, _, _ = self._make_dropbox_mocks()
+ mock_dbx_client = MagicMock()
+ mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
+ mock_dbx_client.files_list_folder.side_effect = Exception("API error")
+
+ with patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}):
+ from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder
+
+ result = _scan_user_dropbox_folder(
+ cfg={"folder_path": "/inbox"},
+ creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_downloads_and_enqueues_new_file(self, tmp_path):
+ """Successful Dropbox download should enqueue file and return 1."""
+ mock_dropbox_mod, FakeFileMeta, _ = self._make_dropbox_mocks()
+ entry = FakeFileMeta()
+ entry.name = "doc.pdf"
+ entry.id = "id:abc123"
+ entry.path_lower = "/inbox/doc.pdf"
+
+ mock_dbx_client = MagicMock()
+ mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
+ result_obj = MagicMock()
+ result_obj.entries = [entry]
+ result_obj.has_more = False
+ mock_dbx_client.files_list_folder.return_value = result_obj
+
+ mock_response = MagicMock()
+ mock_response.content = b"%PDF-1.4 test"
+ mock_dbx_client.files_download.return_value = (MagicMock(), mock_response)
+
+ with (
+ patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder
+
+ count = _scan_user_dropbox_folder(
+ cfg={"folder_path": "/inbox"},
+ creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_enqueue.assert_called_once()
+ mock_mark.assert_called_once()
+
+ def test_skips_cached_file(self, tmp_path):
+ """Files already in cache should be skipped."""
+ mock_dropbox_mod, FakeFileMeta, _ = self._make_dropbox_mocks()
+ entry = FakeFileMeta()
+ entry.name = "doc.pdf"
+ entry.id = "id:abc123"
+ entry.path_lower = "/inbox/doc.pdf"
+
+ mock_dbx_client = MagicMock()
+ mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
+ result_obj = MagicMock()
+ result_obj.entries = [entry]
+ result_obj.has_more = False
+ mock_dbx_client.files_list_folder.return_value = result_obj
+
+ with (
+ patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder
+
+ count = _scan_user_dropbox_folder(
+ cfg={"folder_path": "/inbox"},
+ creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
+ cache={"dropbox:id:abc123": "done"},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_skips_non_file_entries(self, tmp_path):
+ """FolderMetadata entries should be skipped."""
+ mock_dropbox_mod, _, FakeFolderMeta = self._make_dropbox_mocks()
+ folder_entry = FakeFolderMeta()
+ folder_entry.name = "subfolder"
+
+ mock_dbx_client = MagicMock()
+ mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
+ result_obj = MagicMock()
+ result_obj.entries = [folder_entry]
+ result_obj.has_more = False
+ mock_dbx_client.files_list_folder.return_value = result_obj
+
+ with (
+ patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder
+
+ count = _scan_user_dropbox_folder(
+ cfg={"folder_path": "/inbox"},
+ creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_deletes_after_download(self, tmp_path):
+ """delete_after=True should call files_delete_v2."""
+ mock_dropbox_mod, FakeFileMeta, _ = self._make_dropbox_mocks()
+ entry = FakeFileMeta()
+ entry.name = "doc.pdf"
+ entry.id = "id:abc123"
+ entry.path_lower = "/inbox/doc.pdf"
+
+ mock_dbx_client = MagicMock()
+ mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
+ result_obj = MagicMock()
+ result_obj.entries = [entry]
+ result_obj.has_more = False
+ mock_dbx_client.files_list_folder.return_value = result_obj
+
+ mock_response = MagicMock()
+ mock_response.content = b"%PDF-1.4 test"
+ mock_dbx_client.files_download.return_value = (MagicMock(), mock_response)
+
+ with (
+ patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file"),
+ patch("app.tasks.watch_folder_tasks._mark_processed"),
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder
+
+ count = _scan_user_dropbox_folder(
+ cfg={"folder_path": "/inbox"},
+ creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
+ cache={},
+ delete_after=True,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_dbx_client.files_delete_v2.assert_called_once_with("/inbox/doc.pdf")
+
+ def test_handles_download_failure(self, tmp_path):
+ """Download failure should skip the file and continue."""
+ mock_dropbox_mod, FakeFileMeta, _ = self._make_dropbox_mocks()
+ entry = FakeFileMeta()
+ entry.name = "doc.pdf"
+ entry.id = "id:abc123"
+ entry.path_lower = "/inbox/doc.pdf"
+
+ mock_dbx_client = MagicMock()
+ mock_dropbox_mod.Dropbox.return_value = mock_dbx_client
+ result_obj = MagicMock()
+ result_obj.entries = [entry]
+ result_obj.has_more = False
+ mock_dbx_client.files_list_folder.return_value = result_obj
+ mock_dbx_client.files_download.side_effect = Exception("Download failed")
+
+ with (
+ patch.dict("sys.modules", {"dropbox": mock_dropbox_mod}),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_dropbox_folder
+
+ count = _scan_user_dropbox_folder(
+ cfg={"folder_path": "/inbox"},
+ creds={"refresh_token": "tok", "app_key": "ak", "app_secret": "as"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+
+@pytest.mark.unit
+class TestScanUserGoogleDriveFolder:
+ """Tests for _scan_user_google_drive_folder per-user Google Drive scanning."""
+
+ def _make_google_mocks(self):
+ """Create Google API mock modules for sys.modules patching."""
+ mock_sa_mod = MagicMock()
+ mock_gapi_disc = MagicMock()
+ mock_gapi_http = MagicMock()
+
+ mock_service = MagicMock()
+ mock_gapi_disc.build.return_value = mock_service
+ mock_sa_mod.Credentials.from_service_account_info.return_value = MagicMock()
+
+ # Wire parent-child module attributes so `from X.Y import Z` resolves correctly
+ mock_google_oauth2 = MagicMock()
+ mock_google_oauth2.service_account = mock_sa_mod
+ mock_google = MagicMock()
+ mock_google.oauth2 = mock_google_oauth2
+
+ mock_gapi = MagicMock()
+ mock_gapi.discovery = mock_gapi_disc
+ mock_gapi.http = mock_gapi_http
+
+ modules = {
+ "google": mock_google,
+ "google.oauth2": mock_google_oauth2,
+ "google.oauth2.service_account": mock_sa_mod,
+ "googleapiclient": mock_gapi,
+ "googleapiclient.discovery": mock_gapi_disc,
+ "googleapiclient.http": mock_gapi_http,
+ }
+ return modules, mock_service, mock_gapi_http, mock_sa_mod
+
+ def test_returns_zero_when_credentials_json_empty(self):
+ """Empty credentials_json should return 0."""
+ modules, _, _, _ = self._make_google_mocks()
+ with patch.dict("sys.modules", modules):
+ from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder
+
+ result = _scan_user_google_drive_folder(
+ cfg={"folder_id": "abc"},
+ creds={"credentials_json": ""},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_folder_id_empty(self):
+ """Empty folder_id should return 0."""
+ modules, _, _, _ = self._make_google_mocks()
+ with patch.dict("sys.modules", modules):
+ from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder
+
+ result = _scan_user_google_drive_folder(
+ cfg={"folder_id": ""},
+ creds={"credentials_json": '{"type": "service_account"}'},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_auth_fails(self):
+ """service_account.Credentials raising should return 0."""
+ modules, _, _, mock_sa_mod = self._make_google_mocks()
+ mock_sa_mod.Credentials.from_service_account_info.side_effect = Exception("Bad SA key")
+ with patch.dict("sys.modules", modules):
+ from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder
+
+ result = _scan_user_google_drive_folder(
+ cfg={"folder_id": "abc"},
+ creds={"credentials_json": '{"type": "service_account"}'},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_downloads_and_enqueues_new_file(self, tmp_path):
+ """Successful Google Drive download should enqueue file and return 1."""
+ modules, mock_service, mock_gapi_http, _ = self._make_google_mocks()
+
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "f1", "name": "doc.pdf", "mimeType": "application/pdf"}],
+ }
+
+ mock_downloader = MagicMock()
+ mock_downloader.next_chunk.return_value = (None, True)
+ mock_gapi_http.MediaIoBaseDownload.return_value = mock_downloader
+
+ with (
+ patch.dict("sys.modules", modules),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder
+
+ count = _scan_user_google_drive_folder(
+ cfg={"folder_id": "abc"},
+ creds={"credentials_json": '{"type": "service_account"}'},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_enqueue.assert_called_once()
+ mock_mark.assert_called_once()
+
+ def test_skips_cached_file(self, tmp_path):
+ """Files already in cache should be skipped."""
+ modules, mock_service, _, _ = self._make_google_mocks()
+
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "f1", "name": "doc.pdf", "mimeType": "application/pdf"}],
+ }
+
+ with (
+ patch.dict("sys.modules", modules),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder
+
+ count = _scan_user_google_drive_folder(
+ cfg={"folder_id": "abc"},
+ creds={"credentials_json": '{"type": "service_account"}'},
+ cache={"gdrive:f1": "done"},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_deletes_after_download(self, tmp_path):
+ """delete_after=True should call files().delete()."""
+ modules, mock_service, mock_gapi_http, _ = self._make_google_mocks()
+
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "f1", "name": "doc.pdf", "mimeType": "application/pdf"}],
+ }
+ mock_downloader = MagicMock()
+ mock_downloader.next_chunk.return_value = (None, True)
+ mock_gapi_http.MediaIoBaseDownload.return_value = mock_downloader
+
+ with (
+ patch.dict("sys.modules", modules),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file"),
+ patch("app.tasks.watch_folder_tasks._mark_processed"),
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder
+
+ count = _scan_user_google_drive_folder(
+ cfg={"folder_id": "abc"},
+ creds={"credentials_json": '{"type": "service_account"}'},
+ cache={},
+ delete_after=True,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_service.files.return_value.delete.assert_called_once_with(fileId="f1")
+
+ def test_handles_download_failure(self, tmp_path):
+ """Download failure should skip the file and continue."""
+ modules, mock_service, mock_gapi_http, _ = self._make_google_mocks()
+
+ mock_service.files.return_value.list.return_value.execute.return_value = {
+ "files": [{"id": "f1", "name": "doc.pdf", "mimeType": "application/pdf"}],
+ }
+ mock_gapi_http.MediaIoBaseDownload.side_effect = Exception("Download failed")
+
+ with (
+ patch.dict("sys.modules", modules),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder
+
+ count = _scan_user_google_drive_folder(
+ cfg={"folder_id": "abc"},
+ creds={"credentials_json": '{"type": "service_account"}'},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_handles_listing_failure(self, tmp_path):
+ """Listing failure should break and return 0."""
+ modules, mock_service, _, _ = self._make_google_mocks()
+
+ mock_service.files.return_value.list.return_value.execute.side_effect = Exception("API error")
+
+ with (
+ patch.dict("sys.modules", modules),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ from app.tasks.watch_folder_tasks import _scan_user_google_drive_folder
+
+ count = _scan_user_google_drive_folder(
+ cfg={"folder_id": "abc"},
+ creds={"credentials_json": '{"type": "service_account"}'},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+
+@pytest.mark.unit
+class TestScanUserOneDriveFolder:
+ """Tests for _scan_user_onedrive_folder per-user OneDrive scanning."""
+
+ def test_returns_zero_when_credentials_incomplete(self):
+ """Missing refresh_token/client_id/client_secret should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ result = _scan_user_onedrive_folder(
+ cfg={"folder_path": "/Documents"},
+ creds={"refresh_token": "tok", "client_id": ""},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_folder_path_empty(self):
+ """Empty folder_path should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ result = _scan_user_onedrive_folder(
+ cfg={"folder_path": ""},
+ creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_token_exchange_fails(self):
+ """Token exchange failure should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ with patch("requests.post") as mock_post:
+ mock_post.side_effect = Exception("Token error")
+
+ result = _scan_user_onedrive_folder(
+ cfg={"folder_path": "/Documents"},
+ creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_downloads_and_enqueues_new_file(self, tmp_path):
+ """Successful OneDrive download should enqueue file and return 1."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ mock_token_resp = MagicMock()
+ mock_token_resp.json.return_value = {"access_token": "tok123"}
+
+ mock_list_resp = MagicMock()
+ mock_list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "doc.pdf",
+ "id": "item1",
+ "@microsoft.graph.downloadUrl": "https://dl.example.com/doc.pdf",
+ }
+ ],
+ }
+
+ mock_dl_resp = MagicMock()
+ mock_dl_resp.content = b"%PDF-1.4"
+
+ with (
+ patch("requests.post", return_value=mock_token_resp),
+ patch("requests.get", side_effect=[mock_list_resp, mock_dl_resp]),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_onedrive_folder(
+ cfg={"folder_path": "/Documents"},
+ creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_enqueue.assert_called_once()
+ mock_mark.assert_called_once()
+
+ def test_skips_folders(self, tmp_path):
+ """Items with 'folder' key should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ mock_token_resp = MagicMock()
+ mock_token_resp.json.return_value = {"access_token": "tok123"}
+
+ mock_list_resp = MagicMock()
+ mock_list_resp.json.return_value = {
+ "value": [{"name": "subfolder", "id": "fold1", "folder": {"childCount": 3}}],
+ }
+
+ with (
+ patch("requests.post", return_value=mock_token_resp),
+ patch("requests.get", return_value=mock_list_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_onedrive_folder(
+ cfg={"folder_path": "/Documents"},
+ creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_skips_cached_file(self, tmp_path):
+ """Files already in cache should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ mock_token_resp = MagicMock()
+ mock_token_resp.json.return_value = {"access_token": "tok123"}
+
+ mock_list_resp = MagicMock()
+ mock_list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "doc.pdf",
+ "id": "item1",
+ "@microsoft.graph.downloadUrl": "https://dl.example.com/doc.pdf",
+ }
+ ],
+ }
+
+ with (
+ patch("requests.post", return_value=mock_token_resp),
+ patch("requests.get", return_value=mock_list_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_onedrive_folder(
+ cfg={"folder_path": "/Documents"},
+ creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
+ cache={"onedrive:item1": "done"},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_skips_items_without_download_url(self, tmp_path):
+ """Items missing @microsoft.graph.downloadUrl should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ mock_token_resp = MagicMock()
+ mock_token_resp.json.return_value = {"access_token": "tok123"}
+
+ mock_list_resp = MagicMock()
+ mock_list_resp.json.return_value = {
+ "value": [{"name": "doc.pdf", "id": "item1"}],
+ }
+
+ with (
+ patch("requests.post", return_value=mock_token_resp),
+ patch("requests.get", return_value=mock_list_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_onedrive_folder(
+ cfg={"folder_path": "/Documents"},
+ creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_deletes_after_download(self, tmp_path):
+ """delete_after=True should call requests.delete on the item."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ mock_token_resp = MagicMock()
+ mock_token_resp.json.return_value = {"access_token": "tok123"}
+
+ mock_list_resp = MagicMock()
+ mock_list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "doc.pdf",
+ "id": "item1",
+ "@microsoft.graph.downloadUrl": "https://dl.example.com/doc.pdf",
+ }
+ ],
+ }
+
+ mock_dl_resp = MagicMock()
+ mock_dl_resp.content = b"%PDF-1.4"
+ mock_del_resp = MagicMock()
+
+ with (
+ patch("requests.post", return_value=mock_token_resp),
+ patch("requests.get", side_effect=[mock_list_resp, mock_dl_resp]),
+ patch("requests.delete", return_value=mock_del_resp) as mock_delete,
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file"),
+ patch("app.tasks.watch_folder_tasks._mark_processed"),
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_onedrive_folder(
+ cfg={"folder_path": "/Documents"},
+ creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
+ cache={},
+ delete_after=True,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_delete.assert_called_once()
+
+ def test_handles_download_failure(self, tmp_path):
+ """Download failure should skip the file and continue."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ mock_token_resp = MagicMock()
+ mock_token_resp.json.return_value = {"access_token": "tok123"}
+
+ mock_list_resp = MagicMock()
+ mock_list_resp.json.return_value = {
+ "value": [
+ {
+ "name": "doc.pdf",
+ "id": "item1",
+ "@microsoft.graph.downloadUrl": "https://dl.example.com/doc.pdf",
+ }
+ ],
+ }
+
+ mock_dl_resp = MagicMock()
+ mock_dl_resp.raise_for_status.side_effect = Exception("Download failed")
+
+ with (
+ patch("requests.post", return_value=mock_token_resp),
+ patch("requests.get", side_effect=[mock_list_resp, mock_dl_resp]),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_onedrive_folder(
+ cfg={"folder_path": "/Documents"},
+ creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_handles_listing_failure(self, tmp_path):
+ """Listing failure should break and return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_onedrive_folder
+
+ mock_token_resp = MagicMock()
+ mock_token_resp.json.return_value = {"access_token": "tok123"}
+
+ mock_list_resp = MagicMock()
+ mock_list_resp.raise_for_status.side_effect = Exception("Listing failed")
+
+ with (
+ patch("requests.post", return_value=mock_token_resp),
+ patch("requests.get", return_value=mock_list_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_onedrive_folder(
+ cfg={"folder_path": "/Documents"},
+ creds={"refresh_token": "tok", "client_id": "ci", "client_secret": "cs"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+
+def _build_propfind_xml(href_paths: list[str]) -> str:
+ """Build a minimal PROPFIND multi-status XML response for testing."""
+ responses = ""
+ for href in href_paths:
+ responses += f"""
+
+ {href}
+
+ 1024
+ HTTP/1.1 200 OK
+
+ """
+ return f"""
+
+ {responses}
+ """
+
+
+@pytest.mark.unit
+class TestScanUserNextcloudFolder:
+ """Tests for _scan_user_nextcloud_folder per-user Nextcloud scanning."""
+
+ def test_returns_zero_when_settings_incomplete(self):
+ """Missing url, username, or password should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder
+
+ result = _scan_user_nextcloud_folder(
+ cfg={"url": "", "folder_path": "/inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_propfind_fails(self):
+ """PROPFIND failure should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder
+
+ with patch("requests.request") as mock_request:
+ mock_request.side_effect = Exception("Connection refused")
+
+ result = _scan_user_nextcloud_folder(
+ cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_xml_parse_fails(self):
+ """Invalid XML response should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder
+
+ mock_resp = MagicMock()
+ mock_resp.text = "not valid xml <<<"
+
+ with patch("requests.request", return_value=mock_resp):
+ result = _scan_user_nextcloud_folder(
+ cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_downloads_and_enqueues_new_file(self, tmp_path):
+ """Successful Nextcloud download should enqueue file and return 1."""
+ from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/remote.php/dav/files/user/inbox/",
+ "/remote.php/dav/files/user/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ mock_dl_resp = MagicMock()
+ mock_dl_resp.content = b"%PDF-1.4"
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("requests.get", return_value=mock_dl_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_nextcloud_folder(
+ cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_enqueue.assert_called_once()
+ mock_mark.assert_called_once()
+
+ def test_skips_folder_self_entry(self, tmp_path):
+ """The folder itself (self entry) should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder
+
+ propfind_xml = _build_propfind_xml(["/remote.php/dav/files/user/inbox/"])
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_nextcloud_folder(
+ cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_skips_cached_file(self, tmp_path):
+ """Files already in cache should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/remote.php/dav/files/user/inbox/",
+ "/remote.php/dav/files/user/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_nextcloud_folder(
+ cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={"nextcloud:/remote.php/dav/files/user/inbox/doc.pdf": "done"},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_deletes_after_download(self, tmp_path):
+ """delete_after=True should send DELETE request."""
+ from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/remote.php/dav/files/user/inbox/",
+ "/remote.php/dav/files/user/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ mock_dl_resp = MagicMock()
+ mock_dl_resp.content = b"%PDF-1.4"
+
+ mock_del_resp = MagicMock()
+
+ with (
+ patch("requests.request", side_effect=[mock_propfind_resp, mock_del_resp]) as mock_request,
+ patch("requests.get", return_value=mock_dl_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file"),
+ patch("app.tasks.watch_folder_tasks._mark_processed"),
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_nextcloud_folder(
+ cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=True,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ assert mock_request.call_count == 2
+ delete_call = mock_request.call_args_list[1]
+ assert delete_call[0][0] == "DELETE"
+
+ def test_handles_download_failure(self, tmp_path):
+ """Download failure should skip the file and continue."""
+ from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/remote.php/dav/files/user/inbox/",
+ "/remote.php/dav/files/user/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("requests.get") as mock_get,
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ mock_get.side_effect = Exception("Download failed")
+
+ count = _scan_user_nextcloud_folder(
+ cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_handles_absolute_href_url(self, tmp_path):
+ """Absolute href URLs should be used directly for download."""
+ from app.tasks.watch_folder_tasks import _scan_user_nextcloud_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/remote.php/dav/files/user/inbox/",
+ "https://nc.example.com/remote.php/dav/files/user/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ mock_dl_resp = MagicMock()
+ mock_dl_resp.content = b"%PDF-1.4"
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("requests.get", return_value=mock_dl_resp) as mock_get,
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._mark_processed"),
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_nextcloud_folder(
+ cfg={"url": "https://nc.example.com/remote.php/dav/files/user", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_enqueue.assert_called_once()
+ download_url = mock_get.call_args[0][0]
+ assert download_url.startswith("https://")
+
+
+@pytest.mark.unit
+class TestScanUserWebdavFolder:
+ """Tests for _scan_user_webdav_folder per-user WebDAV scanning."""
+
+ def test_returns_zero_when_url_not_configured(self):
+ """Missing URL should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_webdav_folder
+
+ result = _scan_user_webdav_folder(
+ cfg={"url": "", "folder_path": "/inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_propfind_fails(self):
+ """PROPFIND failure should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_webdav_folder
+
+ with patch("requests.request") as mock_request:
+ mock_request.side_effect = Exception("Connection refused")
+
+ result = _scan_user_webdav_folder(
+ cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_returns_zero_when_xml_parse_fails(self):
+ """Invalid XML response should return 0."""
+ from app.tasks.watch_folder_tasks import _scan_user_webdav_folder
+
+ mock_resp = MagicMock()
+ mock_resp.text = "not valid xml <<<"
+
+ with patch("requests.request", return_value=mock_resp):
+ result = _scan_user_webdav_folder(
+ cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+ assert result == 0
+
+ def test_downloads_and_enqueues_new_file(self, tmp_path):
+ """Successful WebDAV download should enqueue file and return 1."""
+ from app.tasks.watch_folder_tasks import _scan_user_webdav_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/inbox/",
+ "/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ mock_dl_resp = MagicMock()
+ mock_dl_resp.content = b"%PDF-1.4"
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("requests.get", return_value=mock_dl_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._mark_processed") as mock_mark,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_webdav_folder(
+ cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_enqueue.assert_called_once()
+ mock_mark.assert_called_once()
+
+ def test_skips_directory_entries(self, tmp_path):
+ """Entries ending with / should be skipped (directories)."""
+ from app.tasks.watch_folder_tasks import _scan_user_webdav_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/inbox/",
+ "/inbox/subdir/",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_webdav_folder(
+ cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_skips_cached_file(self, tmp_path):
+ """Files already in cache should be skipped."""
+ from app.tasks.watch_folder_tasks import _scan_user_webdav_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/inbox/",
+ "/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_webdav_folder(
+ cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={"webdav:/inbox/doc.pdf": "done"},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_deletes_after_download(self, tmp_path):
+ """delete_after=True should send DELETE request."""
+ from app.tasks.watch_folder_tasks import _scan_user_webdav_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/inbox/",
+ "/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ mock_dl_resp = MagicMock()
+ mock_dl_resp.content = b"%PDF-1.4"
+
+ mock_del_resp = MagicMock()
+
+ with (
+ patch("requests.request", side_effect=[mock_propfind_resp, mock_del_resp]) as mock_request,
+ patch("requests.get", return_value=mock_dl_resp),
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file"),
+ patch("app.tasks.watch_folder_tasks._mark_processed"),
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_webdav_folder(
+ cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=True,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ assert mock_request.call_count == 2
+ delete_call = mock_request.call_args_list[1]
+ assert delete_call[0][0] == "DELETE"
+
+ def test_handles_download_failure(self, tmp_path):
+ """Download failure should skip the file and continue."""
+ from app.tasks.watch_folder_tasks import _scan_user_webdav_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/inbox/",
+ "/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("requests.get") as mock_get,
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+ mock_get.side_effect = Exception("Download failed")
+
+ count = _scan_user_webdav_folder(
+ cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 0
+ mock_enqueue.assert_not_called()
+
+ def test_handles_absolute_href_url(self, tmp_path):
+ """Absolute href URLs should be used directly for download."""
+ from app.tasks.watch_folder_tasks import _scan_user_webdav_folder
+
+ propfind_xml = _build_propfind_xml(
+ [
+ "/inbox/",
+ "https://dav.example.com/inbox/doc.pdf",
+ ]
+ )
+
+ mock_propfind_resp = MagicMock()
+ mock_propfind_resp.text = propfind_xml
+
+ mock_dl_resp = MagicMock()
+ mock_dl_resp.content = b"%PDF-1.4"
+
+ with (
+ patch("requests.request", return_value=mock_propfind_resp),
+ patch("requests.get", return_value=mock_dl_resp) as mock_get,
+ patch("app.tasks.watch_folder_tasks.settings") as mock_settings,
+ patch("app.tasks.watch_folder_tasks._enqueue_file") as mock_enqueue,
+ patch("app.tasks.watch_folder_tasks._mark_processed"),
+ patch("app.tasks.watch_folder_tasks._is_allowed_file", return_value=True),
+ ):
+ mock_settings.workdir = str(tmp_path)
+ mock_settings.http_request_timeout = 30
+
+ count = _scan_user_webdav_folder(
+ cfg={"url": "https://dav.example.com", "folder_path": "inbox"},
+ creds={"username": "u", "password": "p"},
+ cache={},
+ delete_after=False,
+ owner_id="u1",
+ )
+
+ assert count == 1
+ mock_enqueue.assert_called_once()
+ download_url = mock_get.call_args[0][0]
+ assert download_url == "https://dav.example.com/inbox/doc.pdf"
+
@pytest.mark.unit
class TestScanAllWatchFoldersIncludesUserIntegrations: