test: add comprehensive tests for critical untested files and fix deprecated warnings

- Add tests for migrate_logs_to_steps.py, upload_to_paperless.py, dropbox API,
  upload_to_dropbox.py, upload_to_nextcloud.py, upload_to_onedrive.py,
  upload_with_rclone.py, and config_validator.py
- Fix PydanticDeprecatedSince20: @validator → @field_validator in config.py, url_upload.py
- Fix PydanticDeprecatedSince20: class Config → model_config = SettingsConfigDict
- Fix PydanticDeprecatedSince211: filter Pydantic internals in settings_display.py
- Fix MovedIn20Warning: use sqlalchemy.orm.declarative_base instead of ext.declarative

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-12 17:32:17 +00:00
parent fca5236b47
commit dff32e08e1
12 changed files with 2840 additions and 35 deletions
+3 -2
View File
@@ -12,7 +12,7 @@ from typing import Optional
import requests import requests
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, HttpUrl, validator from pydantic import BaseModel, HttpUrl, field_validator
from app.auth import require_login from app.auth import require_login
from app.config import settings from app.config import settings
@@ -31,7 +31,8 @@ class URLUploadRequest(BaseModel):
url: HttpUrl url: HttpUrl
filename: Optional[str] = None filename: Optional[str] = None
@validator("url") @field_validator("url")
@classmethod
def validate_url_scheme(cls, v): def validate_url_scheme(cls, v):
"""Ensure only HTTP/HTTPS schemes are allowed""" """Ensure only HTTP/HTTPS schemes are allowed"""
parsed = urllib.parse.urlparse(str(v)) parsed = urllib.parse.urlparse(str(v))
+11 -30
View File
@@ -3,11 +3,13 @@
import os import os
from typing import Any, List, Optional, Union from typing import Any, List, Optional, Union
from pydantic import Field, validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
database_url: str database_url: str
redis_url: str redis_url: str
openai_api_key: str openai_api_key: str
@@ -265,7 +267,8 @@ class Settings(BaseSettings):
description="Stricter rate limit for authentication endpoints to prevent brute force attacks.", description="Stricter rate limit for authentication endpoints to prevent brute force attacks.",
) )
@validator("notification_urls", pre=True) @field_validator("notification_urls", mode="before")
@classmethod
def parse_notification_urls(cls, v): def parse_notification_urls(cls, v):
"""Parse notification URLs from string or list""" """Parse notification URLs from string or list"""
if isinstance(v, str): if isinstance(v, str):
@@ -276,12 +279,13 @@ class Settings(BaseSettings):
return [] return []
return v return v
@validator("session_secret") @field_validator("session_secret")
def validate_session_secret(cls, v, values): @classmethod
def validate_session_secret(cls, v, info):
"""Validate that session_secret is set and has sufficient length when auth is enabled""" """Validate that session_secret is set and has sufficient length when auth is enabled"""
if values.get("auth_enabled") and not v: if info.data.get("auth_enabled") and not v:
raise ValueError("SESSION_SECRET must be set when AUTH_ENABLED=True") raise ValueError("SESSION_SECRET must be set when AUTH_ENABLED=True")
if values.get("auth_enabled") and v and len(v) < 32: if info.data.get("auth_enabled") and v and len(v) < 32:
raise ValueError("SESSION_SECRET must be at least 32 characters long") raise ValueError("SESSION_SECRET must be at least 32 characters long")
return v return v
@@ -347,28 +351,5 @@ class Settings(BaseSettings):
# Return basic info if file not found # Return basic info if file not found
return f"Version: {self.version}\nBuild Date: {self.build_date}\nGit SHA: {self.git_sha}" return f"Version: {self.version}\nBuild Date: {self.build_date}\nGit SHA: {self.git_sha}"
class Config:
env_file = ".env"
# Convert string representations of booleans to actual booleans
# and strip quotes from string values
@classmethod
def parse_env_var(cls, field_name: str, raw_val: str) -> Any:
# First, strip quotes from the value if it's a string
if isinstance(raw_val, str):
if (raw_val.startswith('"') and raw_val.endswith('"')) or (
raw_val.startswith("'") and raw_val.endswith("'")
):
raw_val = raw_val[1:-1]
raw_val = raw_val.strip()
# Convert string representations of booleans to actual booleans
if field_name.endswith("_enabled") or field_name == "debug":
if raw_val.lower() in ("false", "0", "no", "n", "f"):
return False
if raw_val.lower() in ("true", "1", "yes", "y", "t"):
return True
return raw_val
settings = Settings() settings = Settings()
+1 -1
View File
@@ -5,7 +5,7 @@ import os
from sqlalchemy import create_engine, exc from sqlalchemy import create_engine, exc
from sqlalchemy.engine.url import make_url from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from app.config import settings from app.config import settings
@@ -9,12 +9,15 @@ from app.utils.config_validator.masking import mask_sensitive_value
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Pydantic model attributes that should not be iterated as user settings
_PYDANTIC_INTERNALS = {"model_computed_fields", "model_config", "model_extra", "model_fields", "model_fields_set"}
def dump_all_settings(): def dump_all_settings():
"""Log all settings values for diagnostic purposes""" """Log all settings values for diagnostic purposes"""
logger.info("--- DUMPING ALL SETTINGS FOR DIAGNOSTIC PURPOSES ---") logger.info("--- DUMPING ALL SETTINGS FOR DIAGNOSTIC PURPOSES ---")
for key in dir(settings): for key in dir(settings):
if not key.startswith("_") and not callable(getattr(settings, key)): if not key.startswith("_") and key not in _PYDANTIC_INTERNALS and not callable(getattr(settings, key)):
value = getattr(settings, key) value = getattr(settings, key)
# Mask sensitive values in logs # Mask sensitive values in logs
if ( if (
@@ -190,8 +193,8 @@ def get_settings_for_display(show_values=False):
key key
for key in dir(settings) for key in dir(settings)
if not key.startswith("_") if not key.startswith("_")
and key not in _PYDANTIC_INTERNALS
and not callable(getattr(settings, key)) and not callable(getattr(settings, key))
and key not in ["model_computed_fields", "model_config", "model_extra", "model_fields", "model_fields_set"]
] ]
) )
+309
View File
@@ -0,0 +1,309 @@
"""
Tests for app/api/dropbox.py module.
Covers Dropbox OAuth endpoints, settings management, and token testing.
"""
import os
from unittest.mock import MagicMock, Mock, patch
import pytest
import requests
@pytest.mark.unit
class TestExchangeDropboxToken:
"""Tests for exchange_dropbox_token endpoint."""
@patch("app.api.dropbox.exchange_oauth_token")
def test_successful_exchange(self, mock_exchange, client):
"""Test successful OAuth token exchange."""
mock_exchange.return_value = {
"refresh_token": "test-refresh",
"access_token": "test-access",
"expires_in": 14400,
}
response = client.post(
"/api/dropbox/exchange-token",
data={
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"redirect_uri": "http://localhost/callback",
"code": "test-auth-code",
},
)
assert response.status_code == 200
data = response.json()
assert data["refresh_token"] == "test-refresh"
assert data["access_token"] == "test-access"
assert data["expires_in"] == 14400
@patch("app.api.dropbox.exchange_oauth_token")
def test_exchange_default_expiry(self, mock_exchange, client):
"""Test token exchange returns default expires_in when not provided."""
mock_exchange.return_value = {
"refresh_token": "test-refresh",
"access_token": "test-access",
}
response = client.post(
"/api/dropbox/exchange-token",
data={
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"redirect_uri": "http://localhost/callback",
"code": "test-auth-code",
},
)
assert response.status_code == 200
data = response.json()
assert data["expires_in"] == 14400
@pytest.mark.unit
class TestUpdateDropboxSettings:
"""Tests for update_dropbox_settings endpoint."""
@patch("app.api.dropbox.settings")
def test_update_all_settings(self, mock_settings, client):
"""Test updating all Dropbox settings in memory."""
mock_settings.dropbox_refresh_token = ""
mock_settings.dropbox_app_key = ""
mock_settings.dropbox_app_secret = ""
mock_settings.dropbox_folder = ""
response = client.post(
"/api/dropbox/update-settings",
data={
"refresh_token": "new-refresh-token",
"app_key": "new-app-key",
"app_secret": "new-app-secret",
"folder_path": "/Documents",
},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
@patch("app.api.dropbox.settings")
def test_update_refresh_token_only(self, mock_settings, client):
"""Test updating only refresh token."""
mock_settings.dropbox_refresh_token = ""
response = client.post(
"/api/dropbox/update-settings",
data={
"refresh_token": "new-refresh-token",
},
)
assert response.status_code == 200
@patch("app.api.dropbox.settings")
def test_update_settings_exception(self, mock_settings, client):
"""Test that exceptions return 500 error."""
# Make setting the attribute raise an exception
type(mock_settings).dropbox_refresh_token = property(
lambda self: "", lambda self, v: (_ for _ in ()).throw(RuntimeError("forced"))
)
response = client.post(
"/api/dropbox/update-settings",
data={"refresh_token": "token"},
)
assert response.status_code == 500
@pytest.mark.unit
class TestTestDropboxToken:
"""Tests for test_dropbox_token endpoint."""
@patch("app.api.dropbox.settings")
def test_not_configured(self, mock_settings, client):
"""Test response when Dropbox is not configured."""
mock_settings.dropbox_refresh_token = ""
mock_settings.dropbox_app_key = ""
mock_settings.dropbox_app_secret = ""
response = client.get("/api/dropbox/test-token")
assert response.status_code == 200
data = response.json()
assert data["status"] == "error"
assert "not fully configured" in data["message"]
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.settings")
def test_valid_token(self, mock_settings, mock_post, client):
"""Test successful token validation."""
mock_settings.dropbox_refresh_token = "valid-refresh-token"
mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"email": "user@example.com",
"name": {"display_name": "Test User"},
}
mock_post.return_value = mock_response
response = client.get("/api/dropbox/test-token")
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["account"] == "user@example.com"
assert data["account_name"] == "Test User"
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.settings")
def test_expired_token_refreshed(self, mock_settings, mock_post, client):
"""Test that expired token triggers refresh and retry."""
mock_settings.dropbox_refresh_token = "refresh-token"
mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret"
mock_settings.http_request_timeout = 30
# First call returns 401, second call (refresh) returns 200, third call returns 200
mock_401 = Mock()
mock_401.status_code = 401
mock_refresh = Mock()
mock_refresh.status_code = 200
mock_refresh.json.return_value = {"access_token": "new-access-token"}
mock_success = Mock()
mock_success.status_code = 200
mock_success.json.return_value = {
"email": "user@example.com",
"name": {"display_name": "Test User"},
}
mock_post.side_effect = [mock_401, mock_refresh, mock_success]
response = client.get("/api/dropbox/test-token")
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.settings")
def test_refresh_token_expired(self, mock_settings, mock_post, client):
"""Test handling when refresh token itself is expired."""
mock_settings.dropbox_refresh_token = "expired-refresh"
mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret"
mock_settings.http_request_timeout = 30
# First call returns 401, refresh also fails
mock_401 = Mock()
mock_401.status_code = 401
mock_refresh_fail = Mock()
mock_refresh_fail.status_code = 400
mock_refresh_fail.text = "invalid_grant"
mock_post.side_effect = [mock_401, mock_refresh_fail]
response = client.get("/api/dropbox/test-token")
assert response.status_code == 200
data = response.json()
assert data["status"] == "error"
assert data["needs_reauth"] is True
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.settings")
def test_token_validation_failure(self, mock_settings, mock_post, client):
"""Test handling non-401, non-200 response."""
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
mock_post.return_value = mock_response
response = client.get("/api/dropbox/test-token")
assert response.status_code == 200
data = response.json()
assert data["status"] == "error"
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.settings")
def test_connection_error(self, mock_settings, mock_post, client):
"""Test handling of connection exceptions."""
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret"
mock_settings.http_request_timeout = 30
mock_post.side_effect = requests.exceptions.ConnectionError("Connection refused")
response = client.get("/api/dropbox/test-token")
assert response.status_code == 200
data = response.json()
assert data["status"] == "error"
assert "Connection error" in data["message"]
@pytest.mark.unit
class TestSaveDropboxSettings:
"""Tests for save_dropbox_settings endpoint."""
@patch("app.api.dropbox.settings")
def test_save_settings_env_not_found(self, mock_settings, client):
"""Test error when .env file is not found."""
# The endpoint constructs the env path using __file__
with patch("os.path.exists", return_value=False):
response = client.post(
"/api/dropbox/save-settings",
data={"refresh_token": "test-token"},
)
assert response.status_code == 500
@patch("app.api.dropbox.settings")
def test_save_settings_success(self, mock_settings, client, tmp_path):
"""Test successful save of Dropbox settings to .env file."""
mock_settings.dropbox_refresh_token = ""
mock_settings.dropbox_app_key = ""
mock_settings.dropbox_app_secret = ""
mock_settings.dropbox_folder = ""
# Create a temporary .env file
env_file = tmp_path / ".env"
env_file.write_text("DROPBOX_REFRESH_TOKEN=old_token\nOTHER_VAR=value\n")
with (
patch("app.api.dropbox.os.path.join", return_value=str(env_file)),
patch("app.api.dropbox.os.path.exists", return_value=True),
patch("app.api.dropbox.os.path.dirname", return_value=str(tmp_path)),
):
response = client.post(
"/api/dropbox/save-settings",
data={
"refresh_token": "new-token",
"app_key": "new-key",
},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify the .env file was updated
content = env_file.read_text()
assert "new-token" in content
+99
View File
@@ -0,0 +1,99 @@
"""
Tests for app/utils/config_validator.py module coverage.
Ensures all imports and __all__ exports in the re-export module are exercised
so coverage tools track the module as covered.
"""
import pytest
@pytest.mark.unit
class TestConfigValidatorModuleCoverage:
"""Ensure every line in config_validator.py is exercised by coverage."""
def test_all_imports_and_exports_exercised(self):
"""Import every symbol from config_validator to ensure line coverage."""
# These imports exercise lines 7-17 (import statements)
from app.utils.config_validator import (
check_all_configs,
dump_all_settings,
get_provider_status,
get_settings_for_display,
mask_sensitive_value,
validate_email_config,
validate_notification_config,
validate_storage_configs,
)
# Verify all functions are callable (exercises the __all__ list, lines 19-28)
for fn in [
validate_email_config,
validate_storage_configs,
validate_notification_config,
mask_sensitive_value,
get_provider_status,
get_settings_for_display,
dump_all_settings,
check_all_configs,
]:
assert callable(fn)
def test_all_list_contents(self):
"""Verify __all__ is correctly defined and complete."""
import app.utils.config_validator as mod
expected = {
"validate_email_config",
"validate_storage_configs",
"validate_notification_config",
"validate_auth_config",
"mask_sensitive_value",
"get_provider_status",
"get_settings_for_display",
"dump_all_settings",
"check_all_configs",
}
assert set(mod.__all__) == expected
def test_mask_sensitive_value_returns_masked(self):
"""Test that mask_sensitive_value masks a real value."""
from app.utils.config_validator import mask_sensitive_value
result = mask_sensitive_value("my_secret_value_12345")
assert "my_secret_value_12345" != result
def test_validate_storage_configs_returns_dict(self):
"""Test validate_storage_configs returns a dict."""
from app.utils.config_validator import validate_storage_configs
result = validate_storage_configs()
assert isinstance(result, dict)
def test_validate_email_config_returns_list(self):
"""Test validate_email_config returns a list."""
from app.utils.config_validator import validate_email_config
result = validate_email_config()
assert isinstance(result, list)
def test_validate_notification_config_returns_list(self):
"""Test validate_notification_config returns a list."""
from app.utils.config_validator import validate_notification_config
result = validate_notification_config()
assert isinstance(result, list)
def test_get_provider_status_returns_dict(self):
"""Test get_provider_status returns a dict."""
from app.utils.config_validator import get_provider_status
result = get_provider_status()
assert isinstance(result, dict)
def test_check_all_configs_returns_dict(self):
"""Test check_all_configs returns a dict."""
from app.utils.config_validator import check_all_configs
result = check_all_configs()
assert isinstance(result, dict)
+469
View File
@@ -0,0 +1,469 @@
"""
Tests for app/utils/migrate_logs_to_steps.py module.
Covers migrate_logs_to_steps, _parse_logs_to_step_states, migrate_all_files,
and verify_migration with comprehensive unit tests.
"""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from app.models import FileProcessingStep, ProcessingLog
from app.utils.migrate_logs_to_steps import (
_parse_logs_to_step_states,
migrate_all_files,
migrate_logs_to_steps,
verify_migration,
)
def _make_log(file_id, step_name, status, message=None, timestamp=None):
"""Helper to create a mock ProcessingLog."""
log = MagicMock(spec=ProcessingLog)
log.file_id = file_id
log.step_name = step_name
log.status = status
log.message = message
log.timestamp = timestamp or datetime.now(timezone.utc)
return log
def _make_step(file_id, step_name, status, started_at=None, completed_at=None, error_message=None):
"""Helper to create a mock FileProcessingStep."""
step = MagicMock(spec=FileProcessingStep)
step.file_id = file_id
step.step_name = step_name
step.status = status
step.started_at = started_at
step.completed_at = completed_at
step.error_message = error_message
return step
@pytest.mark.unit
class TestParseLogsToStepStates:
"""Tests for _parse_logs_to_step_states function."""
def test_empty_logs(self):
"""Test parsing empty log list returns empty dict."""
result = _parse_logs_to_step_states([])
assert result == {}
def test_single_in_progress_log(self):
"""Test parsing a single in_progress log entry."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
logs = [_make_log(1, "hash_file", "in_progress", timestamp=ts)]
result = _parse_logs_to_step_states(logs)
assert "hash_file" in result
assert result["hash_file"]["status"] == "in_progress"
assert result["hash_file"]["started_at"] == ts
assert result["hash_file"]["completed_at"] is None
def test_in_progress_then_success(self):
"""Test step that starts and completes successfully."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
logs = [
_make_log(1, "hash_file", "in_progress", timestamp=ts1),
_make_log(1, "hash_file", "success", timestamp=ts2),
]
result = _parse_logs_to_step_states(logs)
assert result["hash_file"]["status"] == "success"
assert result["hash_file"]["started_at"] == ts1
assert result["hash_file"]["completed_at"] == ts2
assert result["hash_file"]["error_message"] is None
def test_in_progress_then_failure(self):
"""Test step that starts and fails."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
logs = [
_make_log(1, "ocr", "in_progress", timestamp=ts1),
_make_log(1, "ocr", "failure", message="OCR failed", timestamp=ts2),
]
result = _parse_logs_to_step_states(logs)
assert result["ocr"]["status"] == "failure"
assert result["ocr"]["started_at"] == ts1
assert result["ocr"]["completed_at"] == ts2
assert result["ocr"]["error_message"] == "OCR failed"
def test_success_without_in_progress(self):
"""Test step that goes directly to success without in_progress."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
logs = [_make_log(1, "hash_file", "success", timestamp=ts)]
result = _parse_logs_to_step_states(logs)
assert result["hash_file"]["status"] == "success"
assert result["hash_file"]["started_at"] == ts
assert result["hash_file"]["completed_at"] == ts
def test_failure_without_in_progress(self):
"""Test step that goes directly to failure without in_progress."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
logs = [_make_log(1, "upload", "failure", message="Connection error", timestamp=ts)]
result = _parse_logs_to_step_states(logs)
assert result["upload"]["status"] == "failure"
assert result["upload"]["started_at"] == ts
assert result["upload"]["error_message"] == "Connection error"
def test_pending_status(self):
"""Test step with pending status."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
logs = [_make_log(1, "upload", "pending", timestamp=ts)]
result = _parse_logs_to_step_states(logs)
assert result["upload"]["status"] == "pending"
assert result["upload"]["started_at"] is None
def test_queued_status(self):
"""Test step with queued status."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
logs = [_make_log(1, "upload", "queued", timestamp=ts)]
result = _parse_logs_to_step_states(logs)
assert result["upload"]["status"] == "pending"
def test_multiple_steps(self):
"""Test parsing logs for multiple steps."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
ts3 = datetime(2024, 1, 1, 12, 0, 10, tzinfo=timezone.utc)
ts4 = datetime(2024, 1, 1, 12, 0, 15, tzinfo=timezone.utc)
logs = [
_make_log(1, "hash_file", "in_progress", timestamp=ts1),
_make_log(1, "hash_file", "success", timestamp=ts2),
_make_log(1, "ocr", "in_progress", timestamp=ts3),
_make_log(1, "ocr", "success", timestamp=ts4),
]
result = _parse_logs_to_step_states(logs)
assert len(result) == 2
assert result["hash_file"]["status"] == "success"
assert result["ocr"]["status"] == "success"
def test_retry_overwrites_previous_state(self):
"""Test that later success after failure wins (retry scenario)."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
ts3 = datetime(2024, 1, 1, 12, 0, 10, tzinfo=timezone.utc)
ts4 = datetime(2024, 1, 1, 12, 0, 15, tzinfo=timezone.utc)
logs = [
_make_log(1, "upload", "in_progress", timestamp=ts1),
_make_log(1, "upload", "failure", message="timeout", timestamp=ts2),
_make_log(1, "upload", "in_progress", timestamp=ts3),
_make_log(1, "upload", "success", timestamp=ts4),
]
result = _parse_logs_to_step_states(logs)
assert result["upload"]["status"] == "success"
assert result["upload"]["error_message"] is None
assert result["upload"]["completed_at"] == ts4
def test_pending_does_not_override_in_progress(self):
"""Test that pending does not override in_progress status."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
logs = [
_make_log(1, "upload", "in_progress", timestamp=ts1),
_make_log(1, "upload", "pending", timestamp=ts2),
]
result = _parse_logs_to_step_states(logs)
assert result["upload"]["status"] == "in_progress"
@pytest.mark.unit
class TestMigrateLogsToSteps:
"""Tests for migrate_logs_to_steps function."""
def test_no_logs_found(self, db_session):
"""Test migration when no logs exist for the file."""
result = migrate_logs_to_steps(db_session, file_id=999)
assert result["file_id"] == 999
assert result["steps_created"] == 0
assert result["steps_updated"] == 0
assert result["steps_skipped"] == 0
assert result["errors"] == []
def test_creates_new_steps(self, db_session):
"""Test migration creates FileProcessingStep entries from logs."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
# Add processing logs
log1 = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="in_progress", timestamp=ts1)
log2 = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts2)
db_session.add_all([log1, log2])
db_session.commit()
result = migrate_logs_to_steps(db_session, file_id=1)
assert result["steps_created"] == 1
assert result["steps_updated"] == 0
assert result["errors"] == []
# Verify step was actually created
steps = db_session.query(FileProcessingStep).filter(FileProcessingStep.file_id == 1).all()
assert len(steps) == 1
assert steps[0].step_name == "hash_file"
assert steps[0].status == "success"
def test_updates_existing_step(self, db_session):
"""Test migration updates existing step when status differs."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
# Add a log with success
log = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts2)
db_session.add(log)
# Add existing step with different status
existing = FileProcessingStep(file_id=1, step_name="hash_file", status="in_progress", started_at=ts1)
db_session.add(existing)
db_session.commit()
result = migrate_logs_to_steps(db_session, file_id=1)
assert result["steps_updated"] == 1
assert result["steps_created"] == 0
def test_skips_unchanged_step(self, db_session):
"""Test migration skips step that is already up to date."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
# Add logs
log1 = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="in_progress", timestamp=ts1)
log2 = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts2)
db_session.add_all([log1, log2])
# Add existing step with the same values
existing = FileProcessingStep(
file_id=1, step_name="hash_file", status="success", started_at=ts1, completed_at=ts2
)
db_session.add(existing)
db_session.commit()
result = migrate_logs_to_steps(db_session, file_id=1)
assert result["steps_skipped"] == 1
assert result["steps_created"] == 0
assert result["steps_updated"] == 0
def test_dry_run_does_not_commit(self, db_session):
"""Test that dry_run mode does not persist changes."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
log = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts)
db_session.add(log)
db_session.commit()
result = migrate_logs_to_steps(db_session, file_id=1, dry_run=True)
assert result["steps_created"] == 1
# After dry run + rollback, no steps should be persisted
steps = db_session.query(FileProcessingStep).filter(FileProcessingStep.file_id == 1).all()
assert len(steps) == 0
def test_handles_exception_gracefully(self, db_session):
"""Test that exceptions are caught and reported."""
with patch.object(db_session, "query", side_effect=Exception("DB error")):
result = migrate_logs_to_steps(db_session, file_id=1)
assert "DB error" in result["errors"][0]
assert result["steps_created"] == 0
@pytest.mark.unit
class TestMigrateAllFiles:
"""Tests for migrate_all_files function."""
def test_no_files_to_migrate(self, db_session):
"""Test with no files needing migration."""
result = migrate_all_files(db_session)
assert result["total_files"] == 0
assert result["files_migrated"] == 0
assert result["files_failed"] == 0
def test_migrates_files_with_logs_but_no_steps(self, db_session):
"""Test migrating files that have logs but no steps."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
# Add logs for file 1
log1 = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="in_progress", timestamp=ts1)
log2 = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts2)
db_session.add_all([log1, log2])
db_session.commit()
result = migrate_all_files(db_session)
assert result["total_files"] == 1
assert result["files_migrated"] == 1
assert result["total_steps_created"] == 1
def test_skips_files_already_with_steps(self, db_session):
"""Test that files with existing steps are skipped."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
# Add log and step for file 1
log = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts)
step = FileProcessingStep(file_id=1, step_name="hash_file", status="success")
db_session.add_all([log, step])
db_session.commit()
result = migrate_all_files(db_session)
assert result["total_files"] == 0
def test_handles_file_with_none_file_id(self, db_session):
"""Test that logs with None file_id are excluded."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
log = ProcessingLog(file_id=None, task_id="t1", step_name="hash_file", status="success", timestamp=ts)
db_session.add(log)
db_session.commit()
result = migrate_all_files(db_session)
assert result["total_files"] == 0
def test_batch_processing(self, db_session):
"""Test batch processing with small batch size."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
for i in range(3):
log = ProcessingLog(file_id=i + 10, task_id=f"t{i}", step_name="hash_file", status="success", timestamp=ts)
db_session.add(log)
db_session.commit()
result = migrate_all_files(db_session, batch_size=2)
assert result["total_files"] == 3
assert result["files_migrated"] == 3
def test_tracks_failed_files(self, db_session):
"""Test that migration errors for individual files are tracked."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
log = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts)
db_session.add(log)
db_session.commit()
with patch(
"app.utils.migrate_logs_to_steps.migrate_logs_to_steps",
return_value={"errors": ["forced error"], "steps_created": 0, "steps_updated": 0, "steps_skipped": 0},
):
result = migrate_all_files(db_session)
assert result["files_failed"] == 1
assert "forced error" in result["errors"]
def test_dry_run(self, db_session):
"""Test that dry_run is propagated."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
log = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts)
db_session.add(log)
db_session.commit()
result = migrate_all_files(db_session, dry_run=True)
# File still processed, but changes not committed
assert result["total_files"] == 1
@pytest.mark.unit
class TestVerifyMigration:
"""Tests for verify_migration function."""
def test_no_logs_found(self, db_session):
"""Test verification when no logs exist."""
result = verify_migration(db_session, file_id=999)
assert result["is_valid"] is False
assert "No logs found" in result["discrepancies"][0]
def test_valid_migration(self, db_session):
"""Test verification passes when migration is correct."""
ts1 = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
ts2 = datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
# Add logs
log1 = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="in_progress", timestamp=ts1)
log2 = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts2)
db_session.add_all([log1, log2])
# Add matching step
step = FileProcessingStep(file_id=1, step_name="hash_file", status="success", started_at=ts1, completed_at=ts2)
db_session.add(step)
db_session.commit()
result = verify_migration(db_session, file_id=1)
assert result["is_valid"] is True
assert result["discrepancies"] == []
assert "hash_file" in result["log_steps"]
assert "hash_file" in result["table_steps"]
def test_missing_step_in_table(self, db_session):
"""Test verification detects missing steps."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
log = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts)
db_session.add(log)
db_session.commit()
result = verify_migration(db_session, file_id=1)
assert result["is_valid"] is False
assert any("missing from table" in d for d in result["discrepancies"])
def test_status_mismatch(self, db_session):
"""Test verification detects status mismatches."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
log = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts)
db_session.add(log)
step = FileProcessingStep(file_id=1, step_name="hash_file", status="failure")
db_session.add(step)
db_session.commit()
result = verify_migration(db_session, file_id=1)
assert result["is_valid"] is False
assert any("status mismatch" in d for d in result["discrepancies"])
def test_extra_step_in_table(self, db_session):
"""Test that extra steps in table are noted but don't invalidate."""
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
log = ProcessingLog(file_id=1, task_id="t1", step_name="hash_file", status="success", timestamp=ts)
db_session.add(log)
# Add matching step + an extra one
step1 = FileProcessingStep(file_id=1, step_name="hash_file", status="success")
step2 = FileProcessingStep(file_id=1, step_name="extra_step", status="success")
db_session.add_all([step1, step2])
db_session.commit()
result = verify_migration(db_session, file_id=1)
assert any("Extra step" in d for d in result["discrepancies"])
# Extra steps don't mark as invalid
assert result["is_valid"] is True
+333
View File
@@ -0,0 +1,333 @@
"""
Tests for app/tasks/upload_to_dropbox.py module.
Covers _validate_dropbox_settings, get_dropbox_access_token, get_dropbox_client,
and upload_to_dropbox Celery task.
"""
import os
from unittest.mock import MagicMock, Mock, patch
import pytest
from dropbox.exceptions import ApiError, AuthError
@pytest.mark.unit
class TestValidateDropboxSettings:
"""Tests for _validate_dropbox_settings function."""
def test_all_settings_present(self):
"""Test validation passes when all settings are present."""
from app.tasks.upload_to_dropbox import _validate_dropbox_settings
with patch("app.tasks.upload_to_dropbox.settings") as mock_settings:
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = "secret"
assert _validate_dropbox_settings() is True
def test_missing_refresh_token(self):
"""Test validation fails when refresh token is missing."""
from app.tasks.upload_to_dropbox import _validate_dropbox_settings
with patch("app.tasks.upload_to_dropbox.settings") as mock_settings:
mock_settings.dropbox_refresh_token = ""
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = "secret"
assert _validate_dropbox_settings() is False
def test_missing_app_key(self):
"""Test validation fails when app key is missing."""
from app.tasks.upload_to_dropbox import _validate_dropbox_settings
with patch("app.tasks.upload_to_dropbox.settings") as mock_settings:
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_app_key = ""
mock_settings.dropbox_app_secret = "secret"
assert _validate_dropbox_settings() is False
def test_missing_app_secret(self):
"""Test validation fails when app secret is missing."""
from app.tasks.upload_to_dropbox import _validate_dropbox_settings
with patch("app.tasks.upload_to_dropbox.settings") as mock_settings:
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = ""
assert _validate_dropbox_settings() is False
def test_missing_all_settings(self):
"""Test validation fails when all settings are missing."""
from app.tasks.upload_to_dropbox import _validate_dropbox_settings
with patch("app.tasks.upload_to_dropbox.settings") as mock_settings:
mock_settings.dropbox_refresh_token = None
mock_settings.dropbox_app_key = None
mock_settings.dropbox_app_secret = None
assert _validate_dropbox_settings() is False
@pytest.mark.unit
class TestGetDropboxAccessToken:
"""Tests for get_dropbox_access_token function."""
@patch("app.tasks.upload_to_dropbox.requests.post")
@patch("app.tasks.upload_to_dropbox.settings")
def test_successful_refresh(self, mock_settings, mock_post):
"""Test successful token refresh."""
from app.tasks.upload_to_dropbox import get_dropbox_access_token
mock_settings.dropbox_refresh_token = "refresh-token"
mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"access_token": "new-access-token"}
mock_post.return_value = mock_response
token = get_dropbox_access_token()
assert token == "new-access-token"
@patch("app.tasks.upload_to_dropbox.requests.post")
@patch("app.tasks.upload_to_dropbox.settings")
def test_refresh_failure_raises(self, mock_settings, mock_post):
"""Test that failed token refresh raises exception."""
from app.tasks.upload_to_dropbox import get_dropbox_access_token
mock_settings.dropbox_refresh_token = "refresh-token"
mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 400
mock_response.text = "invalid_grant"
mock_post.return_value = mock_response
with pytest.raises(Exception, match="Failed to refresh"):
get_dropbox_access_token()
def test_returns_none_when_settings_missing(self):
"""Test returns None when settings are not configured."""
from app.tasks.upload_to_dropbox import get_dropbox_access_token
with patch("app.tasks.upload_to_dropbox.settings") as mock_settings:
mock_settings.dropbox_refresh_token = None
mock_settings.dropbox_app_key = None
mock_settings.dropbox_app_secret = None
result = get_dropbox_access_token()
assert result is None
@pytest.mark.unit
class TestGetDropboxClient:
"""Tests for get_dropbox_client function."""
@patch("app.tasks.upload_to_dropbox.dropbox.Dropbox")
@patch("app.tasks.upload_to_dropbox.settings")
def test_successful_client_creation(self, mock_settings, mock_dropbox):
"""Test successful Dropbox client creation."""
from app.tasks.upload_to_dropbox import get_dropbox_client
mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret"
mock_settings.dropbox_refresh_token = "refresh-token"
mock_instance = Mock()
mock_dropbox.return_value = mock_instance
client = get_dropbox_client()
assert client == mock_instance
mock_instance.users_get_current_account.assert_called_once()
@patch("app.tasks.upload_to_dropbox.settings")
def test_missing_app_key_raises(self, mock_settings):
"""Test that missing app key raises ValueError."""
from app.tasks.upload_to_dropbox import get_dropbox_client
mock_settings.dropbox_app_key = ""
mock_settings.dropbox_app_secret = "secret"
mock_settings.dropbox_refresh_token = "token"
with pytest.raises(ValueError, match="app key or app secret"):
get_dropbox_client()
@patch("app.tasks.upload_to_dropbox.settings")
def test_missing_refresh_token_raises(self, mock_settings):
"""Test that missing refresh token raises ValueError."""
from app.tasks.upload_to_dropbox import get_dropbox_client
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = "secret"
mock_settings.dropbox_refresh_token = ""
with pytest.raises(ValueError, match="refresh token"):
get_dropbox_client()
@patch("app.tasks.upload_to_dropbox.dropbox.Dropbox")
@patch("app.tasks.upload_to_dropbox.settings")
def test_auth_error_propagated(self, mock_settings, mock_dropbox):
"""Test that AuthError is propagated."""
from app.tasks.upload_to_dropbox import get_dropbox_client
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = "secret"
mock_settings.dropbox_refresh_token = "token"
mock_instance = Mock()
mock_instance.users_get_current_account.side_effect = AuthError("req-id", "Invalid token")
mock_dropbox.return_value = mock_instance
with pytest.raises(AuthError):
get_dropbox_client()
@pytest.mark.unit
class TestUploadToDropbox:
"""Tests for upload_to_dropbox Celery task."""
@patch("app.tasks.upload_to_dropbox.log_task_progress")
def test_file_not_found(self, mock_log):
"""Test that missing file raises FileNotFoundError."""
from app.tasks.upload_to_dropbox import upload_to_dropbox
mock_self = MagicMock()
mock_self.request.id = "test-task"
with pytest.raises(FileNotFoundError):
upload_to_dropbox.__wrapped__(mock_self, "/nonexistent/file.pdf", file_id=1)
@patch("app.tasks.upload_to_dropbox.log_task_progress")
@patch("app.tasks.upload_to_dropbox.settings")
def test_skipped_when_not_configured(self, mock_settings, mock_log, tmp_path):
"""Test upload skipped when Dropbox not configured."""
from app.tasks.upload_to_dropbox import upload_to_dropbox
mock_settings.dropbox_app_key = ""
mock_settings.dropbox_app_secret = ""
mock_settings.dropbox_refresh_token = ""
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
result = upload_to_dropbox.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Skipped"
@patch("app.tasks.upload_to_dropbox.get_unique_filename")
@patch("app.tasks.upload_to_dropbox.extract_remote_path")
@patch("app.tasks.upload_to_dropbox.get_dropbox_client")
@patch("app.tasks.upload_to_dropbox.log_task_progress")
@patch("app.tasks.upload_to_dropbox.settings")
def test_successful_small_file_upload(
self, mock_settings, mock_log, mock_client, mock_extract, mock_unique, tmp_path
):
"""Test successful upload of a small file."""
from app.tasks.upload_to_dropbox import upload_to_dropbox
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = "secret"
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_folder = "/uploads"
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"small file content")
mock_dbx = Mock()
mock_client.return_value = mock_dbx
mock_extract.return_value = "uploads/test.pdf"
mock_unique.return_value = "/uploads/test.pdf"
result = upload_to_dropbox.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
assert result["file_path"] == str(test_file)
mock_dbx.files_upload.assert_called_once()
@patch("app.tasks.upload_to_dropbox.get_unique_filename")
@patch("app.tasks.upload_to_dropbox.extract_remote_path")
@patch("app.tasks.upload_to_dropbox.get_dropbox_client")
@patch("app.tasks.upload_to_dropbox.log_task_progress")
@patch("app.tasks.upload_to_dropbox.settings")
def test_large_file_chunked_upload(
self, mock_settings, mock_log, mock_client, mock_extract, mock_unique, tmp_path
):
"""Test chunked upload for large files (>10MB)."""
from app.tasks.upload_to_dropbox import upload_to_dropbox
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = "secret"
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_folder = "/uploads"
mock_settings.workdir = str(tmp_path)
# Create a file larger than 10MB threshold
test_file = tmp_path / "large.pdf"
test_file.write_bytes(b"x" * (11 * 1024 * 1024))
mock_dbx = Mock()
mock_client.return_value = mock_dbx
mock_extract.return_value = "uploads/large.pdf"
mock_unique.return_value = "/uploads/large.pdf"
mock_session = Mock()
mock_session.session_id = "session-123"
mock_dbx.files_upload_session_start.return_value = mock_session
result = upload_to_dropbox.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
mock_dbx.files_upload_session_start.assert_called_once()
@patch("app.tasks.upload_to_dropbox.get_dropbox_client")
@patch("app.tasks.upload_to_dropbox.log_task_progress")
@patch("app.tasks.upload_to_dropbox.settings")
def test_auth_error_handling(self, mock_settings, mock_log, mock_client, tmp_path):
"""Test AuthError during upload is caught and re-raised."""
from app.tasks.upload_to_dropbox import upload_to_dropbox
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = "secret"
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_folder = "/uploads"
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_client.side_effect = AuthError("req-id", "Invalid token")
with pytest.raises(Exception, match="Authentication failed"):
upload_to_dropbox.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
@patch("app.tasks.upload_to_dropbox.get_dropbox_client")
@patch("app.tasks.upload_to_dropbox.log_task_progress")
@patch("app.tasks.upload_to_dropbox.settings")
def test_api_error_handling(self, mock_settings, mock_log, mock_client, tmp_path):
"""Test ApiError during upload is caught and re-raised."""
from app.tasks.upload_to_dropbox import upload_to_dropbox
mock_settings.dropbox_app_key = "key"
mock_settings.dropbox_app_secret = "secret"
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_folder = "/uploads"
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_error = Mock()
mock_error.is_path.return_value = False
mock_client.side_effect = ApiError("req-id", mock_error, "user msg", "header")
with pytest.raises(Exception, match="Failed to upload"):
upload_to_dropbox.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
+272
View File
@@ -0,0 +1,272 @@
"""
Tests for app/tasks/upload_to_nextcloud.py module.
Covers the upload_to_nextcloud Celery task including configuration validation,
WebDAV upload, directory creation, and error handling.
"""
import os
from unittest.mock import MagicMock, Mock, call, patch
import pytest
@pytest.mark.unit
class TestUploadToNextcloud:
"""Tests for upload_to_nextcloud Celery task."""
@patch("app.tasks.upload_to_nextcloud.log_task_progress")
def test_file_not_found(self, mock_log):
"""Test that missing file raises FileNotFoundError."""
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
mock_self = MagicMock()
mock_self.request.id = "test-task"
with pytest.raises(FileNotFoundError):
upload_to_nextcloud.__wrapped__(mock_self, "/nonexistent/file.pdf", file_id=1)
@patch("app.tasks.upload_to_nextcloud.log_task_progress")
@patch("app.tasks.upload_to_nextcloud.settings")
def test_skipped_when_not_configured(self, mock_settings, mock_log, tmp_path):
"""Test upload skipped when Nextcloud URL not configured."""
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
mock_settings.nextcloud_upload_url = None
mock_settings.nextcloud_username = None
mock_settings.nextcloud_password = None
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
result = upload_to_nextcloud.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Skipped"
@patch("app.tasks.upload_to_nextcloud.log_task_progress")
@patch("app.tasks.upload_to_nextcloud.settings")
def test_skipped_when_username_missing(self, mock_settings, mock_log, tmp_path):
"""Test upload skipped when username is missing."""
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com/remote.php/dav"
mock_settings.nextcloud_username = None
mock_settings.nextcloud_password = "password" # noqa: S105
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
result = upload_to_nextcloud.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Skipped"
@patch("app.tasks.upload_to_nextcloud.get_unique_filename")
@patch("app.tasks.upload_to_nextcloud.extract_remote_path")
@patch("app.tasks.upload_to_nextcloud.requests")
@patch("app.tasks.upload_to_nextcloud.log_task_progress")
@patch("app.tasks.upload_to_nextcloud.settings")
def test_successful_upload_201(self, mock_settings, mock_log, mock_requests, mock_extract, mock_unique, tmp_path):
"""Test successful file upload with 201 response."""
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com/remote.php/dav/"
mock_settings.nextcloud_username = "user"
mock_settings.nextcloud_password = "pass" # noqa: S105
mock_settings.nextcloud_folder = ""
mock_settings.workdir = str(tmp_path)
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_extract.return_value = "test.pdf"
mock_unique.return_value = "test.pdf"
# Mock the PUT response for file upload
mock_put_response = Mock()
mock_put_response.status_code = 201
mock_requests.put.return_value = mock_put_response
# Mock PROPFIND for existence check
mock_propfind_response = Mock()
mock_propfind_response.text = ""
mock_requests.request.return_value = mock_propfind_response
result = upload_to_nextcloud.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
assert result["response_code"] == 201
@patch("app.tasks.upload_to_nextcloud.get_unique_filename")
@patch("app.tasks.upload_to_nextcloud.extract_remote_path")
@patch("app.tasks.upload_to_nextcloud.requests")
@patch("app.tasks.upload_to_nextcloud.log_task_progress")
@patch("app.tasks.upload_to_nextcloud.settings")
def test_successful_upload_204(self, mock_settings, mock_log, mock_requests, mock_extract, mock_unique, tmp_path):
"""Test successful file upload with 204 (No Content / overwrite) response."""
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com/remote.php/dav/"
mock_settings.nextcloud_username = "user"
mock_settings.nextcloud_password = "pass" # noqa: S105
mock_settings.nextcloud_folder = ""
mock_settings.workdir = str(tmp_path)
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_extract.return_value = "test.pdf"
mock_unique.return_value = "test.pdf"
mock_put_response = Mock()
mock_put_response.status_code = 204
mock_requests.put.return_value = mock_put_response
mock_propfind_response = Mock()
mock_propfind_response.text = ""
mock_requests.request.return_value = mock_propfind_response
result = upload_to_nextcloud.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
assert result["response_code"] == 204
@patch("app.tasks.upload_to_nextcloud.get_unique_filename")
@patch("app.tasks.upload_to_nextcloud.extract_remote_path")
@patch("app.tasks.upload_to_nextcloud.requests")
@patch("app.tasks.upload_to_nextcloud.log_task_progress")
@patch("app.tasks.upload_to_nextcloud.settings")
def test_upload_failure_status_code(
self, mock_settings, mock_log, mock_requests, mock_extract, mock_unique, tmp_path
):
"""Test upload failure with non-success status code."""
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com/remote.php/dav/"
mock_settings.nextcloud_username = "user"
mock_settings.nextcloud_password = "pass" # noqa: S105
mock_settings.nextcloud_folder = ""
mock_settings.workdir = str(tmp_path)
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_extract.return_value = "test.pdf"
mock_unique.return_value = "test.pdf"
mock_put_response = Mock()
mock_put_response.status_code = 500
mock_put_response.text = "Internal Server Error"
mock_requests.put.return_value = mock_put_response
mock_propfind_response = Mock()
mock_propfind_response.text = ""
mock_requests.request.return_value = mock_propfind_response
with pytest.raises(Exception, match="Failed to upload"):
upload_to_nextcloud.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
@patch("app.tasks.upload_to_nextcloud.get_unique_filename")
@patch("app.tasks.upload_to_nextcloud.extract_remote_path")
@patch("app.tasks.upload_to_nextcloud.requests")
@patch("app.tasks.upload_to_nextcloud.log_task_progress")
@patch("app.tasks.upload_to_nextcloud.settings")
def test_creates_parent_directories(
self, mock_settings, mock_log, mock_requests, mock_extract, mock_unique, tmp_path
):
"""Test that parent directories are created via MKCOL."""
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com/remote.php/dav/"
mock_settings.nextcloud_username = "user"
mock_settings.nextcloud_password = "pass" # noqa: S105
mock_settings.nextcloud_folder = "documents"
mock_settings.workdir = str(tmp_path)
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_extract.return_value = "documents/subfolder/test.pdf"
mock_unique.return_value = "documents/subfolder/test.pdf"
mock_put_response = Mock()
mock_put_response.status_code = 201
mock_requests.put.return_value = mock_put_response
mock_request_response = Mock()
mock_request_response.text = ""
mock_requests.request.return_value = mock_request_response
result = upload_to_nextcloud.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
# Verify MKCOL calls were made for parent directories
mkcol_calls = [c for c in mock_requests.request.call_args_list if c[0][0] == "MKCOL"]
assert len(mkcol_calls) >= 1
@patch("app.tasks.upload_to_nextcloud.get_unique_filename")
@patch("app.tasks.upload_to_nextcloud.extract_remote_path")
@patch("app.tasks.upload_to_nextcloud.requests")
@patch("app.tasks.upload_to_nextcloud.log_task_progress")
@patch("app.tasks.upload_to_nextcloud.settings")
def test_connection_error(self, mock_settings, mock_log, mock_requests, mock_extract, mock_unique, tmp_path):
"""Test handling of connection errors during upload."""
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com/remote.php/dav/"
mock_settings.nextcloud_username = "user"
mock_settings.nextcloud_password = "pass" # noqa: S105
mock_settings.nextcloud_folder = ""
mock_settings.workdir = str(tmp_path)
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_extract.return_value = "test.pdf"
mock_unique.return_value = "test.pdf"
mock_requests.put.side_effect = Exception("Connection refused")
mock_requests.request.return_value = Mock(text="")
with pytest.raises(Exception, match="Failed to upload"):
upload_to_nextcloud.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
@patch("app.tasks.upload_to_nextcloud.get_unique_filename")
@patch("app.tasks.upload_to_nextcloud.extract_remote_path")
@patch("app.tasks.upload_to_nextcloud.requests")
@patch("app.tasks.upload_to_nextcloud.log_task_progress")
@patch("app.tasks.upload_to_nextcloud.settings")
def test_url_trailing_slash_normalization(
self, mock_settings, mock_log, mock_requests, mock_extract, mock_unique, tmp_path
):
"""Test that URLs without trailing slashes are handled."""
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com/remote.php/dav"
mock_settings.nextcloud_username = "user"
mock_settings.nextcloud_password = "pass" # noqa: S105
mock_settings.nextcloud_folder = ""
mock_settings.workdir = str(tmp_path)
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_extract.return_value = "test.pdf"
mock_unique.return_value = "test.pdf"
mock_put_response = Mock()
mock_put_response.status_code = 201
mock_requests.put.return_value = mock_put_response
mock_request_response = Mock()
mock_request_response.text = ""
mock_requests.request.return_value = mock_request_response
result = upload_to_nextcloud.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
+404
View File
@@ -0,0 +1,404 @@
"""
Tests for app/tasks/upload_to_onedrive.py module.
Covers get_onedrive_token, create_upload_session, upload_large_file,
and upload_to_onedrive Celery task.
"""
import os
from unittest.mock import MagicMock, Mock, patch
import pytest
@pytest.mark.unit
class TestGetOnedriveToken:
"""Tests for get_onedrive_token function."""
@patch("app.tasks.upload_to_onedrive.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_onedrive.settings")
def test_refresh_token_flow(self, mock_settings, mock_msal):
"""Test token acquisition using refresh token."""
from app.tasks.upload_to_onedrive import get_onedrive_token
mock_settings.onedrive_client_id = "client-id"
mock_settings.onedrive_client_secret = "client-secret"
mock_settings.onedrive_refresh_token = "refresh-token"
mock_settings.onedrive_tenant_id = "common"
mock_app = Mock()
mock_app.acquire_token_by_refresh_token.return_value = {
"access_token": "new-access-token",
}
mock_msal.return_value = mock_app
token = get_onedrive_token()
assert token == "new-access-token"
@patch("app.tasks.upload_to_onedrive.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_onedrive.settings")
def test_refresh_token_updates_new_token(self, mock_settings, mock_msal):
"""Test that a new refresh token updates settings."""
from app.tasks.upload_to_onedrive import get_onedrive_token
mock_settings.onedrive_client_id = "client-id"
mock_settings.onedrive_client_secret = "client-secret"
mock_settings.onedrive_refresh_token = "old-refresh-token"
mock_settings.onedrive_tenant_id = "common"
mock_app = Mock()
mock_app.acquire_token_by_refresh_token.return_value = {
"access_token": "access-token",
"refresh_token": "new-refresh-token",
}
mock_msal.return_value = mock_app
get_onedrive_token()
assert mock_settings.onedrive_refresh_token == "new-refresh-token"
@patch("app.tasks.upload_to_onedrive.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_onedrive.settings")
def test_refresh_token_failure(self, mock_settings, mock_msal):
"""Test error handling when refresh token fails."""
from app.tasks.upload_to_onedrive import get_onedrive_token
mock_settings.onedrive_client_id = "client-id"
mock_settings.onedrive_client_secret = "client-secret"
mock_settings.onedrive_refresh_token = "expired-token"
mock_settings.onedrive_tenant_id = "common"
mock_app = Mock()
mock_app.acquire_token_by_refresh_token.return_value = {
"error": "invalid_grant",
"error_description": "Token expired",
}
mock_msal.return_value = mock_app
with pytest.raises(ValueError, match="Failed to get access token"):
get_onedrive_token()
@patch("app.tasks.upload_to_onedrive.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_onedrive.settings")
def test_client_credentials_flow(self, mock_settings, mock_msal):
"""Test token acquisition using client credentials (org accounts)."""
from app.tasks.upload_to_onedrive import get_onedrive_token
mock_settings.onedrive_client_id = "client-id"
mock_settings.onedrive_client_secret = "client-secret"
mock_settings.onedrive_refresh_token = ""
mock_settings.onedrive_tenant_id = "org-tenant-id"
mock_app = Mock()
mock_app.acquire_token_for_client.return_value = {
"access_token": "client-cred-token",
}
mock_msal.return_value = mock_app
token = get_onedrive_token()
assert token == "client-cred-token"
@patch("app.tasks.upload_to_onedrive.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_onedrive.settings")
def test_client_credentials_failure(self, mock_settings, mock_msal):
"""Test error handling when client credentials flow fails."""
from app.tasks.upload_to_onedrive import get_onedrive_token
mock_settings.onedrive_client_id = "client-id"
mock_settings.onedrive_client_secret = "client-secret"
mock_settings.onedrive_refresh_token = ""
mock_settings.onedrive_tenant_id = "org-tenant-id"
mock_app = Mock()
mock_app.acquire_token_for_client.return_value = {
"error": "unauthorized_client",
"error_description": "Not authorized",
}
mock_msal.return_value = mock_app
with pytest.raises(ValueError, match="Failed to get access token"):
get_onedrive_token()
@patch("app.tasks.upload_to_onedrive.settings")
def test_missing_client_id(self, mock_settings):
"""Test error when client ID is missing."""
from app.tasks.upload_to_onedrive import get_onedrive_token
mock_settings.onedrive_client_id = ""
mock_settings.onedrive_client_secret = "secret"
with pytest.raises(ValueError, match="client ID and client secret"):
get_onedrive_token()
@patch("app.tasks.upload_to_onedrive.settings")
def test_no_refresh_token_personal_account(self, mock_settings):
"""Test error for personal account without refresh token."""
from app.tasks.upload_to_onedrive import get_onedrive_token
mock_settings.onedrive_client_id = "client-id"
mock_settings.onedrive_client_secret = "secret"
mock_settings.onedrive_refresh_token = ""
mock_settings.onedrive_tenant_id = "common"
with pytest.raises(ValueError, match="ONEDRIVE_REFRESH_TOKEN must be configured"):
get_onedrive_token()
@patch("app.tasks.upload_to_onedrive.settings")
def test_no_refresh_token_no_tenant(self, mock_settings):
"""Test error when no refresh token and no specific tenant."""
from app.tasks.upload_to_onedrive import get_onedrive_token
mock_settings.onedrive_client_id = "client-id"
mock_settings.onedrive_client_secret = "secret"
mock_settings.onedrive_refresh_token = ""
mock_settings.onedrive_tenant_id = ""
with pytest.raises(ValueError, match="ONEDRIVE_REFRESH_TOKEN must be configured"):
get_onedrive_token()
@pytest.mark.unit
class TestCreateUploadSession:
"""Tests for create_upload_session function."""
@patch("app.tasks.upload_to_onedrive.requests.post")
@patch("app.tasks.upload_to_onedrive.settings")
def test_successful_session_creation(self, mock_settings, mock_post):
"""Test successful upload session creation."""
from app.tasks.upload_to_onedrive import create_upload_session
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"uploadUrl": "https://upload.url/session123"}
mock_post.return_value = mock_response
url = create_upload_session("test.pdf", "Documents/Uploads", "access-token")
assert url == "https://upload.url/session123"
@patch("app.tasks.upload_to_onedrive.requests.post")
@patch("app.tasks.upload_to_onedrive.settings")
def test_session_creation_without_folder(self, mock_settings, mock_post):
"""Test upload session creation without folder path."""
from app.tasks.upload_to_onedrive import create_upload_session
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"uploadUrl": "https://upload.url/session456"}
mock_post.return_value = mock_response
url = create_upload_session("test.pdf", None, "access-token")
assert url == "https://upload.url/session456"
@patch("app.tasks.upload_to_onedrive.requests.post")
@patch("app.tasks.upload_to_onedrive.settings")
def test_session_creation_failure(self, mock_settings, mock_post):
"""Test error handling when session creation fails."""
from app.tasks.upload_to_onedrive import create_upload_session
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 403
mock_response.text = "Access denied"
mock_post.return_value = mock_response
with pytest.raises(Exception, match="Failed to create upload session"):
create_upload_session("test.pdf", "Documents", "access-token")
@patch("app.tasks.upload_to_onedrive.requests.post")
@patch("app.tasks.upload_to_onedrive.settings")
def test_url_encoding_of_special_characters(self, mock_settings, mock_post):
"""Test that special characters in folder path are URL-encoded."""
from app.tasks.upload_to_onedrive import create_upload_session
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"uploadUrl": "https://upload.url/session"}
mock_post.return_value = mock_response
create_upload_session("file with spaces.pdf", "My Documents/Uploads", "access-token")
# Verify the URL was constructed with encoded components
call_url = mock_post.call_args[0][0]
assert "My%20Documents" in call_url
assert "file%20with%20spaces.pdf" in call_url
@pytest.mark.unit
class TestUploadLargeFile:
"""Tests for upload_large_file function."""
@patch("app.tasks.upload_to_onedrive.requests.put")
@patch("app.tasks.upload_to_onedrive.settings")
def test_small_single_chunk_upload(self, mock_settings, mock_put, tmp_path):
"""Test uploading a file that fits in a single chunk."""
from app.tasks.upload_to_onedrive import upload_large_file
mock_settings.http_request_timeout = 30
test_file = tmp_path / "small.pdf"
test_file.write_bytes(b"small content")
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {"id": "file123", "name": "small.pdf"}
mock_put.return_value = mock_response
result = upload_large_file(str(test_file), "https://upload.url/session")
assert result["id"] == "file123"
@patch("app.tasks.upload_to_onedrive.time.sleep")
@patch("app.tasks.upload_to_onedrive.requests.put")
@patch("app.tasks.upload_to_onedrive.settings")
def test_chunk_upload_retry_on_failure(self, mock_settings, mock_put, mock_sleep, tmp_path):
"""Test retry logic when a chunk upload fails."""
from app.tasks.upload_to_onedrive import upload_large_file
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
# First attempt fails, second succeeds
mock_fail = Mock()
mock_fail.status_code = 500
mock_success = Mock()
mock_success.status_code = 201
mock_success.json.return_value = {"id": "file123"}
mock_put.side_effect = [mock_fail, mock_success]
result = upload_large_file(str(test_file), "https://upload.url/session")
assert result["id"] == "file123"
@patch("app.tasks.upload_to_onedrive.time.sleep")
@patch("app.tasks.upload_to_onedrive.requests.put")
@patch("app.tasks.upload_to_onedrive.settings")
def test_chunk_upload_retry_on_exception(self, mock_settings, mock_put, mock_sleep, tmp_path):
"""Test retry logic when an exception occurs during upload."""
from app.tasks.upload_to_onedrive import upload_large_file
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_success = Mock()
mock_success.status_code = 201
mock_success.json.return_value = {"id": "file123"}
mock_put.side_effect = [Exception("Network error"), mock_success]
result = upload_large_file(str(test_file), "https://upload.url/session")
assert result["id"] == "file123"
@patch("app.tasks.upload_to_onedrive.time.sleep")
@patch("app.tasks.upload_to_onedrive.requests.put")
@patch("app.tasks.upload_to_onedrive.settings")
def test_all_retries_exhausted(self, mock_settings, mock_put, mock_sleep, tmp_path):
"""Test that exhausting all retries raises an exception."""
from app.tasks.upload_to_onedrive import upload_large_file
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_fail = Mock()
mock_fail.status_code = 500
mock_fail.text = "Server Error"
mock_put.return_value = mock_fail
with pytest.raises(Exception, match="Failed to upload chunk"):
upload_large_file(str(test_file), "https://upload.url/session")
@pytest.mark.unit
class TestUploadToOnedrive:
"""Tests for upload_to_onedrive Celery task."""
@patch("app.tasks.upload_to_onedrive.log_task_progress")
def test_file_not_found(self, mock_log):
"""Test that missing file raises FileNotFoundError."""
from app.tasks.upload_to_onedrive import upload_to_onedrive
mock_self = MagicMock()
mock_self.request.id = "test-task"
with pytest.raises(FileNotFoundError):
upload_to_onedrive.__wrapped__(mock_self, "/nonexistent/file.pdf", file_id=1)
@patch("app.tasks.upload_to_onedrive.log_task_progress")
@patch("app.tasks.upload_to_onedrive.settings")
def test_missing_client_id(self, mock_settings, mock_log, tmp_path):
"""Test error when OneDrive client ID is not configured."""
from app.tasks.upload_to_onedrive import upload_to_onedrive
mock_settings.onedrive_client_id = ""
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_self = MagicMock()
mock_self.request.id = "test-task"
with pytest.raises(ValueError, match="client ID is not configured"):
upload_to_onedrive.__wrapped__(mock_self, str(test_file), file_id=1)
@patch("app.tasks.upload_to_onedrive.upload_large_file")
@patch("app.tasks.upload_to_onedrive.create_upload_session")
@patch("app.tasks.upload_to_onedrive.get_onedrive_token")
@patch("app.tasks.upload_to_onedrive.log_task_progress")
@patch("app.tasks.upload_to_onedrive.settings")
def test_successful_upload(self, mock_settings, mock_log, mock_token, mock_session, mock_upload, tmp_path):
"""Test successful OneDrive upload."""
from app.tasks.upload_to_onedrive import upload_to_onedrive
mock_settings.onedrive_client_id = "client-id"
mock_settings.onedrive_client_secret = "secret"
mock_settings.onedrive_refresh_token = "token"
mock_settings.onedrive_folder_path = "Documents"
mock_settings.onedrive_tenant_id = "common"
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_token.return_value = "access-token"
mock_session.return_value = "https://upload.url/session"
mock_upload.return_value = {"webUrl": "https://onedrive.live.com/test.pdf"}
result = upload_to_onedrive.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
assert "Documents" in result["onedrive_path"]
assert result["web_url"] == "https://onedrive.live.com/test.pdf"
@patch("app.tasks.upload_to_onedrive.get_onedrive_token")
@patch("app.tasks.upload_to_onedrive.log_task_progress")
@patch("app.tasks.upload_to_onedrive.settings")
def test_upload_exception_handling(self, mock_settings, mock_log, mock_token, tmp_path):
"""Test that upload errors are properly handled."""
from app.tasks.upload_to_onedrive import upload_to_onedrive
mock_settings.onedrive_client_id = "client-id"
mock_settings.onedrive_client_secret = "secret"
mock_settings.onedrive_folder_path = "Documents"
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_token.side_effect = ValueError("Token error")
with pytest.raises(Exception, match="Failed to upload"):
upload_to_onedrive.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
+626
View File
@@ -0,0 +1,626 @@
"""
Tests for app/tasks/upload_to_paperless.py module.
Covers helper functions (normalize_metadata_value, _is_duplicate_error, poll_task_for_document_id,
get_custom_field_id, set_document_custom_fields) and the upload_to_paperless Celery task.
"""
import json
import os
from unittest.mock import MagicMock, Mock, mock_open, patch
import pytest
import requests
from app.tasks.upload_to_paperless import (
_get_headers,
_is_duplicate_error,
_paperless_api_url,
get_custom_field_id,
normalize_metadata_value,
poll_task_for_document_id,
set_document_custom_fields,
upload_to_paperless,
)
@pytest.mark.unit
class TestNormalizeMetadataValue:
"""Tests for normalize_metadata_value function."""
def test_none_returns_empty(self):
"""Test that None value returns empty string."""
assert normalize_metadata_value(None) == ""
def test_empty_string_returns_empty(self):
"""Test that empty string returns empty string."""
assert normalize_metadata_value("") == ""
def test_unknown_placeholder_returns_empty(self):
"""Test that 'Unknown' placeholder returns empty string."""
assert normalize_metadata_value("Unknown") == ""
def test_normal_string_passes_through(self):
"""Test normal string values pass through."""
assert normalize_metadata_value("John Doe") == "John Doe"
def test_integer_converted_to_string(self):
"""Test integer values are converted to string."""
assert normalize_metadata_value(42) == "42"
def test_float_converted_to_string(self):
"""Test float values are converted to string."""
assert normalize_metadata_value(3.14) == "3.14"
def test_boolean_converted_to_string(self):
"""Test boolean values are converted to string."""
assert normalize_metadata_value(True) == "True"
@pytest.mark.unit
class TestIsDuplicateError:
"""Tests for _is_duplicate_error function."""
def test_empty_message(self):
"""Test that empty/None message returns False."""
assert _is_duplicate_error("") is False
assert _is_duplicate_error(None) is False
def test_duplicate_message(self):
"""Test detection of duplicate document message."""
assert _is_duplicate_error("Not consuming duplicate document") is True
def test_duplicate_case_insensitive(self):
"""Test case insensitive duplicate detection."""
assert _is_duplicate_error("DUPLICATE document not consuming") is True
def test_non_duplicate_message(self):
"""Test that non-duplicate messages return False."""
assert _is_duplicate_error("Processing completed") is False
def test_partial_match_not_duplicate(self):
"""Test that message with only 'duplicate' but not 'not consuming' returns False."""
assert _is_duplicate_error("Found a duplicate") is False
@pytest.mark.unit
class TestGetHeaders:
"""Tests for _get_headers function."""
def test_returns_auth_header(self):
"""Test that headers include authorization token."""
with patch("app.tasks.upload_to_paperless.settings") as mock_settings:
mock_settings.paperless_ngx_api_token = "test-token"
headers = _get_headers()
assert headers["Authorization"] == "Token test-token"
@pytest.mark.unit
class TestPaperlessApiUrl:
"""Tests for _paperless_api_url function."""
def test_constructs_url(self):
"""Test URL construction."""
with patch("app.tasks.upload_to_paperless.settings") as mock_settings:
mock_settings.paperless_host = "http://paperless:8000"
url = _paperless_api_url("/api/documents/")
assert url == "http://paperless:8000/api/documents/"
def test_strips_trailing_slash_from_host(self):
"""Test trailing slash is removed from host."""
with patch("app.tasks.upload_to_paperless.settings") as mock_settings:
mock_settings.paperless_host = "http://paperless:8000/"
url = _paperless_api_url("/api/documents/")
assert url == "http://paperless:8000/api/documents/"
def test_adds_leading_slash_to_path(self):
"""Test leading slash is added to path if missing."""
with patch("app.tasks.upload_to_paperless.settings") as mock_settings:
mock_settings.paperless_host = "http://paperless:8000"
url = _paperless_api_url("api/documents/")
assert url == "http://paperless:8000/api/documents/"
@pytest.mark.unit
class TestPollTaskForDocumentId:
"""Tests for poll_task_for_document_id function."""
@patch("app.tasks.upload_to_paperless.time.sleep")
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_success_returns_document_id(self, mock_settings, mock_get, mock_sleep):
"""Test successful polling returns document ID."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = [{"status": "SUCCESS", "related_document": "42"}]
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
result = poll_task_for_document_id("test-task-id")
assert result == 42
@patch("app.tasks.upload_to_paperless.time.sleep")
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_success_with_paginated_response(self, mock_settings, mock_get, mock_sleep):
"""Test polling handles paginated API responses."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = {
"results": [{"status": "SUCCESS", "related_document": "99"}]
}
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
result = poll_task_for_document_id("test-task-id")
assert result == 99
@patch("app.tasks.upload_to_paperless.time.sleep")
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_failure_raises_runtime_error(self, mock_settings, mock_get, mock_sleep):
"""Test that task failure raises RuntimeError."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = [{"status": "FAILURE", "result": "Processing error"}]
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
with pytest.raises(RuntimeError, match="failed"):
poll_task_for_document_id("test-task-id")
@patch("app.tasks.upload_to_paperless.time.sleep")
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_duplicate_returns_none(self, mock_settings, mock_get, mock_sleep):
"""Test that duplicate document failure returns None."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = [
{"status": "FAILURE", "result": "Not consuming duplicate document"}
]
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
result = poll_task_for_document_id("test-task-id")
assert result is None
@patch("app.tasks.upload_to_paperless.POLL_MAX_ATTEMPTS", 2)
@patch("app.tasks.upload_to_paperless.time.sleep")
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_timeout_raises_error(self, mock_settings, mock_get, mock_sleep):
"""Test that timeout raises TimeoutError."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
# Return empty results each time
mock_response = Mock()
mock_response.json.return_value = []
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
with pytest.raises(TimeoutError):
poll_task_for_document_id("test-task-id")
@patch("app.tasks.upload_to_paperless.POLL_MAX_ATTEMPTS", 2)
@patch("app.tasks.upload_to_paperless.time.sleep")
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_handles_request_exception(self, mock_settings, mock_get, mock_sleep):
"""Test that request exceptions are handled with retries."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_get.side_effect = requests.exceptions.ConnectionError("Connection refused")
with pytest.raises(TimeoutError):
poll_task_for_document_id("test-task-id")
@patch("app.tasks.upload_to_paperless.time.sleep")
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_success_without_document_id_raises(self, mock_settings, mock_get, mock_sleep):
"""Test success status without related_document raises RuntimeError."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = [{"status": "SUCCESS", "related_document": None}]
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
with pytest.raises(RuntimeError, match="no doc ID found"):
poll_task_for_document_id("test-task-id")
@pytest.mark.unit
class TestGetCustomFieldId:
"""Tests for get_custom_field_id function."""
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_finds_field_by_name(self, mock_settings, mock_get):
"""Test finding a custom field by name."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = {
"results": [{"name": "sender", "id": 5}, {"name": "date", "id": 6}]
}
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
assert get_custom_field_id("sender") == 5
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_field_not_found_raises_value_error(self, mock_settings, mock_get):
"""Test that missing field raises ValueError."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
with pytest.raises(ValueError, match="not found"):
get_custom_field_id("nonexistent")
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_handles_non_paginated_response(self, mock_settings, mock_get):
"""Test handling of non-paginated API response (list)."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.json.return_value = [{"name": "sender", "id": 5}]
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
assert get_custom_field_id("sender") == 5
@patch("app.tasks.upload_to_paperless.requests.get")
@patch("app.tasks.upload_to_paperless.settings")
def test_request_exception_is_raised(self, mock_settings, mock_get):
"""Test that request exceptions are propagated."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_get.side_effect = requests.exceptions.ConnectionError("Connection refused")
with pytest.raises(requests.exceptions.ConnectionError):
get_custom_field_id("sender")
@pytest.mark.unit
class TestSetDocumentCustomFields:
"""Tests for set_document_custom_fields function."""
@patch("app.tasks.upload_to_paperless.get_custom_field_id")
@patch("app.tasks.upload_to_paperless.requests.patch")
@patch("app.tasks.upload_to_paperless.settings")
def test_sets_custom_fields(self, mock_settings, mock_patch, mock_field_id):
"""Test setting custom fields on a document."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_field_id.return_value = 5
mock_response = Mock()
mock_response.raise_for_status = Mock()
mock_patch.return_value = mock_response
set_document_custom_fields(42, {"sender": "John Doe"}, "task-123")
mock_patch.assert_called_once()
def test_empty_fields_returns_immediately(self):
"""Test that empty custom fields dict returns without API calls."""
with patch("app.tasks.upload_to_paperless.requests.patch") as mock_patch:
set_document_custom_fields(42, {}, "task-123")
mock_patch.assert_not_called()
@patch("app.tasks.upload_to_paperless.get_custom_field_id")
@patch("app.tasks.upload_to_paperless.settings")
def test_skips_unknown_values(self, mock_settings, mock_field_id):
"""Test that 'Unknown' values are skipped."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
with patch("app.tasks.upload_to_paperless.requests.patch") as mock_patch:
set_document_custom_fields(42, {"sender": "Unknown"}, "task-123")
mock_patch.assert_not_called()
@patch("app.tasks.upload_to_paperless.get_custom_field_id")
@patch("app.tasks.upload_to_paperless.requests.patch")
@patch("app.tasks.upload_to_paperless.settings")
def test_skips_field_not_found(self, mock_settings, mock_patch, mock_field_id):
"""Test that fields not found in Paperless are skipped."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_field_id.side_effect = ValueError("Custom field 'foo' not found")
set_document_custom_fields(42, {"foo": "bar"}, "task-123")
mock_patch.assert_not_called()
@patch("app.tasks.upload_to_paperless.get_custom_field_id")
@patch("app.tasks.upload_to_paperless.requests.patch")
@patch("app.tasks.upload_to_paperless.settings")
def test_handles_patch_failure(self, mock_settings, mock_patch, mock_field_id):
"""Test that PATCH failure is logged but does not raise."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_field_id.return_value = 5
mock_exc = requests.exceptions.HTTPError("500 Server Error")
mock_exc.response = Mock()
mock_exc.response.text = "Internal server error"
mock_patch.side_effect = mock_exc
# Should not raise
set_document_custom_fields(42, {"sender": "John"}, "task-123")
@pytest.mark.unit
class TestUploadToPaperless:
"""Tests for upload_to_paperless Celery task."""
@patch("app.tasks.upload_to_paperless.log_task_progress")
def test_file_not_found(self, mock_log):
"""Test that missing file raises FileNotFoundError."""
mock_self = MagicMock()
mock_self.request.id = "test-task"
with pytest.raises(FileNotFoundError):
upload_to_paperless.__wrapped__(mock_self, "/nonexistent/file.pdf", file_id=1)
@patch("app.tasks.upload_to_paperless.set_document_custom_fields")
@patch("app.tasks.upload_to_paperless.poll_task_for_document_id")
@patch("app.tasks.upload_to_paperless.requests.post")
@patch("app.tasks.upload_to_paperless.settings")
@patch("app.tasks.upload_to_paperless.log_task_progress")
def test_successful_upload(self, mock_log, mock_settings, mock_post, mock_poll, mock_set_fields, tmp_path):
"""Test successful upload to Paperless."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_settings.paperless_custom_fields_mapping = None
mock_settings.paperless_custom_field_absender = None
# Create test file
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 test content")
mock_response = Mock()
mock_response.text = '"task-uuid-123"'
mock_response.raise_for_status = Mock()
mock_post.return_value = mock_response
mock_poll.return_value = 42
result = upload_to_paperless.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
assert result["paperless_document_id"] == 42
@patch("app.tasks.upload_to_paperless.poll_task_for_document_id")
@patch("app.tasks.upload_to_paperless.requests.post")
@patch("app.tasks.upload_to_paperless.settings")
@patch("app.tasks.upload_to_paperless.log_task_progress")
def test_duplicate_document(self, mock_log, mock_settings, mock_post, mock_poll, tmp_path):
"""Test handling of duplicate document detection."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_settings.paperless_custom_fields_mapping = None
mock_settings.paperless_custom_field_absender = None
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 test content")
mock_response = Mock()
mock_response.text = '"task-uuid-123"'
mock_response.raise_for_status = Mock()
mock_post.return_value = mock_response
mock_poll.return_value = None # Duplicate detected
result = upload_to_paperless.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Duplicate"
assert result["paperless_document_id"] is None
@patch("app.tasks.upload_to_paperless.log_task_progress")
@patch("app.tasks.upload_to_paperless.settings")
def test_missing_config_raises_value_error(self, mock_settings, mock_log, tmp_path):
"""Test that missing Paperless config raises ValueError."""
mock_settings.paperless_host = ""
mock_settings.paperless_ngx_api_token = ""
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 test content")
mock_self = MagicMock()
mock_self.request.id = "test-task"
with pytest.raises(ValueError, match="not fully configured"):
upload_to_paperless.__wrapped__(mock_self, str(test_file), file_id=1)
@patch("app.tasks.upload_to_paperless.requests.post")
@patch("app.tasks.upload_to_paperless.settings")
@patch("app.tasks.upload_to_paperless.log_task_progress")
def test_upload_request_failure(self, mock_log, mock_settings, mock_post, tmp_path):
"""Test that failed HTTP request raises."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 test content")
mock_exc = requests.exceptions.ConnectionError("Connection refused")
mock_exc.response = None
mock_post.side_effect = mock_exc
mock_self = MagicMock()
mock_self.request.id = "test-task"
with pytest.raises(requests.exceptions.ConnectionError):
upload_to_paperless.__wrapped__(mock_self, str(test_file), file_id=1)
@patch("app.tasks.upload_to_paperless.set_document_custom_fields")
@patch("app.tasks.upload_to_paperless.poll_task_for_document_id")
@patch("app.tasks.upload_to_paperless.requests.post")
@patch("app.tasks.upload_to_paperless.settings")
@patch("app.tasks.upload_to_paperless.log_task_progress")
def test_loads_metadata_from_json(self, mock_log, mock_settings, mock_post, mock_poll, mock_set_fields, tmp_path):
"""Test that metadata is loaded from accompanying JSON file."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_settings.paperless_custom_fields_mapping = json.dumps({"absender": "Sender"})
mock_settings.paperless_custom_field_absender = None
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 test content")
# Create metadata JSON
json_file = tmp_path / "test.json"
json_file.write_text(json.dumps({"absender": "Test Sender", "date": "2024-01-01"}))
mock_response = Mock()
mock_response.text = '"task-uuid-123"'
mock_response.raise_for_status = Mock()
mock_post.return_value = mock_response
mock_poll.return_value = 42
result = upload_to_paperless.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
mock_set_fields.assert_called_once()
# Verify the custom fields include the mapped metadata
call_args = mock_set_fields.call_args
assert "Sender" in call_args[0][1]
@patch("app.tasks.upload_to_paperless.set_document_custom_fields")
@patch("app.tasks.upload_to_paperless.poll_task_for_document_id")
@patch("app.tasks.upload_to_paperless.requests.post")
@patch("app.tasks.upload_to_paperless.settings")
@patch("app.tasks.upload_to_paperless.log_task_progress")
def test_legacy_absender_field(self, mock_log, mock_settings, mock_post, mock_poll, mock_set_fields, tmp_path):
"""Test legacy absender field configuration fallback."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_settings.paperless_custom_fields_mapping = None
mock_settings.paperless_custom_field_absender = "Absender"
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 test content")
json_file = tmp_path / "test.json"
json_file.write_text(json.dumps({"absender": "Legacy Sender"}))
mock_response = Mock()
mock_response.text = '"task-uuid-123"'
mock_response.raise_for_status = Mock()
mock_post.return_value = mock_response
mock_poll.return_value = 42
result = upload_to_paperless.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
mock_set_fields.assert_called_once()
call_args = mock_set_fields.call_args
assert "Absender" in call_args[0][1]
@patch("app.tasks.upload_to_paperless.set_document_custom_fields")
@patch("app.tasks.upload_to_paperless.poll_task_for_document_id")
@patch("app.tasks.upload_to_paperless.requests.post")
@patch("app.tasks.upload_to_paperless.settings")
@patch("app.tasks.upload_to_paperless.log_task_progress")
def test_invalid_json_mapping_handled(
self, mock_log, mock_settings, mock_post, mock_poll, mock_set_fields, tmp_path
):
"""Test that invalid JSON mapping does not crash the task."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_settings.paperless_custom_fields_mapping = "not-valid-json"
mock_settings.paperless_custom_field_absender = None
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 test content")
mock_response = Mock()
mock_response.text = '"task-uuid-123"'
mock_response.raise_for_status = Mock()
mock_post.return_value = mock_response
mock_poll.return_value = 42
result = upload_to_paperless.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
@patch("app.tasks.upload_to_paperless.set_document_custom_fields")
@patch("app.tasks.upload_to_paperless.poll_task_for_document_id")
@patch("app.tasks.upload_to_paperless.requests.post")
@patch("app.tasks.upload_to_paperless.settings")
@patch("app.tasks.upload_to_paperless.log_task_progress")
def test_custom_fields_failure_does_not_fail_upload(
self, mock_log, mock_settings, mock_post, mock_poll, mock_set_fields, tmp_path
):
"""Test that custom field errors don't fail the entire upload."""
mock_settings.paperless_host = "http://paperless:8000"
mock_settings.paperless_ngx_api_token = "test-token"
mock_settings.http_request_timeout = 30
mock_settings.paperless_custom_fields_mapping = json.dumps({"absender": "Sender"})
mock_settings.paperless_custom_field_absender = None
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"%PDF-1.4 test content")
json_file = tmp_path / "test.json"
json_file.write_text(json.dumps({"absender": "Test"}))
mock_response = Mock()
mock_response.text = '"task-uuid-123"'
mock_response.raise_for_status = Mock()
mock_post.return_value = mock_response
mock_poll.return_value = 42
mock_set_fields.side_effect = Exception("Custom fields error")
result = upload_to_paperless.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
# Upload should still succeed even though custom fields failed
assert result["status"] == "Completed"
+308
View File
@@ -0,0 +1,308 @@
"""
Tests for app/tasks/upload_with_rclone.py module.
Extends existing tests with comprehensive coverage for upload_with_rclone
and send_to_all_rclone_destinations Celery tasks.
"""
import os
import subprocess
from unittest.mock import MagicMock, Mock, call, patch
import pytest
from app.tasks.upload_with_rclone import send_to_all_rclone_destinations, upload_with_rclone
@pytest.mark.unit
class TestUploadWithRcloneExtended:
"""Extended tests for upload_with_rclone task."""
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_successful_upload(self, mock_settings, mock_log, mock_run, tmp_path):
"""Test successful rclone upload."""
mock_settings.workdir = str(tmp_path)
# Create test file and rclone config
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype = drive\n")
# Mock successful subprocess calls
mock_mkdir = Mock()
mock_mkdir.returncode = 0
mock_upload = Mock()
mock_upload.returncode = 0
mock_upload.stdout = ""
mock_upload.stderr = ""
mock_link = Mock()
mock_link.returncode = 0
mock_link.stdout = "https://drive.google.com/file/abc123\n"
mock_run.side_effect = [mock_mkdir, mock_upload, mock_link]
result = upload_with_rclone(str(test_file), "gdrive:uploads")
assert result["status"] == "Completed"
assert result["destination"] == "gdrive:uploads"
assert result["public_url"] == "https://drive.google.com/file/abc123"
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_successful_upload_no_public_url(self, mock_settings, mock_log, mock_run, tmp_path):
"""Test successful upload when public link is not available."""
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype = drive\n")
mock_mkdir = Mock()
mock_mkdir.returncode = 0
mock_upload = Mock()
mock_upload.returncode = 0
mock_link = Mock()
mock_link.returncode = 1 # Public link not available
mock_run.side_effect = [mock_mkdir, mock_upload, mock_link]
result = upload_with_rclone(str(test_file), "gdrive:uploads")
assert result["status"] == "Completed"
assert result["public_url"] is None
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_upload_link_exception(self, mock_settings, mock_log, mock_run, tmp_path):
"""Test that link failure does not fail the upload."""
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype = drive\n")
mock_mkdir = Mock()
mock_mkdir.returncode = 0
mock_upload = Mock()
mock_upload.returncode = 0
# First two calls succeed, link raises exception
mock_run.side_effect = [mock_mkdir, mock_upload, subprocess.SubprocessError("link failed")]
result = upload_with_rclone(str(test_file), "gdrive:uploads")
assert result["status"] == "Completed"
assert result["public_url"] is None
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_mkdir_failure(self, mock_settings, mock_log, mock_run, tmp_path):
"""Test rclone mkdir failure."""
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype = drive\n")
mock_run.side_effect = subprocess.CalledProcessError(
1, "rclone", stderr=b"mkdir failed"
)
with pytest.raises(RuntimeError, match="Rclone error"):
upload_with_rclone(str(test_file), "gdrive:uploads")
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_upload_command_failure(self, mock_settings, mock_log, mock_run, tmp_path):
"""Test rclone copy command failure."""
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype = drive\n")
mock_mkdir = Mock()
mock_mkdir.returncode = 0
mock_run.side_effect = [
mock_mkdir,
subprocess.CalledProcessError(1, "rclone", stderr="upload failed"),
]
with pytest.raises(RuntimeError, match="Rclone error"):
upload_with_rclone(str(test_file), "gdrive:uploads")
@patch("app.tasks.upload_with_rclone.log_task_progress")
def test_os_error_handling(self, mock_log, tmp_path):
"""Test OSError during rclone execution."""
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype = drive\n")
with patch("app.tasks.upload_with_rclone.settings") as mock_settings:
mock_settings.workdir = str(tmp_path)
with patch("app.tasks.upload_with_rclone.subprocess.run", side_effect=OSError("Permission denied")):
with pytest.raises(RuntimeError, match="Error uploading"):
upload_with_rclone(str(test_file), "gdrive:uploads")
@patch("app.tasks.upload_with_rclone.log_task_progress")
def test_validates_remote_name_special_chars(self, mock_log, tmp_path):
"""Test that special characters in remote name are rejected."""
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
with pytest.raises(ValueError, match="Invalid remote name"):
upload_with_rclone(str(test_file), "rem ote:path")
@patch("app.tasks.upload_with_rclone.log_task_progress")
def test_validates_remote_name_with_underscore_hyphen(self, mock_log, tmp_path):
"""Test that underscores and hyphens in remote names are valid."""
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
with patch("app.tasks.upload_with_rclone.settings") as mock_settings:
mock_settings.workdir = str(tmp_path)
# No rclone.conf -> ValueError for config not found
with pytest.raises(ValueError, match="Rclone configuration not found"):
upload_with_rclone(str(test_file), "my-remote_1:path")
@pytest.mark.unit
class TestSendToAllRcloneDestinations:
"""Tests for send_to_all_rclone_destinations task."""
def test_file_not_found(self):
"""Test raises FileNotFoundError for missing file."""
with pytest.raises(FileNotFoundError):
send_to_all_rclone_destinations("/nonexistent/file.pdf")
@patch("app.tasks.upload_with_rclone.upload_with_rclone")
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_successful_queue_to_all_destinations(self, mock_settings, mock_log, mock_run, mock_upload, tmp_path):
"""Test queuing uploads to all configured remotes."""
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype=drive\n[s3]\ntype=s3\n")
mock_remotes = Mock()
mock_remotes.returncode = 0
mock_remotes.stdout = "gdrive:\ns3:\n"
mock_run.return_value = mock_remotes
mock_task = Mock()
mock_task.id = "task-123"
mock_upload.delay.return_value = mock_task
result = send_to_all_rclone_destinations(str(test_file))
assert result["status"] == "Queued"
assert "tasks" in result
assert len(result["tasks"]) == 2
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_no_rclone_config(self, mock_settings, mock_log, mock_run, tmp_path):
"""Test error when rclone config is missing."""
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
with pytest.raises(ValueError, match="Rclone configuration not found"):
send_to_all_rclone_destinations(str(test_file))
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_listremotes_failure(self, mock_settings, mock_log, mock_run, tmp_path):
"""Test error when listremotes command fails."""
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype=drive\n")
mock_run.side_effect = subprocess.SubprocessError("rclone not found")
with pytest.raises(RuntimeError, match="Error setting up"):
send_to_all_rclone_destinations(str(test_file))
@patch("app.tasks.upload_with_rclone.upload_with_rclone")
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_uses_custom_path_settings(self, mock_settings, mock_log, mock_run, mock_upload, tmp_path):
"""Test that custom path settings are used for each remote."""
mock_settings.workdir = str(tmp_path)
mock_settings.rclone_gdrive_path = "Documents/Uploads"
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype=drive\n")
mock_remotes = Mock()
mock_remotes.returncode = 0
mock_remotes.stdout = "gdrive:\n"
mock_run.return_value = mock_remotes
mock_task = Mock()
mock_task.id = "task-123"
mock_upload.delay.return_value = mock_task
result = send_to_all_rclone_destinations(str(test_file))
assert result["status"] == "Queued"
# Check the destination includes the custom path
call_args = mock_upload.delay.call_args
assert "Documents/Uploads" in call_args[0][1]
@patch("app.tasks.upload_with_rclone.subprocess.run")
@patch("app.tasks.upload_with_rclone.log_task_progress")
@patch("app.tasks.upload_with_rclone.settings")
def test_listremotes_nonzero_return_code(self, mock_settings, mock_log, mock_run, tmp_path):
"""Test error when listremotes returns non-zero exit code."""
mock_settings.workdir = str(tmp_path)
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
rclone_config = tmp_path / "rclone.conf"
rclone_config.write_text("[gdrive]\ntype=drive\n")
mock_result = Mock()
mock_result.returncode = 1
mock_result.stderr = "config error"
mock_run.return_value = mock_result
with pytest.raises(RuntimeError, match="Failed to list rclone remotes"):
send_to_all_rclone_destinations(str(test_file))