fix: resolve merge conflict in database.py
Merge origin/main into branch, resolving conflict in app/database.py. Combined improvements from both branches: - Keep pool_pre_ping=True and structured variable approach from feature branch - Add explicit QueuePool import and poolclass assignment from main
This commit is contained in:
@@ -329,7 +329,7 @@ class TestDeactivateDevice:
|
||||
"""Tests for DELETE /api/mobile/devices/{device_id}."""
|
||||
|
||||
def test_deactivate_own_device(self, mob_engine, mob_session):
|
||||
"""Deactivating a device sets is_active to False."""
|
||||
"""Deactivating an active device sets is_active to False (soft-delete, returns 200)."""
|
||||
from app.main import app
|
||||
|
||||
device = MobileDevice(
|
||||
@@ -346,7 +346,8 @@ class TestDeactivateDevice:
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.delete(f"/api/mobile/devices/{device_id}")
|
||||
assert resp.status_code == 204
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["detail"] == "Device deactivated"
|
||||
|
||||
mob_session.expire_all()
|
||||
updated = mob_session.get(MobileDevice, device_id)
|
||||
@@ -355,6 +356,33 @@ class TestDeactivateDevice:
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_delete_inactive_device(self, mob_engine, mob_session):
|
||||
"""Deleting an already-inactive device permanently removes it (hard-delete, returns 200)."""
|
||||
from app.main import app
|
||||
|
||||
device = MobileDevice(
|
||||
owner_id=_OWNER,
|
||||
push_token=_EXPO_TOKEN,
|
||||
platform="ios",
|
||||
is_active=False,
|
||||
)
|
||||
mob_session.add(device)
|
||||
mob_session.commit()
|
||||
mob_session.refresh(device)
|
||||
device_id = device.id
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.delete(f"/api/mobile/devices/{device_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["detail"] == "Device deleted"
|
||||
|
||||
mob_session.expire_all()
|
||||
deleted = mob_session.get(MobileDevice, device_id)
|
||||
assert deleted is None
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session):
|
||||
"""Attempting to deactivate another user's device returns 404."""
|
||||
from app.main import app
|
||||
|
||||
+223
-4
@@ -314,8 +314,8 @@ class TestTokenRevoke:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_revoke_already_revoked_token(self, tok_engine):
|
||||
"""Revoking an already-revoked token should return 400."""
|
||||
def test_delete_already_revoked_token(self, tok_engine):
|
||||
"""Deleting an already-revoked token should permanently remove it (hard-delete, 200)."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
@@ -324,9 +324,15 @@ class TestTokenRevoke:
|
||||
token_id = create_resp.json()["id"]
|
||||
client.delete(f"/api/api-tokens/{token_id}")
|
||||
|
||||
# Second DELETE should hard-delete the revoked token.
|
||||
resp = client.delete(f"/api/api-tokens/{token_id}")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Token is already revoked"
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["detail"] == "Token deleted"
|
||||
|
||||
# Token must no longer appear in the list.
|
||||
list_resp = client.get("/api/api-tokens/")
|
||||
ids = [t["id"] for t in list_resp.json()]
|
||||
assert token_id not in ids
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@@ -677,3 +683,216 @@ class TestTokenUtils:
|
||||
token = "de_test_token_value"
|
||||
expected_hash = "9b89d9adf2f390c75bf2fd0ff2bb5622ef5a9dce438354cce6e39f2f5401129e"
|
||||
assert hash_token(token) == expected_hash
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Token reactivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenReactivate:
|
||||
"""Tests for POST /api/api-tokens/{id}/reactivate."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reactivate_revoked_token(self, tok_engine):
|
||||
"""Reactivating a revoked token should set is_active=True and clear revoked_at."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
create_resp = client.post("/api/api-tokens/", json={"name": "Reactivate Me"})
|
||||
token_id = create_resp.json()["id"]
|
||||
client.delete(f"/api/api-tokens/{token_id}")
|
||||
|
||||
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_active"] is True
|
||||
assert data["revoked_at"] is None
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reactivate_active_token_returns_400(self, tok_engine):
|
||||
"""Reactivating an already-active token should return 400."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
create_resp = client.post("/api/api-tokens/", json={"name": "Already Active"})
|
||||
token_id = create_resp.json()["id"]
|
||||
|
||||
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Token is already active"
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reactivate_nonexistent_token(self, tok_engine):
|
||||
"""Reactivating a non-existent token should return 404."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.post("/api/api-tokens/99999/reactivate")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_reactivate_other_users_token(self, tok_engine):
|
||||
"""A user cannot reactivate another user's token."""
|
||||
from app.main import app
|
||||
|
||||
client_a = _make_client(tok_engine, _OWNER)
|
||||
try:
|
||||
create_resp = client_a.post("/api/api-tokens/", json={"name": "A Token"})
|
||||
token_id = create_resp.json()["id"]
|
||||
client_a.delete(f"/api/api-tokens/{token_id}")
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
client_b = _make_client(tok_engine, _OTHER_OWNER)
|
||||
try:
|
||||
resp = client_b.post(f"/api/api-tokens/{token_id}/reactivate")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Token lifetime (expires_at)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTokenExpiry:
|
||||
"""Tests for token creation with optional lifetime and expiry enforcement."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_token_without_expiry(self, tok_engine):
|
||||
"""Creating a token without expires_in_days should leave expires_at as None."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.post("/api/api-tokens/", json={"name": "No Expiry"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["expires_at"] is None
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_token_with_expiry(self, tok_engine, tok_session):
|
||||
"""Creating a token with expires_in_days should set expires_at in the future."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.post("/api/api-tokens/", json={"name": "With Expiry", "expires_in_days": 30})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["expires_at"] is not None
|
||||
# Parse the returned datetime; handle both tz-aware and tz-naive serialisations
|
||||
expires_str = data["expires_at"].replace("Z", "+00:00")
|
||||
expires_at = datetime.fromisoformat(expires_str)
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
delta_days = (expires_at - now).days
|
||||
assert 28 <= delta_days <= 30
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_expired_token_not_resolved(self, tok_engine, tok_session):
|
||||
"""A token past its expires_at should not authenticate."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
from app.auth import _resolve_bearer_user
|
||||
|
||||
plaintext = generate_api_token()
|
||||
token_hash = hash_token(plaintext)
|
||||
|
||||
db_token = ApiToken(
|
||||
owner_id=_OWNER,
|
||||
name="Expired Token",
|
||||
token_hash=token_hash,
|
||||
token_prefix=plaintext[:12],
|
||||
is_active=True,
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(days=1), # expired yesterday
|
||||
)
|
||||
tok_session.add(db_token)
|
||||
tok_session.commit()
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
user = _resolve_bearer_user(mock_request, tok_session)
|
||||
assert user is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_non_expired_token_resolves(self, tok_engine, tok_session):
|
||||
"""A token before its expires_at should authenticate normally."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
from app.auth import _resolve_bearer_user
|
||||
|
||||
plaintext = generate_api_token()
|
||||
token_hash = hash_token(plaintext)
|
||||
|
||||
db_token = ApiToken(
|
||||
owner_id=_OWNER,
|
||||
name="Valid Token",
|
||||
token_hash=token_hash,
|
||||
token_prefix=plaintext[:12],
|
||||
is_active=True,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=30), # expires in 30 days
|
||||
)
|
||||
tok_session.add(db_token)
|
||||
tok_session.commit()
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
user = _resolve_bearer_user(mock_request, tok_session)
|
||||
assert user is not None
|
||||
assert user["preferred_username"] == _OWNER
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_token_expires_in_days_zero_rejected(self, tok_engine):
|
||||
"""expires_in_days=0 should be rejected with 422 (ge=1)."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.post("/api/api-tokens/", json={"name": "Bad Expiry", "expires_in_days": 0})
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_expires_at_included_in_list_response(self, tok_engine):
|
||||
"""List endpoint should include expires_at field."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
client.post("/api/api-tokens/", json={"name": "Listed", "expires_in_days": 7})
|
||||
resp = client.get("/api/api-tokens/")
|
||||
assert resp.status_code == 200
|
||||
tokens = resp.json()
|
||||
assert len(tokens) == 1
|
||||
assert "expires_at" in tokens[0]
|
||||
assert tokens[0]["expires_at"] is not None
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@@ -665,6 +665,7 @@ def _all_should_upload_false():
|
||||
"email",
|
||||
"onedrive",
|
||||
"s3",
|
||||
"sharepoint",
|
||||
"icloud",
|
||||
]
|
||||
return [patch(f"app.tasks.send_to_all._should_upload_to_{s}", return_value=False) for s in services]
|
||||
@@ -694,6 +695,7 @@ class TestSendToAllCoverage:
|
||||
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
|
||||
patch("app.tasks.send_to_all.SessionLocal") as mock_session_cls,
|
||||
):
|
||||
@@ -806,6 +808,7 @@ class TestSendToAllCoverage:
|
||||
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
|
||||
patch("app.tasks.send_to_all.SessionLocal"),
|
||||
):
|
||||
@@ -866,6 +869,7 @@ class TestSendToAllCoverage:
|
||||
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
|
||||
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
|
||||
patch("app.tasks.send_to_all.SessionLocal"),
|
||||
):
|
||||
|
||||
@@ -337,6 +337,27 @@ class TestCSRFMiddlewareDispatch:
|
||||
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_auth_claim_is_exempt(self):
|
||||
"""QR auth claim path is exempt from CSRF validation.
|
||||
|
||||
The mobile app calls this endpoint without a browser session and
|
||||
therefore without a CSRF token. The cryptographically-random,
|
||||
single-use challenge token provides equivalent protection.
|
||||
"""
|
||||
middleware = self._make_middleware()
|
||||
request = self._make_request(
|
||||
method="POST",
|
||||
path="/api/qr-auth/claim",
|
||||
session={},
|
||||
)
|
||||
call_next = AsyncMock(return_value=MagicMock())
|
||||
|
||||
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
|
||||
result = await middleware.dispatch(request, call_next)
|
||||
|
||||
call_next.assert_called_once_with(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests – via TestClient
|
||||
@@ -362,6 +383,7 @@ class TestCSRFIntegration:
|
||||
assert "PATCH" in CSRF_PROTECTED_METHODS
|
||||
assert "GET" not in CSRF_PROTECTED_METHODS
|
||||
assert "/oauth-callback" in CSRF_EXEMPT_PATHS
|
||||
assert "/api/qr-auth/claim" in CSRF_EXEMPT_PATHS
|
||||
|
||||
def test_csrf_middleware_noop_when_auth_disabled(self):
|
||||
"""When AUTH_ENABLED=False the middleware dispatch is a no-op (no validation)."""
|
||||
|
||||
@@ -360,6 +360,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
@@ -368,6 +369,7 @@ class TestSendToAllDestinations:
|
||||
mock_upload,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -397,6 +399,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
@@ -410,6 +413,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_paperless")
|
||||
@@ -434,6 +438,7 @@ class TestSendToAllDestinations:
|
||||
mock_paperless,
|
||||
mock_nextcloud,
|
||||
mock_should_s3,
|
||||
mock_sharepoint,
|
||||
mock_icloud,
|
||||
mock_should_dropbox,
|
||||
mock_settings,
|
||||
@@ -456,6 +461,7 @@ class TestSendToAllDestinations:
|
||||
mock_sftp.return_value = False
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_dropbox_upload.delay.return_value = MagicMock(id="dropbox-task")
|
||||
mock_s3_upload.delay.return_value = MagicMock(id="s3-task")
|
||||
@@ -478,12 +484,14 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
def test_skips_unconfigured_services(
|
||||
self,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -513,6 +521,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
||||
@@ -534,6 +543,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
@@ -542,6 +552,7 @@ class TestSendToAllDestinations:
|
||||
mock_upload,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -571,6 +582,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
@@ -593,6 +605,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||
@@ -603,6 +616,7 @@ class TestSendToAllDestinations:
|
||||
mock_validator,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -633,6 +647,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
@@ -653,6 +668,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||
@@ -661,6 +677,7 @@ class TestSendToAllDestinations:
|
||||
mock_validator,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -691,6 +708,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
# Should not raise, should fall back to individual checks
|
||||
@@ -710,6 +728,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
@@ -718,6 +737,7 @@ class TestSendToAllDestinations:
|
||||
mock_upload,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -747,6 +767,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.side_effect = Exception("Queue error")
|
||||
|
||||
@@ -758,6 +779,7 @@ class TestSendToAllDestinations:
|
||||
assert "dropbox_error" in result.result["tasks"]
|
||||
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@@ -786,6 +808,7 @@ class TestSendToAllDestinations:
|
||||
mock_email,
|
||||
mock_onedrive,
|
||||
mock_s3,
|
||||
mock_sharepoint,
|
||||
mock_icloud,
|
||||
tmp_path,
|
||||
):
|
||||
@@ -808,6 +831,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
# Mock database session
|
||||
@@ -836,12 +860,14 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
def test_should_upload_check_exception_handling(
|
||||
self,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -871,6 +897,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
# Should not raise, should treat as not configured
|
||||
|
||||
@@ -401,6 +401,35 @@ class TestQRLogin:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
assert expires > datetime.now(timezone.utc)
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_create_qr_challenge_ttl_seconds(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that ttl_seconds can be derived from created_at and expires_at.
|
||||
|
||||
The API endpoint computes ttl_seconds = (expires_at - created_at) to
|
||||
allow the client to run a countdown timer without comparing absolute
|
||||
timestamps (avoiding clock-skew issues).
|
||||
"""
|
||||
from app.utils.session_manager import create_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
|
||||
ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
|
||||
assert ttl_seconds == 120
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_create_qr_challenge_custom_ttl(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that a custom TTL is correctly reflected in the challenge timestamps."""
|
||||
from app.utils.session_manager import create_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 300
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
|
||||
ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
|
||||
assert ttl_seconds == 300
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test validating a valid QR challenge."""
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
Tests for app/tasks/upload_to_sharepoint.py module.
|
||||
|
||||
Covers get_sharepoint_token, resolve_sharepoint_drive,
|
||||
create_sharepoint_upload_session, upload_large_file_sharepoint,
|
||||
and upload_to_sharepoint Celery task.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetSharepointToken:
|
||||
"""Tests for get_sharepoint_token function."""
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_refresh_token_flow(self, mock_settings, mock_msal):
|
||||
"""Test token acquisition using refresh token."""
|
||||
from app.tasks.upload_to_sharepoint import get_sharepoint_token
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_client_secret = "client-secret"
|
||||
mock_settings.sharepoint_refresh_token = "refresh-token"
|
||||
mock_settings.sharepoint_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_sharepoint_token()
|
||||
assert token == "new-access-token"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
|
||||
@patch("app.tasks.upload_to_sharepoint.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_sharepoint import get_sharepoint_token
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_client_secret = "client-secret"
|
||||
mock_settings.sharepoint_refresh_token = "old-refresh-token"
|
||||
mock_settings.sharepoint_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_sharepoint_token()
|
||||
assert mock_settings.sharepoint_refresh_token == "new-refresh-token"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_refresh_token_failure(self, mock_settings, mock_msal):
|
||||
"""Test error handling when refresh token fails."""
|
||||
from app.tasks.upload_to_sharepoint import get_sharepoint_token
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_client_secret = "client-secret"
|
||||
mock_settings.sharepoint_refresh_token = "expired-token"
|
||||
mock_settings.sharepoint_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 SharePoint access token"):
|
||||
get_sharepoint_token()
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_client_credentials_flow(self, mock_settings, mock_msal):
|
||||
"""Test token acquisition using client credentials (org accounts)."""
|
||||
from app.tasks.upload_to_sharepoint import get_sharepoint_token
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_client_secret = "client-secret"
|
||||
mock_settings.sharepoint_refresh_token = ""
|
||||
mock_settings.sharepoint_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_sharepoint_token()
|
||||
assert token == "client-cred-token"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_client_credentials_failure(self, mock_settings, mock_msal):
|
||||
"""Test error handling when client credentials flow fails."""
|
||||
from app.tasks.upload_to_sharepoint import get_sharepoint_token
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_client_secret = "client-secret"
|
||||
mock_settings.sharepoint_refresh_token = ""
|
||||
mock_settings.sharepoint_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 SharePoint access token"):
|
||||
get_sharepoint_token()
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_missing_client_id(self, mock_settings):
|
||||
"""Test error when client ID is missing."""
|
||||
from app.tasks.upload_to_sharepoint import get_sharepoint_token
|
||||
|
||||
mock_settings.sharepoint_client_id = ""
|
||||
mock_settings.sharepoint_client_secret = "secret"
|
||||
|
||||
with pytest.raises(ValueError, match="client ID and client secret"):
|
||||
get_sharepoint_token()
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_no_refresh_token_common_tenant(self, mock_settings):
|
||||
"""Test error for common tenant without refresh token."""
|
||||
from app.tasks.upload_to_sharepoint import get_sharepoint_token
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_client_secret = "secret"
|
||||
mock_settings.sharepoint_refresh_token = ""
|
||||
mock_settings.sharepoint_tenant_id = "common"
|
||||
|
||||
with pytest.raises(ValueError, match="either a refresh token or a non-'common' tenant ID"):
|
||||
get_sharepoint_token()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestResolveSharepointDrive:
|
||||
"""Tests for resolve_sharepoint_drive function."""
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.get")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_successful_resolution(self, mock_settings, mock_get):
|
||||
"""Test successful site and drive resolution."""
|
||||
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
|
||||
|
||||
mock_settings.http_request_timeout = 30
|
||||
|
||||
site_resp = Mock()
|
||||
site_resp.status_code = 200
|
||||
site_resp.json.return_value = {"id": "site-id-123"}
|
||||
|
||||
drives_resp = Mock()
|
||||
drives_resp.status_code = 200
|
||||
drives_resp.json.return_value = {
|
||||
"value": [
|
||||
{"id": "drive-1", "name": "Documents"},
|
||||
{"id": "drive-2", "name": "Site Assets"},
|
||||
]
|
||||
}
|
||||
|
||||
mock_get.side_effect = [site_resp, drives_resp]
|
||||
|
||||
site_id, drive_id = resolve_sharepoint_drive(
|
||||
"access-token", "https://tenant.sharepoint.com/sites/mysite", "Documents"
|
||||
)
|
||||
|
||||
assert site_id == "site-id-123"
|
||||
assert drive_id == "drive-1"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.get")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_library_not_found(self, mock_settings, mock_get):
|
||||
"""Test error when document library is not found."""
|
||||
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
|
||||
|
||||
mock_settings.http_request_timeout = 30
|
||||
|
||||
site_resp = Mock()
|
||||
site_resp.status_code = 200
|
||||
site_resp.json.return_value = {"id": "site-id-123"}
|
||||
|
||||
drives_resp = Mock()
|
||||
drives_resp.status_code = 200
|
||||
drives_resp.json.return_value = {
|
||||
"value": [
|
||||
{"id": "drive-1", "name": "Documents"},
|
||||
]
|
||||
}
|
||||
|
||||
mock_get.side_effect = [site_resp, drives_resp]
|
||||
|
||||
with pytest.raises(RuntimeError, match="not found on site"):
|
||||
resolve_sharepoint_drive("access-token", "https://tenant.sharepoint.com/sites/mysite", "NonExistentLibrary")
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.get")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_site_resolution_failure(self, mock_settings, mock_get):
|
||||
"""Test error when site resolution fails."""
|
||||
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
|
||||
|
||||
mock_settings.http_request_timeout = 30
|
||||
|
||||
site_resp = Mock()
|
||||
site_resp.status_code = 404
|
||||
site_resp.text = "Site not found"
|
||||
|
||||
mock_get.return_value = site_resp
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to resolve SharePoint site"):
|
||||
resolve_sharepoint_drive("access-token", "https://tenant.sharepoint.com/sites/nonexistent", "Documents")
|
||||
|
||||
def test_invalid_site_url(self):
|
||||
"""Test error with invalid site URL."""
|
||||
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid SharePoint site URL"):
|
||||
resolve_sharepoint_drive("access-token", "not-a-url", "Documents")
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.get")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_case_insensitive_library_match(self, mock_settings, mock_get):
|
||||
"""Test that library name matching is case-insensitive."""
|
||||
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
|
||||
|
||||
mock_settings.http_request_timeout = 30
|
||||
|
||||
site_resp = Mock()
|
||||
site_resp.status_code = 200
|
||||
site_resp.json.return_value = {"id": "site-id"}
|
||||
|
||||
drives_resp = Mock()
|
||||
drives_resp.status_code = 200
|
||||
drives_resp.json.return_value = {
|
||||
"value": [
|
||||
{"id": "drive-1", "name": "Shared Documents"},
|
||||
]
|
||||
}
|
||||
|
||||
mock_get.side_effect = [site_resp, drives_resp]
|
||||
|
||||
site_id, drive_id = resolve_sharepoint_drive(
|
||||
"access-token", "https://tenant.sharepoint.com/sites/mysite", "shared documents"
|
||||
)
|
||||
|
||||
assert drive_id == "drive-1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateSharepointUploadSession:
|
||||
"""Tests for create_sharepoint_upload_session function."""
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.post")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_successful_session_creation(self, mock_settings, mock_post):
|
||||
"""Test successful upload session creation."""
|
||||
from app.tasks.upload_to_sharepoint import create_sharepoint_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_sharepoint_upload_session("test.pdf", "Uploads", "drive-id", "site-id", "access-token")
|
||||
|
||||
assert url == "https://upload.url/session123"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.post")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_session_without_folder(self, mock_settings, mock_post):
|
||||
"""Test upload session creation without folder path."""
|
||||
from app.tasks.upload_to_sharepoint import create_sharepoint_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_sharepoint_upload_session("test.pdf", None, "drive-id", "site-id", "access-token")
|
||||
|
||||
assert url == "https://upload.url/session456"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.post")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_session_creation_failure(self, mock_settings, mock_post):
|
||||
"""Test error handling when session creation fails."""
|
||||
from app.tasks.upload_to_sharepoint import create_sharepoint_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(RuntimeError, match="Failed to create SharePoint upload session"):
|
||||
create_sharepoint_upload_session("test.pdf", "Uploads", "drive-id", "site-id", "access-token")
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.post")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_url_encoding_special_characters(self, mock_settings, mock_post):
|
||||
"""Test that special characters in folder path are URL-encoded."""
|
||||
from app.tasks.upload_to_sharepoint import create_sharepoint_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_sharepoint_upload_session("file with spaces.pdf", "My Documents/Uploads", "drive-id", "site-id", "token")
|
||||
|
||||
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 TestUploadLargeFileSharepoint:
|
||||
"""Tests for upload_large_file_sharepoint function."""
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.put")
|
||||
@patch("app.tasks.upload_to_sharepoint.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_sharepoint import upload_large_file_sharepoint
|
||||
|
||||
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_sharepoint(str(test_file), "https://upload.url/session")
|
||||
|
||||
assert result["id"] == "file123"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.time.sleep")
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.put")
|
||||
@patch("app.tasks.upload_to_sharepoint.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_sharepoint import upload_large_file_sharepoint
|
||||
|
||||
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_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_sharepoint(str(test_file), "https://upload.url/session")
|
||||
|
||||
assert result["id"] == "file123"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.time.sleep")
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.put")
|
||||
@patch("app.tasks.upload_to_sharepoint.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_sharepoint import upload_large_file_sharepoint
|
||||
|
||||
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_sharepoint(str(test_file), "https://upload.url/session")
|
||||
|
||||
assert result["id"] == "file123"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.time.sleep")
|
||||
@patch("app.tasks.upload_to_sharepoint.requests.put")
|
||||
@patch("app.tasks.upload_to_sharepoint.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_sharepoint import upload_large_file_sharepoint
|
||||
|
||||
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(RuntimeError, match="Failed to upload chunk"):
|
||||
upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadToSharepoint:
|
||||
"""Tests for upload_to_sharepoint Celery task."""
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
|
||||
def test_file_not_found(self, mock_log):
|
||||
"""Test that missing file raises FileNotFoundError."""
|
||||
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
upload_to_sharepoint.__wrapped__("/nonexistent/file.pdf", file_id=1)
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_missing_client_id(self, mock_settings, mock_log, tmp_path):
|
||||
"""Test error when SharePoint client ID is not configured."""
|
||||
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
|
||||
|
||||
mock_settings.sharepoint_client_id = ""
|
||||
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
with pytest.raises(ValueError, match="client ID is not configured"):
|
||||
upload_to_sharepoint.__wrapped__(str(test_file), file_id=1)
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_missing_site_url(self, mock_settings, mock_log, tmp_path):
|
||||
"""Test error when SharePoint site URL is not configured."""
|
||||
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_site_url = ""
|
||||
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
with pytest.raises(ValueError, match="site URL is not configured"):
|
||||
upload_to_sharepoint.__wrapped__(str(test_file), file_id=1)
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.upload_large_file_sharepoint")
|
||||
@patch("app.tasks.upload_to_sharepoint.create_sharepoint_upload_session")
|
||||
@patch("app.tasks.upload_to_sharepoint.resolve_sharepoint_drive")
|
||||
@patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
|
||||
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_successful_upload(
|
||||
self, mock_settings, mock_log, mock_token, mock_resolve, mock_session, mock_upload, tmp_path
|
||||
):
|
||||
"""Test successful SharePoint upload."""
|
||||
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_client_secret = "secret"
|
||||
mock_settings.sharepoint_refresh_token = "token"
|
||||
mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
|
||||
mock_settings.sharepoint_document_library = "Documents"
|
||||
mock_settings.sharepoint_folder_path = "Uploads"
|
||||
mock_settings.sharepoint_tenant_id = "common"
|
||||
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
mock_token.return_value = "access-token"
|
||||
mock_resolve.return_value = ("site-id", "drive-id")
|
||||
mock_session.return_value = "https://upload.url/session"
|
||||
mock_upload.return_value = {"webUrl": "https://tenant.sharepoint.com/sites/mysite/test.pdf"}
|
||||
|
||||
result = upload_to_sharepoint.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
assert "Uploads" in result["sharepoint_path"]
|
||||
assert result["web_url"] == "https://tenant.sharepoint.com/sites/mysite/test.pdf"
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
|
||||
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
|
||||
@patch("app.tasks.upload_to_sharepoint.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_sharepoint import upload_to_sharepoint
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_client_secret = "secret"
|
||||
mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
|
||||
mock_settings.sharepoint_folder_path = "Uploads"
|
||||
mock_settings.sharepoint_document_library = "Documents"
|
||||
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
mock_token.side_effect = ValueError("Token error")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Failed to upload"):
|
||||
upload_to_sharepoint.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
|
||||
|
||||
@patch("app.tasks.upload_to_sharepoint.upload_large_file_sharepoint")
|
||||
@patch("app.tasks.upload_to_sharepoint.create_sharepoint_upload_session")
|
||||
@patch("app.tasks.upload_to_sharepoint.resolve_sharepoint_drive")
|
||||
@patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
|
||||
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
|
||||
@patch("app.tasks.upload_to_sharepoint.settings")
|
||||
def test_folder_override(
|
||||
self, mock_settings, mock_log, mock_token, mock_resolve, mock_session, mock_upload, tmp_path
|
||||
):
|
||||
"""Test that folder_override is used instead of settings."""
|
||||
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
|
||||
|
||||
mock_settings.sharepoint_client_id = "client-id"
|
||||
mock_settings.sharepoint_client_secret = "secret"
|
||||
mock_settings.sharepoint_refresh_token = "token"
|
||||
mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
|
||||
mock_settings.sharepoint_document_library = "Documents"
|
||||
mock_settings.sharepoint_folder_path = "DefaultFolder"
|
||||
mock_settings.sharepoint_tenant_id = "common"
|
||||
|
||||
test_file = tmp_path / "test.pdf"
|
||||
test_file.write_bytes(b"test content")
|
||||
|
||||
mock_token.return_value = "access-token"
|
||||
mock_resolve.return_value = ("site-id", "drive-id")
|
||||
mock_session.return_value = "https://upload.url/session"
|
||||
mock_upload.return_value = {"webUrl": "https://example.com/test.pdf"}
|
||||
|
||||
result = upload_to_sharepoint.apply(
|
||||
args=[str(test_file)], kwargs={"file_id": 1, "folder_override": "CustomFolder"}
|
||||
).get()
|
||||
|
||||
# Verify the session was created with the override folder
|
||||
mock_session.assert_called_once_with("test.pdf", "CustomFolder", "drive-id", "site-id", "access-token")
|
||||
assert result["status"] == "Completed"
|
||||
Reference in New Issue
Block a user