fix: merge main, resolve conflicts, address review feedback

- Resolve merge conflicts in app/api/onedrive.py and tests/test_api_google_drive_final.py
- Fix legacy Dict[str, str] type hints in update_env_file functions to use dict[str, str]
- Add admin-only access (_require_admin dependency) to save-settings endpoints
  in google_drive.py, onedrive.py, and dropbox.py
- Fix in_memory_only response field to reflect actual env_write_success status
- Update tests to override _require_admin dependency for save-settings endpoint tests
This commit is contained in:
copilot-swe-agent[bot]
2026-03-23 16:29:46 +00:00
232 changed files with 114245 additions and 911 deletions
+140
View File
@@ -269,6 +269,53 @@ class TestSavedSearchesCRUD:
response2 = client.post("/api/saved-searches", json=payload)
assert response2.status_code == 409
def test_create_saved_search_db_error(self, client: TestClient, monkeypatch):
"""POST /api/saved-searches returns 500 on DB exception."""
# Mock db.add or db.commit to raise an exception
# We can monkeypatch the route's dependency or the models
# It's easier to mock the SavedSearch model's __init__ or db's add
# Since we use db: DbSession, it's an instance of sqlalchemy.orm.Session
from sqlalchemy.orm import Session
original_commit = Session.commit
def mock_commit(*args, **kwargs):
raise Exception("Simulated DB error")
monkeypatch.setattr(Session, "commit", mock_commit)
payload = {
"name": "DB Error Search",
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 500
assert "Failed to save search" in response.json()["detail"]
def test_create_saved_search_limit_reached(self, client: TestClient, monkeypatch):
"""POST /api/saved-searches returns 409 if max limit is reached."""
monkeypatch.setattr("app.api.saved_searches.MAX_SAVED_SEARCHES_PER_USER", 1)
# Create first one
payload1 = {"name": "Search 1", "filters": {"status": "completed"}}
response1 = client.post("/api/saved-searches", json=payload1)
assert response1.status_code == 201
# Creating second one should fail due to limit
payload2 = {"name": "Search 2", "filters": {"status": "pending"}}
response2 = client.post("/api/saved-searches", json=payload2)
assert response2.status_code == 409
assert "Maximum of 1 saved searches reached" in response2.json()["detail"]
def test_create_saved_search_invalid_name_type(self, client: TestClient):
"""POST /api/saved-searches with non-string name returns 422."""
payload = {
"name": 12345,
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_update_saved_search(self, client: TestClient):
"""PUT /api/saved-searches/{id} updates the saved search."""
# Create
@@ -296,6 +343,83 @@ class TestSavedSearchesCRUD:
)
assert response.status_code == 404
def test_update_saved_search_duplicate_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with duplicate name returns 409."""
# Create first search
client.post(
"/api/saved-searches",
json={"name": "First Search", "filters": {"status": "pending"}},
)
# Create second search
create_resp2 = client.post(
"/api/saved-searches",
json={"name": "Second Search", "filters": {"status": "completed"}},
)
search_id2 = create_resp2.json()["id"]
# Try to rename second search to "First Search"
update_resp = client.put(
f"/api/saved-searches/{search_id2}",
json={"name": "First Search", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 409
def test_update_saved_search_name_too_long(self, client: TestClient):
"""PUT /api/saved-searches/{id} with name > 100 chars returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "x" * 101, "filters": {"status": "completed"}},
)
assert update_resp.status_code == 422
def test_update_saved_search_empty_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with empty name returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 422
def test_update_saved_search_empty_filters(self, client: TestClient):
"""PUT /api/saved-searches/{id} with empty filters returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Valid Name", "filters": {}},
)
assert update_resp.status_code == 422
def test_update_saved_search_invalid_filters(self, client: TestClient):
"""PUT /api/saved-searches/{id} with only invalid filters returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Valid Name", "filters": {"invalid_key": "value"}},
)
assert update_resp.status_code == 422
def test_delete_saved_search(self, client: TestClient):
"""DELETE /api/saved-searches/{id} removes the saved search."""
# Create
@@ -318,6 +442,22 @@ class TestSavedSearchesCRUD:
response = client.delete("/api/saved-searches/999")
assert response.status_code == 404
def test_delete_saved_search_db_error(self, client: TestClient):
"""DELETE /api/saved-searches/{id} handles database errors (500)."""
from unittest.mock import patch
# Create
create_resp = client.post(
"/api/saved-searches",
json={"name": "To Delete DB Error", "filters": {"status": "failed"}},
)
search_id = create_resp.json()["id"]
with patch("sqlalchemy.orm.Session.delete", side_effect=Exception("DB Delete Error")):
response = client.delete(f"/api/saved-searches/{search_id}")
assert response.status_code == 500
assert response.json()["detail"] == "Failed to delete saved search"
def test_create_name_too_long(self, client: TestClient):
"""POST /api/saved-searches with name > 100 chars returns 422."""
payload = {
+11 -7
View File
@@ -7,7 +7,6 @@ Covers Dropbox OAuth endpoints, settings management, and token testing.
from unittest.mock import Mock, patch
import pytest
import requests
@pytest.mark.unit
@@ -137,7 +136,7 @@ class TestTestDropboxToken:
assert data["status"] == "error"
assert "not fully configured" in data["message"]
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings")
def test_valid_token(self, mock_settings, mock_post, client):
"""Test successful token validation."""
@@ -162,7 +161,7 @@ class TestTestDropboxToken:
assert data["account"] == "user@example.com"
assert data["account_name"] == "Test User"
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.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."""
@@ -194,7 +193,7 @@ class TestTestDropboxToken:
data = response.json()
assert data["status"] == "success"
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.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."""
@@ -220,7 +219,7 @@ class TestTestDropboxToken:
assert data["status"] == "error"
assert data["needs_reauth"] is True
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings")
def test_token_validation_failure(self, mock_settings, mock_post, client):
"""Test handling non-401, non-200 response."""
@@ -240,16 +239,21 @@ class TestTestDropboxToken:
data = response.json()
assert data["status"] == "error"
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings")
def test_connection_error(self, mock_settings, mock_post, client):
"""Test handling of connection exceptions."""
import httpx
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")
mock_post.side_effect = httpx.RequestError(
"Connection refused",
request=httpx.Request("POST", "https://api.dropboxapi.com/2/users/get_current_account"),
)
response = client.get("/api/dropbox/test-token")
+23 -19
View File
@@ -84,8 +84,8 @@ class TestUpdateDropboxSettings:
class TestTestDropboxToken:
"""Tests for GET /dropbox/test-token endpoint."""
@patch("app.api.dropbox.requests.post")
def test_test_token_success(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_success(self, mock_client_cls):
"""Test successful token validation."""
from app.config import settings
@@ -95,7 +95,7 @@ class TestTestDropboxToken:
"email": "test@example.com",
"name": {"display_name": "Test User"},
}
mock_post.return_value = mock_response
mock_client_cls.return_value.__aenter__.return_value.post.return_value = mock_response
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
@@ -104,8 +104,8 @@ class TestTestDropboxToken:
# Should include account email and name
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_not_configured(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_not_configured(self, mock_client_cls):
"""Test when credentials are not configured."""
from app.config import settings
@@ -113,8 +113,8 @@ class TestTestDropboxToken:
# Should return error indicating not configured
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_partial_config(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_partial_config(self, mock_client_cls):
"""Test with partial configuration (missing some credentials)."""
from app.config import settings
@@ -123,8 +123,8 @@ class TestTestDropboxToken:
# Should return error
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_expired_requires_refresh(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_expired_requires_refresh(self, mock_client_cls):
"""Test when access token is expired and needs refresh."""
from app.config import settings
@@ -145,7 +145,9 @@ class TestTestDropboxToken:
"name": {"display_name": "Test User"},
}
mock_post.side_effect = [mock_response_401, mock_refresh_response, mock_success_response]
mock_client = MagicMock()
mock_client.post.side_effect = [mock_response_401, mock_refresh_response, mock_success_response]
mock_client_cls.return_value.__aenter__.return_value = mock_client
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
@@ -153,8 +155,8 @@ class TestTestDropboxToken:
# Should refresh and succeed
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_refresh_failed(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_refresh_failed(self, mock_client_cls):
"""Test when refresh token is invalid."""
from app.config import settings
@@ -167,7 +169,9 @@ class TestTestDropboxToken:
mock_refresh_response.status_code = 400
mock_refresh_response.text = "Invalid refresh token"
mock_post.side_effect = [mock_response_401, mock_refresh_response]
mock_client = MagicMock()
mock_client.post.side_effect = [mock_response_401, mock_refresh_response]
mock_client_cls.return_value.__aenter__.return_value = mock_client
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
@@ -175,8 +179,8 @@ class TestTestDropboxToken:
# Should return error with needs_reauth: True
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_perpetual_token_info(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_perpetual_token_info(self, mock_client_cls):
"""Test that perpetual token info is returned."""
from app.config import settings
@@ -186,7 +190,7 @@ class TestTestDropboxToken:
"email": "test@example.com",
"name": {"display_name": "Test User"},
}
mock_post.return_value = mock_response
mock_client_cls.return_value.__aenter__.return_value.post.return_value = mock_response
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
@@ -194,12 +198,12 @@ class TestTestDropboxToken:
# token_info should indicate never expires
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_exception_handling(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_exception_handling(self, mock_client_cls):
"""Test handling of exceptions."""
from app.config import settings
mock_post.side_effect = Exception("Network error")
mock_client_cls.return_value.__aenter__.return_value.post.side_effect = Exception("Network error")
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
@@ -360,6 +360,15 @@ class TestFormatTimeRemaining:
class TestSaveGoogleDriveSettings:
"""Tests for POST /google-drive/save-settings endpoint."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.google_drive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("builtins.open", new_callable=mock_open, read_data="# Existing config\n")
@patch("os.path.exists")
@patch("os.path.dirname")
+9
View File
@@ -195,6 +195,15 @@ class TestGetGoogleDriveTokenInfo:
class TestSaveGoogleDriveSettings:
"""Test save_google_drive_settings endpoint edge cases."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.google_drive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("app.api.google_drive.settings")
@patch("os.path.exists")
def test_save_settings_env_file_not_exists(self, mock_exists, mock_settings, client: TestClient):
+10
View File
@@ -154,6 +154,15 @@ class TestGetTokenInfoCredentialsBranches:
class TestSaveGoogleDriveSettingsFalsyFields:
"""Cover branches 395->397, 449->451, 468->470 in save_google_drive_settings."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.google_drive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("app.api.google_drive.settings")
@patch("os.path.exists", return_value=False)
@pytest.mark.asyncio
@@ -173,6 +182,7 @@ class TestSaveGoogleDriveSettingsFalsyFields:
with patch("app.api.google_drive.notify_settings_updated"):
result = await save_google_drive_settings(
request=mock_request,
_admin={"is_admin": True},
refresh_token="", # falsy → branches 395->397 and 449->451
client_id="cid",
client_secret=None,
+21 -21
View File
@@ -5,7 +5,7 @@ Focuses on uncovered lines: 98-99, 121-143, 160-161, 170-171,
324-326, 400-402, 436-438.
"""
from unittest.mock import MagicMock, PropertyMock, patch
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
import pytest
from fastapi.testclient import TestClient
@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
class TestTestTokenRefreshFailed:
"""Cover lines 98-99: token refresh returns non-200."""
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_test_token_refresh_returns_non_200(self, mock_post, client: TestClient):
"""Test token refresh returning a failure status hits the error branch."""
from app.config import settings
@@ -42,8 +42,8 @@ class TestTestTokenRefreshFailed:
class TestTestTokenRotation:
"""Cover lines 121-143, 160-161: token rotation with .env and DB persist."""
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_env_file_exists(self, mock_post, mock_get, client: TestClient, tmp_path):
"""When a new refresh token is received and .env file exists, it should be updated."""
from app.config import settings
@@ -75,8 +75,8 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.join", return_value=str(env_file)),
patch("app.api.onedrive.os.path.exists", return_value=True),
patch("app.utils.env_utils.os.path.join", return_value=str(env_file)),
patch("app.utils.env_utils.os.path.exists", return_value=True),
patch("app.database.SessionLocal") as mock_session_local,
patch("app.api.onedrive.save_setting_to_db"),
patch("app.api.onedrive.notify_settings_updated"),
@@ -90,8 +90,8 @@ class TestTestTokenRotation:
data = response.json()
assert data["status"] == "success"
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_env_not_existing(self, mock_post, mock_get, client: TestClient):
"""Token rotation when .env doesn't exist still succeeds."""
from app.config import settings
@@ -117,7 +117,7 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.exists", return_value=False),
patch("app.utils.env_utils.os.path.exists", return_value=False),
patch("app.database.SessionLocal") as mock_session_local,
patch("app.api.onedrive.save_setting_to_db"),
patch("app.api.onedrive.notify_settings_updated"),
@@ -130,8 +130,8 @@ class TestTestTokenRotation:
assert response.status_code == 200
assert response.json()["status"] == "success"
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_env_write_failure(self, mock_post, mock_get, client: TestClient):
"""Token rotation when .env write fails (lines 142-143) still continues."""
from app.config import settings
@@ -157,7 +157,7 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.exists", return_value=True),
patch("app.utils.env_utils.os.path.exists", return_value=True),
patch("builtins.open", side_effect=PermissionError("Permission denied")),
patch("app.database.SessionLocal") as mock_session_local,
patch("app.api.onedrive.save_setting_to_db"),
@@ -171,8 +171,8 @@ class TestTestTokenRotation:
assert response.status_code == 200
assert response.json()["status"] == "success"
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_db_persist_failure(self, mock_post, mock_get, client: TestClient):
"""Token rotation when DB persist fails (lines 160-161) still continues."""
from app.config import settings
@@ -198,7 +198,7 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.exists", return_value=False),
patch("app.utils.env_utils.os.path.exists", return_value=False),
patch("app.database.SessionLocal", side_effect=Exception("DB error")),
):
response = client.get("/api/onedrive/test-token")
@@ -211,8 +211,8 @@ class TestTestTokenRotation:
class TestTestTokenUserInfoFailed:
"""Cover lines 170-171: user info request fails."""
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_user_info_returns_non_200(self, mock_post, mock_get, client: TestClient):
"""Test when user info request fails after successful token refresh."""
from app.config import settings
@@ -247,8 +247,8 @@ class TestTestTokenUserInfoFailed:
class TestTokenRotationEnvAppendLine:
"""Cover the branch at line 134 where token line is not found in .env and must be appended."""
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_appends_to_env(self, mock_post, mock_get, client: TestClient, tmp_path):
"""When .env exists but doesn't have ONEDRIVE_REFRESH_TOKEN, it should append."""
from app.config import settings
@@ -277,8 +277,8 @@ class TestTokenRotationEnvAppendLine:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.join", return_value=str(env_file)),
patch("app.api.onedrive.os.path.exists", return_value=True),
patch("app.utils.env_utils.os.path.join", return_value=str(env_file)),
patch("app.utils.env_utils.os.path.exists", return_value=True),
patch("app.database.SessionLocal") as mock_sl,
patch("app.api.onedrive.save_setting_to_db"),
patch("app.api.onedrive.notify_settings_updated"),
+12 -12
View File
@@ -1,7 +1,7 @@
"""Comprehensive unit tests for app/api/onedrive.py module."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -48,8 +48,8 @@ class TestExchangeOneDriveToken:
class TestTestOneDriveToken:
"""Tests for GET /onedrive/test-token endpoint."""
@patch("app.api.onedrive.requests.post")
@patch("app.api.onedrive.requests.get")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
def test_test_token_success(self, mock_get, mock_post):
"""Test successful token validation."""
from app.config import settings
@@ -79,7 +79,7 @@ class TestTestOneDriveToken:
# Should return success
pass
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_test_token_not_configured(self, mock_post):
"""Test when credentials are not configured."""
from app.config import settings
@@ -88,7 +88,7 @@ class TestTestOneDriveToken:
# Should return error
pass
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_test_token_refresh_failed(self, mock_post):
"""Test when token refresh fails."""
from app.config import settings
@@ -104,8 +104,8 @@ class TestTestOneDriveToken:
# Should return error with needs_reauth
pass
@patch("app.api.onedrive.requests.post")
@patch("app.api.onedrive.requests.get")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
def test_test_token_user_info_failed(self, mock_get, mock_post):
"""Test when user info request fails."""
from app.config import settings
@@ -128,8 +128,8 @@ class TestTestOneDriveToken:
# Should return error
pass
@patch("app.api.onedrive.requests.post")
@patch("app.api.onedrive.requests.get")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("builtins.open", create=True)
@patch("os.path.exists")
def test_test_token_updates_refresh_token(self, mock_exists, mock_open, mock_get, mock_post):
@@ -167,8 +167,8 @@ class TestTestOneDriveToken:
# Should update refresh token in memory and file
pass
@patch("app.api.onedrive.requests.post")
@patch("app.api.onedrive.requests.get")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
def test_test_token_expiration_info(self, mock_get, mock_post):
"""Test that expiration info is included."""
from app.config import settings
@@ -195,7 +195,7 @@ class TestTestOneDriveToken:
# token_info should include expiration details
pass
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_test_token_exception_handling(self, mock_post):
"""Test handling of exceptions."""
from app.config import settings
+191
View File
@@ -0,0 +1,191 @@
"""Tests for the saved searches API (app/api/saved_searches.py)."""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import SavedSearch
# ---------------------------------------------------------------------------
# Test data constants
# ---------------------------------------------------------------------------
_OWNER = "test_user@example.com"
_OTHER_OWNER = "other_user@example.com"
# ---------------------------------------------------------------------------
# Shared fixture helpers
# ---------------------------------------------------------------------------
@pytest.fixture()
def int_engine():
"""In-memory SQLite engine for integration tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def int_session(int_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=int_engine)
session = Session()
yield session
session.close()
def _make_client(int_engine, owner_id: str = _OWNER):
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from unittest.mock import patch
from app.main import app
def override_db():
Session = sessionmaker(bind=int_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with patch("app.api.saved_searches._get_user_id", return_value=owner_id):
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture()
def int_client(int_engine):
"""TestClient authenticated as _OWNER."""
yield from _make_client(int_engine, _OWNER)
# ---------------------------------------------------------------------------
# CRUD tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSavedSearchesAPI:
"""Tests for Saved Searches endpoints."""
def test_list_saved_searches_empty(self, int_client):
"""No saved searches returns empty list."""
resp = int_client.get("/api/saved-searches")
assert resp.status_code == 200
assert resp.json() == []
def test_create_saved_search(self, int_client):
"""Create a saved search and verify the response."""
payload = {"name": "My Invoices", "filters": {"tags": "invoice", "document_type": "Invoice"}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "My Invoices"
assert data["filters"] == {"tags": "invoice", "document_type": "Invoice"}
assert "id" in data
def test_create_saved_search_invalid_filters(self, int_client):
"""Creating with invalid filters returns 422."""
# Missing filters parameter (or empty after sanitization)
payload = {"name": "My Invoices", "filters": {}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 422
# Invalid filters format
payload2 = {"name": "My Invoices", "filters": "not_a_dict"}
resp2 = int_client.post("/api/saved-searches", json=payload2)
assert resp2.status_code == 422
def test_create_saved_search_duplicate(self, int_client):
"""Creating a duplicate named search returns 409."""
payload = {"name": "Duplicate", "filters": {"q": "test"}}
int_client.post("/api/saved-searches", json=payload)
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 409
def test_create_saved_search_limit(self, int_client, int_session):
"""Exceeding MAX_SAVED_SEARCHES_PER_USER returns 409."""
# Create 50 searches using the API to ensure they are visible
for i in range(50):
resp = int_client.post("/api/saved-searches", json={"name": f"Search LIMIT {i}", "filters": {"q": "test"}})
assert resp.status_code == 201
payload = {"name": "One too many", "filters": {"q": "test"}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 409
def test_update_saved_search(self, int_client):
"""Update an existing saved search."""
payload = {"name": "Original Name", "filters": {"q": "test"}}
created = int_client.post("/api/saved-searches", json=payload).json()
search_id = created["id"]
update_payload = {"name": "Updated Name", "filters": {"tags": "new"}}
resp = int_client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Updated Name"
assert data["filters"] == {"tags": "new"}
def test_update_saved_search_not_found(self, int_client):
"""Updating a non-existent search returns 404."""
update_payload = {"name": "Updated Name"}
resp = int_client.put("/api/saved-searches/999", json=update_payload)
assert resp.status_code == 404
def test_update_saved_search_duplicate_name(self, int_client):
"""Updating name to an existing search name returns 409."""
payload1 = {"name": "Search 1", "filters": {"q": "a"}}
payload2 = {"name": "Search 2", "filters": {"q": "b"}}
int_client.post("/api/saved-searches", json=payload1)
created2 = int_client.post("/api/saved-searches", json=payload2).json()
search2_id = created2["id"]
update_payload = {"name": "Search 1"}
resp = int_client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
assert resp.status_code == 409
def test_delete_saved_search(self, int_client, int_session):
"""Delete an existing search."""
payload = {"name": "To be deleted", "filters": {"q": "test"}}
created = int_client.post("/api/saved-searches", json=payload).json()
search_id = created["id"]
resp = int_client.delete(f"/api/saved-searches/{search_id}")
assert resp.status_code == 204
assert int_session.query(SavedSearch).filter(SavedSearch.id == search_id).first() is None
def test_delete_saved_search_not_found(self, int_client):
"""Deleting a non-existent search returns 404."""
resp = int_client.delete("/api/saved-searches/999")
assert resp.status_code == 404
def test_other_users_searches_isolated(self, int_engine, int_session):
"""Users only see and can only modify their own saved searches."""
int_session.add(SavedSearch(user_id=_OTHER_OWNER, name="Other Search", filters='{"q": "test"}'))
int_session.commit()
client = next(_make_client(int_engine, _OWNER))
resp = client.get("/api/saved-searches")
assert resp.status_code == 200
assert len(resp.json()) == 0
other_search = int_session.query(SavedSearch).first()
resp = client.put(f"/api/saved-searches/{other_search.id}", json={"name": "Hacked"})
assert resp.status_code == 404
resp = client.delete(f"/api/saved-searches/{other_search.id}")
assert resp.status_code == 404
+129 -6
View File
@@ -101,6 +101,43 @@ def _cleanup(app):
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests Auth Helper
# ---------------------------------------------------------------------------
class TestGetOwnerId:
"""Tests for the _get_owner_id dependency helper."""
@pytest.mark.unit
def test_get_owner_id_unauthenticated(self):
"""_get_owner_id should raise a 401 if the user is not authenticated."""
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from app.api.api_tokens import _get_owner_id
mock_request = MagicMock()
with patch("app.api.api_tokens.get_current_owner_id", return_value=None):
with pytest.raises(HTTPException) as exc_info:
_get_owner_id(mock_request)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Not authenticated"
@pytest.mark.unit
def test_get_owner_id_authenticated(self):
"""_get_owner_id should return owner_id if user is authenticated."""
from unittest.mock import MagicMock, patch
from app.api.api_tokens import _get_owner_id
mock_request = MagicMock()
with patch("app.api.api_tokens.get_current_owner_id", return_value="owner123"):
owner_id = _get_owner_id(mock_request)
assert owner_id == "owner123"
# ---------------------------------------------------------------------------
# Tests Token CRUD
# ---------------------------------------------------------------------------
@@ -157,6 +194,46 @@ class TestTokenCreate:
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_token_database_error(self, tok_engine, tok_session):
"""Creating a token should rollback and raise 500 if database commit fails."""
from unittest.mock import patch
from sqlalchemy.orm import Session as SASession
from app.main import app
client = _make_client(tok_engine)
try:
# Wrap commit: flush first so changes are staged in the transaction,
# then raise to simulate a commit failure after data has been written.
def _fail_after_flush(self):
self.flush() # stage changes inside the open transaction
raise Exception("DB Failure")
# Spy on rollback so we can assert it is called.
rollback_called = False
real_rollback = SASession.rollback
def _spy_rollback(self):
nonlocal rollback_called
rollback_called = True
real_rollback(self)
with patch.object(SASession, "commit", _fail_after_flush):
with patch.object(SASession, "rollback", _spy_rollback):
resp = client.post("/api/api-tokens/", json={"name": "DB Error Create Test"})
assert resp.status_code == 500
# rollback() must have been called to undo the flushed changes.
assert rollback_called, "db.rollback() was not called after commit failure in create_token"
# After rollback the token must not exist in the database.
db_token = tok_session.query(ApiToken).filter(ApiToken.name == "DB Error Create Test").first()
assert db_token is None
finally:
_cleanup(app)
class TestTokenList:
"""Tests for GET /api/api-tokens/."""
@@ -326,12 +403,10 @@ class TestTokenRevoke:
rollback_called = True
real_rollback(self)
with (
patch.object(SASession, "commit", _fail_after_flush),
patch.object(SASession, "rollback", _spy_rollback),
):
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 500
with patch.object(SASession, "commit", _fail_after_flush):
with patch.object(SASession, "rollback", _spy_rollback):
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 500
# rollback() must have been called to undo the flushed changes.
assert rollback_called, "db.rollback() was not called after commit failure"
@@ -534,6 +609,44 @@ class TestTokenUtils:
tokens = {generate_api_token() for _ in range(100)}
assert len(tokens) == 100
@pytest.mark.unit
def test_generate_api_token_length(self):
"""Generated tokens should have the exact expected length based on TOKEN_BYTES."""
import math
from app.api.api_tokens import TOKEN_BYTES, TOKEN_PREFIX, generate_api_token
# base64url encoding of N bytes without padding: ceil(N * 4 / 3) characters
expected_b64_len = math.ceil(TOKEN_BYTES * 4 / 3)
expected_total_len = len(TOKEN_PREFIX) + expected_b64_len
token = generate_api_token()
assert len(token) == expected_total_len
@pytest.mark.unit
def test_generate_api_token_charset(self):
"""Generated tokens should only contain URL-safe base64 characters and the prefix."""
import re
from app.api.api_tokens import TOKEN_PREFIX, generate_api_token
token = generate_api_token()
# Check it starts with prefix and the rest is base64url chars ([A-Za-z0-9_-])
pattern = f"^{re.escape(TOKEN_PREFIX)}[A-Za-z0-9_\\-]+$"
assert re.match(pattern, token) is not None
@pytest.mark.unit
def test_generate_api_token_uses_secrets(self):
"""Generated tokens should use secrets.token_urlsafe with the correct number of bytes."""
from unittest.mock import patch
from app.api.api_tokens import TOKEN_BYTES, TOKEN_PREFIX, generate_api_token
with patch("app.api.api_tokens.secrets.token_urlsafe", return_value="mocked_token") as mock_secrets:
token = generate_api_token()
mock_secrets.assert_called_once_with(TOKEN_BYTES)
assert token == f"{TOKEN_PREFIX}mocked_token"
@pytest.mark.unit
def test_hash_token_deterministic(self):
"""Hashing the same token should always produce the same result."""
@@ -554,3 +667,13 @@ class TestTokenUtils:
# All characters should be valid lowercase hex digits.
int(h, 16)
assert h == h.lower()
@pytest.mark.unit
def test_hash_token_known_value(self):
"""hash_token should return the exact expected PBKDF2 digest for a known input."""
from app.api.api_tokens import hash_token
# PBKDF2-HMAC-SHA256 with 100,000 iterations and salt b"api-token-v1"
token = "de_test_token_value"
expected_hash = "9b89d9adf2f390c75bf2fd0ff2bb5622ef5a9dce438354cce6e39f2f5401129e"
assert hash_token(token) == expected_hash
+35
View File
@@ -2,6 +2,7 @@
import asyncio
import hashlib
import logging
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -38,6 +39,40 @@ class TestGetCurrentUser:
result = get_current_user(mock_request)
assert result is None
def test_logs_debug_when_session_user_found(self, caplog):
"""Test that get_current_user emits a DEBUG log when session user is found."""
mock_request = MagicMock(spec=Request)
mock_request.session = {"user": {"id": "u1", "preferred_username": "alice"}}
mock_request.state = MagicMock(spec=[]) # no api_token_user attribute
with caplog.at_level(logging.DEBUG, logger="app.auth"):
get_current_user(mock_request)
assert any("[AUTH] get_current_user: resolved from session" in m for m in caplog.messages)
def test_logs_debug_when_no_user(self, caplog):
"""Test that get_current_user emits a DEBUG log when no user is present."""
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_request.state = MagicMock(spec=[])
with caplog.at_level(logging.DEBUG, logger="app.auth"):
get_current_user(mock_request)
assert any("[AUTH] get_current_user: no user in session or API token" in m for m in caplog.messages)
def test_logs_debug_when_api_token_user(self, caplog):
"""Test that get_current_user emits a DEBUG log when resolved from API token."""
mock_request = MagicMock(spec=Request)
mock_request.state.api_token_user = {"id": "tok_user"}
mock_request.session = {}
with caplog.at_level(logging.DEBUG, logger="app.auth"):
result = get_current_user(mock_request)
assert result == {"id": "tok_user"}
assert any("[AUTH] get_current_user: resolved from API token" in m for m in caplog.messages)
@pytest.mark.unit
class TestGetGravatarUrl:
+139
View File
@@ -224,3 +224,142 @@ class TestTaskFailureHandler:
# Simply verify that importing the handler doesn't cause errors
# The actual signal connection is tested implicitly by the other tests
assert callable(task_failure_handler)
@pytest.mark.unit
class TestDispatchUserFailureNotification:
"""Tests for _dispatch_user_failure_notification helper."""
@patch("app.celery_app._dispatch_user_failure_notification")
@patch("app.celery_app.settings")
@patch("app.utils.notification.notify_celery_failure")
def test_task_failure_handler_calls_user_failure_dispatch(self, mock_notify_sys, mock_settings, mock_dispatch):
"""task_failure_handler also calls _dispatch_user_failure_notification."""
mock_settings.notify_on_task_failure = True
from app.celery_app import task_failure_handler
mock_sender = MagicMock()
mock_sender.name = "app.tasks.process_document.process_document"
exc = ValueError("OCR timeout")
task_failure_handler(
sender=mock_sender,
task_id="tid",
exception=exc,
args=["/tmp/f.pdf"],
kwargs={"file_id": 42},
)
mock_dispatch.assert_called_once_with(mock_sender, exc, ["/tmp/f.pdf"], {"file_id": 42})
def test_dispatch_ignores_non_document_tasks(self):
"""Non app.tasks.* tasks should be silently ignored."""
from app.celery_app import _dispatch_user_failure_notification
sender = MagicMock()
sender.name = "celery.backend_cleanup"
# Should complete without error or DB access
_dispatch_user_failure_notification(sender, ValueError("x"), [], {})
def test_dispatch_ignores_when_no_file_id(self):
"""If file_id is not in args or kwargs, nothing happens."""
from app.celery_app import _dispatch_user_failure_notification
sender = MagicMock()
sender.name = "app.tasks.process_document.process_document"
# No file_id anywhere
_dispatch_user_failure_notification(sender, ValueError("x"), ["/tmp/f.pdf"], {})
@patch("app.database.SessionLocal")
def test_dispatch_extracts_file_id_from_kwargs(self, mock_session):
"""file_id should be extracted from kwargs when present."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_record = MagicMock()
mock_record.owner_id = "alice@example.com"
mock_record.original_filename = "invoice.pdf"
mock_record.local_filename = "/tmp/invoice.pdf"
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.finalize_document_storage.finalize_document_storage"
exc = RuntimeError("Upload failed")
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
_dispatch_user_failure_notification(sender, exc, ["/tmp/f.pdf"], {"file_id": 10})
mock_notify.assert_called_once_with(
owner_id="alice@example.com",
filename="invoice.pdf",
error="RuntimeError: Upload failed",
file_id=10,
)
@patch("app.database.SessionLocal")
def test_dispatch_extracts_file_id_from_positional_args(self, mock_session):
"""file_id should be extracted from positional args for known tasks."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_record = MagicMock()
mock_record.owner_id = "bob@test.com"
mock_record.original_filename = "scan.pdf"
mock_record.local_filename = "/tmp/scan.pdf"
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.process_with_ocr.process_with_ocr"
exc = ValueError("OCR error")
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
# process_with_ocr: file_id is args[1]
_dispatch_user_failure_notification(sender, exc, ["filename.pdf", 77], {})
mock_notify.assert_called_once_with(
owner_id="bob@test.com",
filename="scan.pdf",
error="ValueError: OCR error",
file_id=77,
)
@patch("app.database.SessionLocal")
def test_dispatch_skips_when_no_owner(self, mock_session):
"""When file record has no owner_id, no notification is sent."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_record = MagicMock()
mock_record.owner_id = None
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.process_document.process_document"
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
_dispatch_user_failure_notification(sender, ValueError("x"), [], {"file_id": 5})
mock_notify.assert_not_called()
@patch("app.database.SessionLocal")
def test_dispatch_skips_when_record_not_found(self, mock_session):
"""When file record doesn't exist, no notification is sent."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.process_document.process_document"
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
_dispatch_user_failure_notification(sender, ValueError("x"), [], {"file_id": 999})
mock_notify.assert_not_called()
+199
View File
@@ -0,0 +1,199 @@
"""Tests for scripts/check_alembic_migrations.py."""
# The script lives outside of the ``app`` package, so we import it by path.
import importlib.util
import textwrap
from pathlib import Path
import pytest
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_alembic_migrations.py"
_spec = importlib.util.spec_from_file_location("check_alembic_migrations", _SCRIPT)
assert _spec and _spec.loader
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
check_migrations = _mod.check_migrations
main = _mod.main
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _write_migration(
directory: Path, filename: str, revision: str, down_revision: str | tuple[str, ...] | None
) -> Path:
"""Helper to create a minimal migration file."""
if down_revision is None:
down_rev_str = "None"
elif isinstance(down_revision, tuple):
down_rev_str = repr(down_revision)
else:
down_rev_str = f'"{down_revision}"'
content = textwrap.dedent(f'''\
"""Test migration."""
from typing import Union
revision: str = "{revision}"
down_revision: Union[str, None] = {down_rev_str}
depends_on: Union[str, None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
''')
path = directory / filename
path.write_text(content)
return path
@pytest.fixture
def versions_dir(tmp_path: Path) -> Path:
"""Return a temporary versions directory."""
d = tmp_path / "versions"
d.mkdir()
return d
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCheckMigrations:
"""Tests for the check_migrations function."""
def test_valid_linear_chain(self, versions_dir: Path) -> None:
"""A simple linear chain should pass with no errors."""
_write_migration(versions_dir, "001_initial.py", "001_initial", None)
_write_migration(versions_dir, "002_add_col.py", "002_add_col", "001_initial")
_write_migration(versions_dir, "003_add_table.py", "003_add_table", "002_add_col")
errors = check_migrations(versions_dir)
assert errors == []
def test_valid_merge_migration(self, versions_dir: Path) -> None:
"""A chain with a merge point should pass."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
# Merge file with tuple down_revision
content = textwrap.dedent('''\
"""Merge."""
from typing import Union
revision: str = "003_merge"
down_revision: Union[str, tuple] = ("002_a", "002_b")
depends_on: Union[str, None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
''')
(versions_dir / "003_merge.py").write_text(content)
errors = check_migrations(versions_dir)
assert errors == []
def test_multiple_heads_detected(self, versions_dir: Path) -> None:
"""Two unmerged branches should report multiple heads."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
errors = check_migrations(versions_dir)
assert len(errors) == 1
assert "Multiple migration heads" in errors[0]
assert "002_a" in errors[0]
assert "002_b" in errors[0]
def test_broken_down_revision(self, versions_dir: Path) -> None:
"""A migration pointing to a non-existent parent should be flagged."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_orphan.py", "002_orphan", "NONEXISTENT")
errors = check_migrations(versions_dir)
assert any("Broken chain" in e for e in errors)
assert any("NONEXISTENT" in e for e in errors)
def test_duplicate_revision(self, versions_dir: Path) -> None:
"""Two files declaring the same revision should be flagged."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_first.py", "002_dup", "001_base")
_write_migration(versions_dir, "002_second.py", "002_dup", "001_base")
errors = check_migrations(versions_dir)
assert any("Duplicate revision" in e for e in errors)
def test_filename_mismatch(self, versions_dir: Path) -> None:
"""A file whose revision doesn't match its filename should be flagged."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
# filename stem is "002_wrong_name" but revision says "002_correct_name"
_write_migration(versions_dir, "002_wrong_name.py", "002_correct_name", "001_base")
errors = check_migrations(versions_dir)
assert any("Filename mismatch" in e for e in errors)
def test_empty_directory(self, versions_dir: Path) -> None:
"""An empty versions directory should report an error."""
errors = check_migrations(versions_dir)
assert len(errors) == 1
assert "No migration files found" in errors[0]
def test_init_py_is_skipped(self, versions_dir: Path) -> None:
"""__init__.py files should be ignored."""
(versions_dir / "__init__.py").write_text("")
_write_migration(versions_dir, "001_base.py", "001_base", None)
errors = check_migrations(versions_dir)
assert errors == []
def test_non_migration_file_skipped(self, versions_dir: Path) -> None:
"""A .py file without a revision variable should be silently skipped."""
(versions_dir / "helper.py").write_text("# just a helper\nx = 1\n")
_write_migration(versions_dir, "001_base.py", "001_base", None)
errors = check_migrations(versions_dir)
assert errors == []
@pytest.mark.unit
class TestMainCLI:
"""Tests for the CLI entry-point."""
def test_success_returns_zero(self, versions_dir: Path) -> None:
"""Valid chain should exit 0."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
rc = main(["--versions-dir", str(versions_dir)])
assert rc == 0
def test_failure_returns_one(self, versions_dir: Path) -> None:
"""Invalid chain should exit 1."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
rc = main(["--versions-dir", str(versions_dir)])
assert rc == 1
def test_missing_directory_returns_two(self, tmp_path: Path) -> None:
"""Non-existent versions directory should exit 2."""
rc = main(["--versions-dir", str(tmp_path / "does_not_exist")])
assert rc == 2
def test_verbose_flag(self, versions_dir: Path) -> None:
"""The --verbose flag should not crash."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
rc = main(["--versions-dir", str(versions_dir), "--verbose"])
assert rc == 0
def test_real_migrations(self) -> None:
"""Smoke test against the actual project migrations."""
real_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
if not real_dir.is_dir():
pytest.skip("migrations/versions directory not found in working tree")
rc = main(["--versions-dir", str(real_dir)])
assert rc == 0
+13 -6
View File
@@ -5,7 +5,7 @@ in files listed in the 90%+ coverage push issue.
Each test class maps to a single source module.
"""
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from dropbox.exceptions import ApiError
@@ -547,16 +547,23 @@ class TestURLUploadAdditionalCoverage:
assert validate_file_type("", "noextfile") is False
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_empty_url_path(self, mock_process, mock_get, client):
def test_process_url_empty_url_path(self, mock_process, mock_stream, client):
"""URL with empty path defaults to 'download' filename (line 197-202)."""
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "50"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
mock_task = Mock()
mock_task.id = "task-empty-path"
+149 -2
View File
@@ -704,6 +704,18 @@ def _set_minimal_provider_settings(mock_settings):
class TestSettingsSyncAdditional:
"""Additional tests for settings_sync covering reload failure branch."""
def test_notify_settings_updated_redis_failure_logs_warning(self):
"""Test that a Redis failure is logged, not raised (lines 58-59)."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.settings_sync.redis") as mock_redis_module:
mock_redis_module.from_url.side_effect = Exception("Redis connection failed")
with patch("app.utils.settings_sync.logger") as mock_logger:
notify_settings_updated()
mock_logger.warning.assert_any_call(
"Could not publish settings update to Redis: Redis connection failed"
)
def test_reload_failure_is_logged_not_raised(self):
"""Test that a reload failure is logged, not raised (lines 71-72)."""
from app.utils.settings_sync import notify_settings_updated
@@ -711,8 +723,66 @@ class TestSettingsSyncAdditional:
with patch("app.utils.settings_sync.redis") as mock_redis_module:
mock_redis_module.from_url.return_value = MagicMock() # Redis OK
with patch("app.utils.config_loader.reload_settings_from_db", side_effect=Exception("reload failed")):
# Should not raise despite reload failure
notify_settings_updated()
with patch("app.utils.settings_sync.logger") as mock_logger:
# Should not raise despite reload failure
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not reload in-process settings: reload failed")
def test_notify_settings_updated_ocr_failure_logs_warning(self):
"""Test that OCR check failure is logged, not raised (lines 82-83)."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.settings_sync.redis") as mock_redis_module:
mock_redis_module.from_url.return_value = MagicMock() # Redis OK
with patch("app.utils.config_loader.reload_settings_from_db"):
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async", side_effect=Exception("OCR failed")
):
with patch("app.utils.settings_sync.logger") as mock_logger:
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR failed")
def test_signal_handler_ocr_check_failure_logs_warning(self):
"""Test that signal handler logs warning if OCR language check fails on worker (lines 115-116)."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = b"1234567890.0"
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db"):
with patch("app.utils.settings_sync._last_seen_version", ""):
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async",
side_effect=Exception("Worker OCR fail"),
):
with patch("app.utils.settings_sync.logger") as mock_logger:
handler_fn(sender=None)
mock_logger.warning.assert_any_call(
"Could not schedule OCR language check on worker: Worker OCR fail"
)
def test_signal_handler_reloads_on_version_change(self):
"""Test the task_prerun signal handler reloads settings when version changes (lines 95-98)."""
@@ -820,6 +890,83 @@ class TestSettingsSyncAdditional:
# Should not raise
handler_fn(sender=None)
def test_signal_handler_no_version_returned(self):
"""Test that handler does nothing if Redis returns None for version."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = None # Return None for version
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
handler_fn(sender=None)
mock_reload.assert_not_called()
def test_signal_handler_ocr_language_manager_exception(self):
"""Test that OCR language check exception inside handler is caught and logged."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = b"9999999.0" # New version
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
with patch("app.utils.settings_sync._last_seen_version", "111.0"):
with patch("app.utils.settings_sync.logger") as mock_logger:
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async",
side_effect=Exception("OCR failed"),
):
handler_fn(sender=None)
mock_reload.assert_called_once()
mock_logger.warning.assert_called_with(
"Could not schedule OCR language check on worker: OCR failed"
)
# ===========================================================================
# app/api/logs.py additional branches
@@ -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"),
):
+22
View File
@@ -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)."""
+201
View File
@@ -0,0 +1,201 @@
"""Tests for the Devices page and mobile token filtering (app/api/api_tokens.py mobile endpoint).
These tests validate:
- ``GET /api/api-tokens/mobile`` returns only mobile tokens
- ``GET /api/api-tokens/`` excludes mobile tokens
- ``GET /devices`` renders the devices page
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import ApiToken
# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------
_OWNER = "devices_user@example.com"
_OTHER_OWNER = "other_devices@example.com"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def dev_engine():
"""In-memory SQLite engine."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def dev_session(dev_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=dev_engine)
session = Session()
yield session
session.close()
def _make_client(dev_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.api_tokens import _get_owner_id
from app.main import app
Session = sessionmaker(bind=dev_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _override_owner():
return owner_id
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _override_owner
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
return client
def _cleanup(app):
"""Remove dependency overrides after test."""
app.dependency_overrides.clear()
def _seed_tokens(session, owner_id: str = _OWNER):
"""Create a mix of regular and mobile tokens for testing."""
from app.api.api_tokens import generate_api_token, hash_token
tokens = []
# Regular API tokens
for name in ["CI Pipeline", "Webhook Upload"]:
pt = generate_api_token()
t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
session.add(t)
tokens.append(t)
# Mobile tokens (various naming patterns)
for name in [
"Mobile App iPhone 15 Pro",
"Mobile App (QR) Christian's iPad",
"Mobile App",
]:
pt = generate_api_token()
t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
session.add(t)
tokens.append(t)
session.commit()
return tokens
# ---------------------------------------------------------------------------
# Tests Mobile Token Filtering
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMobileTokenFiltering:
"""Tests for GET /api/api-tokens/mobile and filtering from GET /api/api-tokens/."""
def test_list_mobile_tokens_returns_only_mobile(self, dev_engine, dev_session):
"""GET /api/api-tokens/mobile should only return tokens starting with 'Mobile App'."""
_seed_tokens(dev_session)
client = _make_client(dev_engine)
try:
res = client.get("/api/api-tokens/mobile")
assert res.status_code == 200
data = res.json()
assert len(data) == 3
for t in data:
assert t["name"].startswith("Mobile App")
finally:
_cleanup(client.app)
def test_list_regular_tokens_excludes_mobile(self, dev_engine, dev_session):
"""GET /api/api-tokens/ should NOT return tokens starting with 'Mobile App'."""
_seed_tokens(dev_session)
client = _make_client(dev_engine)
try:
res = client.get("/api/api-tokens/")
assert res.status_code == 200
data = res.json()
assert len(data) == 2
for t in data:
assert not t["name"].startswith("Mobile App")
finally:
_cleanup(client.app)
def test_list_mobile_tokens_empty(self, dev_engine):
"""GET /api/api-tokens/mobile returns [] when no mobile tokens exist."""
client = _make_client(dev_engine)
try:
res = client.get("/api/api-tokens/mobile")
assert res.status_code == 200
assert res.json() == []
finally:
_cleanup(client.app)
def test_list_mobile_tokens_isolation(self, dev_engine, dev_session):
"""Mobile tokens for other users should not appear."""
_seed_tokens(dev_session, owner_id=_OTHER_OWNER)
client = _make_client(dev_engine, owner_id=_OWNER)
try:
res = client.get("/api/api-tokens/mobile")
assert res.status_code == 200
assert res.json() == []
finally:
_cleanup(client.app)
def test_mobile_token_revoke_via_api_tokens_endpoint(self, dev_engine, dev_session):
"""Mobile tokens can still be revoked via DELETE /api/api-tokens/{id}."""
tokens = _seed_tokens(dev_session)
mobile_token = next(t for t in tokens if t.name.startswith("Mobile App"))
client = _make_client(dev_engine)
try:
res = client.delete(f"/api/api-tokens/{mobile_token.id}")
assert res.status_code == 200
# Verify it's gone from mobile list
res2 = client.get("/api/api-tokens/mobile")
active_names = [t["name"] for t in res2.json() if t["is_active"]]
assert mobile_token.name not in active_names
finally:
_cleanup(client.app)
# ---------------------------------------------------------------------------
# Tests Devices Page View
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDevicesPageView:
"""Tests for GET /devices page rendering."""
def test_devices_page_renders(self, dev_engine):
"""GET /devices should return 200 with the devices template."""
from app.views.devices import router as _ # noqa: F401 ensures route is registered
client = _make_client(dev_engine)
try:
res = client.get("/devices")
assert res.status_code == 200
assert "devices.heading" in res.text or "Mobile Devices" in res.text
finally:
_cleanup(client.app)
+37 -16
View File
@@ -5,7 +5,7 @@ This test module serves as a regression prevention mechanism to ensure
that endpoints remain accessible after code refactoring or reorganization.
"""
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -17,17 +17,24 @@ TEST_URL = "https://example.com/test.pdf"
class TestEndpointRegistration:
"""Verify that critical API endpoints are registered in the FastAPI app"""
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_endpoint_exists(self, mock_process_document, mock_requests_get, client):
def test_process_url_endpoint_exists(self, mock_process_document, mock_stream, client):
"""Verify that /api/process-url endpoint is registered and accessible"""
# Mock successful download to prevent actual HTTP requests
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -46,17 +53,24 @@ class TestEndpointRegistration:
"Verify that url_upload_router is included in app/api/__init__.py"
)
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_endpoint_accepts_post(self, mock_process_document, mock_requests_get, client):
def test_process_url_endpoint_accepts_post(self, mock_process_document, mock_stream, client):
"""Verify that /api/process-url accepts POST requests"""
# Mock successful download to prevent actual HTTP requests
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -72,17 +86,24 @@ class TestEndpointRegistration:
"Verify the endpoint is decorated with @router.post()"
)
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_api_router_included_in_app(self, mock_process_document, mock_requests_get, client):
def test_api_router_included_in_app(self, mock_process_document, mock_stream, client):
"""Verify that the main API router is included in the FastAPI app"""
# Mock successful download for /api/process-url test
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
+12 -6
View File
@@ -417,14 +417,20 @@ class TestOneDriveIntegration:
def test_onedrive_token_refresh_and_user_info(self, original_env: dict) -> None:
"""Validate token refresh and user info retrieval."""
import requests
import asyncio
import httpx
token = self._get_access_token(original_env)
resp = requests.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
async def _test():
async with httpx.AsyncClient(timeout=30) as client:
return await client.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {token}"},
)
resp = asyncio.run(_test())
assert resp.status_code == 200, f"OneDrive user info failed: {resp.text}"
def test_onedrive_upload_download_delete(self, original_env: dict) -> None:
+1 -1
View File
@@ -345,7 +345,7 @@ class TestUploadErrorHandling:
def test_upload_disk_write_failure(self, client: TestClient):
"""Test handling of disk write failures."""
with patch("builtins.open", side_effect=IOError("Disk full")):
with patch("aiofiles.open", side_effect=IOError("Disk full")):
pdf_content = b"%PDF-1.4\n%EOF"
response = client.post(
+134
View File
@@ -632,3 +632,137 @@ class TestFinalizeDocumentStorageUserRouting:
mock_send_all.delay.assert_called_once_with("/workdir/processed/file.pdf", True, 400)
mock_send_user.delay.assert_not_called()
assert result["status"] == "Completed"
@pytest.mark.unit
class TestFinalizeDocumentStorageUserNotification:
"""Tests for per-user notification dispatch in finalize_document_storage."""
@patch("app.tasks.finalize_document_storage.notify_user_document_processed")
@patch("app.tasks.finalize_document_storage.notify_file_processed")
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=2)
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
@patch("app.tasks.finalize_document_storage.log_task_progress")
@patch("app.tasks.finalize_document_storage.SessionLocal")
def test_dispatches_per_user_notification_when_owner_is_set(
self,
mock_session_local,
mock_log_progress,
mock_get_services,
mock_get_dest_count,
mock_send_all,
mock_send_user,
mock_notify_system,
mock_notify_user,
):
"""notify_user_document_processed is called when owner_id is available."""
mock_get_services.return_value = {"dropbox": True}
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_file_record = _make_file_record(100, owner_id="alice@example.com")
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=1024):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="doc.pdf"):
finalize_document_storage.request.id = "test-task-id"
finalize_document_storage.__wrapped__(
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/doc.pdf",
metadata={"filename": "doc.pdf"},
file_id=100,
)
mock_notify_user.assert_called_once_with(
owner_id="alice@example.com",
filename="doc.pdf",
file_id=100,
)
@patch("app.tasks.finalize_document_storage.notify_user_document_processed")
@patch("app.tasks.finalize_document_storage.notify_file_processed")
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
@patch("app.tasks.finalize_document_storage.log_task_progress")
@patch("app.tasks.finalize_document_storage.SessionLocal")
def test_skips_per_user_notification_when_no_owner(
self,
mock_session_local,
mock_log_progress,
mock_get_services,
mock_get_dest_count,
mock_send_all,
mock_send_user,
mock_notify_system,
mock_notify_user,
):
"""notify_user_document_processed is NOT called when owner_id is None."""
mock_get_services.return_value = {"dropbox": True}
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_file_record = _make_file_record(200, owner_id=None)
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=2048):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="file.pdf"):
finalize_document_storage.request.id = "test-task-id"
finalize_document_storage.__wrapped__(
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/file.pdf",
metadata={"filename": "file.pdf"},
file_id=200,
)
mock_notify_user.assert_not_called()
@patch("app.tasks.finalize_document_storage.notify_user_document_processed")
@patch("app.tasks.finalize_document_storage.notify_file_processed")
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
@patch("app.tasks.finalize_document_storage.log_task_progress")
@patch("app.tasks.finalize_document_storage.SessionLocal")
def test_per_user_notification_failure_does_not_break_task(
self,
mock_session_local,
mock_log_progress,
mock_get_services,
mock_get_dest_count,
mock_send_all,
mock_send_user,
mock_notify_system,
mock_notify_user,
):
"""Even if notify_user_document_processed raises, finalize returns success."""
mock_get_services.return_value = {"dropbox": True}
mock_notify_user.side_effect = RuntimeError("SMTP down")
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_file_record = _make_file_record(300, owner_id="bob@example.com")
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=512):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="a.pdf"):
finalize_document_storage.request.id = "test-task-id"
result = finalize_document_storage.__wrapped__(
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/a.pdf",
metadata={"filename": "a.pdf"},
file_id=300,
)
assert result["status"] == "Completed"
mock_notify_user.assert_called_once()
+285
View File
@@ -0,0 +1,285 @@
"""Tests for application logging configuration.
Validates that the LOG_LEVEL and DEBUG settings correctly control the
Python root-logger level and that the standard precedence rules are respected:
1. Explicit LOG_LEVEL always wins.
2. DEBUG=True without LOG_LEVEL → effective DEBUG.
3. Neither set → default INFO.
"""
import logging
import os
from unittest.mock import patch
import pytest
from app.config import Settings
@pytest.mark.unit
class TestLogLevelSetting:
"""Tests for the log_level config field."""
_BASE_KWARGS = {
"database_url": "sqlite:///test.db",
"redis_url": "redis://localhost:6379",
"openai_api_key": "test",
"azure_ai_key": "test",
"azure_region": "test",
"azure_endpoint": "https://test.example.com",
"gotenberg_url": "http://localhost:3000",
"workdir": "/tmp",
"auth_enabled": False,
"session_secret": None,
}
def test_log_level_default_is_info(self):
"""Test that log_level defaults to INFO."""
config = Settings(**self._BASE_KWARGS)
assert config.log_level.upper() == "INFO"
def test_log_level_accepts_debug(self):
"""Test that log_level accepts DEBUG."""
config = Settings(**self._BASE_KWARGS, log_level="DEBUG")
assert config.log_level.upper() == "DEBUG"
def test_log_level_accepts_warning(self):
"""Test that log_level accepts WARNING."""
config = Settings(**self._BASE_KWARGS, log_level="WARNING")
assert config.log_level.upper() == "WARNING"
def test_log_level_accepts_error(self):
"""Test that log_level accepts ERROR."""
config = Settings(**self._BASE_KWARGS, log_level="ERROR")
assert config.log_level.upper() == "ERROR"
def test_log_level_case_insensitive(self):
"""Test that log_level is case-insensitive in usage."""
config = Settings(**self._BASE_KWARGS, log_level="debug")
assert config.log_level.upper() == "DEBUG"
def test_debug_flag_defaults_to_false(self):
"""Test that debug defaults to False."""
config = Settings(**self._BASE_KWARGS)
assert config.debug is False
@pytest.mark.unit
class TestEffectiveLogLevel:
"""Tests for the effective log-level resolution logic in main.py."""
def test_debug_true_without_log_level_gives_debug(self):
"""When DEBUG=True and LOG_LEVEL is not set, effective level is DEBUG."""
with patch.dict(os.environ, {"DEBUG": "true"}, clear=False):
# Remove LOG_LEVEL from env if present
env = os.environ.copy()
env.pop("LOG_LEVEL", None)
with patch.dict(os.environ, env, clear=True):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
debug=True,
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "DEBUG"
def test_explicit_log_level_overrides_debug(self):
"""When LOG_LEVEL is explicitly set, it takes precedence over DEBUG=True."""
with patch.dict(os.environ, {"LOG_LEVEL": "WARNING", "DEBUG": "true"}, clear=False):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
debug=True,
log_level="WARNING",
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "WARNING"
def test_default_no_flags_gives_info(self):
"""When neither DEBUG nor LOG_LEVEL is set, effective level is INFO."""
env = os.environ.copy()
env.pop("LOG_LEVEL", None)
env.pop("DEBUG", None)
with patch.dict(os.environ, env, clear=True):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "INFO"
def test_effective_level_maps_to_logging_constant(self):
"""The effective level string maps to a valid logging constant."""
for level_name in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
assert getattr(logging, level_name) is not None
@pytest.mark.unit
class TestLoggingConfiguredAtStartup:
"""Tests that the main module configures the root logger on import."""
def test_root_logger_has_handler(self):
"""Root logger should have at least one handler after app import."""
root = logging.getLogger()
assert len(root.handlers) > 0, "Root logger has no handlers after app startup"
def test_root_logger_level_is_not_warning_default(self):
"""Root logger should not be at the unconfigured WARNING default.
Our basicConfig(force=True) should have set it to at least INFO.
"""
root = logging.getLogger()
# The test env doesn't set DEBUG=True, so the level should be INFO (20)
assert root.level <= logging.INFO
@pytest.mark.unit
class TestJsonFormatter:
"""Tests for the _JsonFormatter used when LOG_FORMAT=json."""
def _make_formatter(self):
"""Lazily import the JSON formatter from main module."""
from app.main import _JsonFormatter
return _JsonFormatter()
def test_output_is_valid_json(self):
"""JSON formatter output should be parseable JSON."""
import json
fmt = self._make_formatter()
record = logging.LogRecord(
name="test.logger",
level=logging.INFO,
pathname="test.py",
lineno=42,
msg="Hello %s",
args=("world",),
exc_info=None,
)
result = fmt.format(record)
parsed = json.loads(result)
assert parsed["level"] == "INFO"
assert parsed["logger"] == "test.logger"
assert parsed["message"] == "Hello world"
assert parsed["lineno"] == 42
def test_includes_timestamp_iso8601(self):
"""JSON output should contain an ISO 8601 timestamp."""
import json
fmt = self._make_formatter()
record = logging.LogRecord(
name="x",
level=logging.DEBUG,
pathname="x.py",
lineno=1,
msg="test",
args=(),
exc_info=None,
)
parsed = json.loads(fmt.format(record))
assert "timestamp" in parsed
# ISO 8601 timestamps contain "T" and "+00:00" (UTC)
assert "T" in parsed["timestamp"]
def test_includes_exc_info_when_present(self):
"""JSON output should include exc_info when an exception is logged."""
import json
fmt = self._make_formatter()
try:
raise ValueError("boom") # noqa: TRY301
except ValueError:
import sys
record = logging.LogRecord(
name="x",
level=logging.ERROR,
pathname="x.py",
lineno=1,
msg="error",
args=(),
exc_info=sys.exc_info(),
)
parsed = json.loads(fmt.format(record))
assert "exc_info" in parsed
assert "ValueError" in parsed["exc_info"]
@pytest.mark.unit
class TestLogFormatSetting:
"""Tests for the log_format and log_syslog_* config fields."""
_BASE_KWARGS = {
"database_url": "sqlite:///test.db",
"redis_url": "redis://localhost:6379",
"openai_api_key": "test",
"azure_ai_key": "test",
"azure_region": "test",
"azure_endpoint": "https://test.example.com",
"gotenberg_url": "http://localhost:3000",
"workdir": "/tmp",
"auth_enabled": False,
"session_secret": None,
}
def test_log_format_default_is_text(self):
"""Test that log_format defaults to 'text'."""
config = Settings(**self._BASE_KWARGS)
assert config.log_format == "text"
def test_log_format_accepts_json(self):
"""Test that log_format accepts 'json'."""
config = Settings(**self._BASE_KWARGS, log_format="json")
assert config.log_format == "json"
def test_log_syslog_defaults(self):
"""Test syslog forwarding defaults."""
config = Settings(**self._BASE_KWARGS)
assert config.log_syslog_enabled is False
assert config.log_syslog_host == "localhost"
assert config.log_syslog_port == 514
assert config.log_syslog_protocol == "udp"
def test_log_syslog_can_be_enabled(self):
"""Test that syslog forwarding can be enabled."""
config = Settings(**self._BASE_KWARGS, log_syslog_enabled=True, log_syslog_host="syslog.example.com")
assert config.log_syslog_enabled is True
assert config.log_syslog_host == "syslog.example.com"
+117 -1
View File
@@ -18,7 +18,7 @@ from sqlalchemy.pool import StaticPool
from app.config import settings
from app.database import Base
from app.models import FileRecord
from app.models import ApiToken, FileRecord
# ---------------------------------------------------------------------------
# Fixtures
@@ -162,6 +162,122 @@ class TestGetCurrentOwnerId:
request.session = {}
assert get_current_owner_id(request) is None
@pytest.mark.unit
def test_resolves_from_api_token_user_state(self):
"""get_current_owner_id should resolve from request.state.api_token_user."""
from app.utils.user_scope import get_current_owner_id
request = MagicMock()
request.session = {}
request.state.api_token_user = {
"id": "tok-owner",
"preferred_username": "tok-owner",
"email": "tok-owner",
}
assert get_current_owner_id(request) == "tok-owner"
@pytest.mark.unit
def test_session_takes_precedence_over_api_token_user(self):
"""Session auth should take precedence over api_token_user in state."""
from app.utils.user_scope import get_current_owner_id
request = MagicMock()
request.session = {"user": {"sub": "session-sub", "email": "session@example.com"}}
request.state.api_token_user = {"id": "tok-owner"}
assert get_current_owner_id(request) == "session-sub"
@pytest.mark.unit
def test_resolves_bearer_token_directly(self, mu_engine, mu_session):
"""get_current_owner_id should resolve a Bearer token when no session exists."""
from types import SimpleNamespace
from app.api.api_tokens import generate_api_token, hash_token
from app.utils.user_scope import get_current_owner_id
# Create a token in the DB
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id="bearer-owner",
name="Test Bearer",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
)
mu_session.add(db_token)
mu_session.commit()
# Build a mock request with Bearer header but no session.
# SimpleNamespace starts with no attributes so getattr(..., None) works.
request = MagicMock()
request.session = {}
request.state = SimpleNamespace()
request.headers = {"authorization": f"Bearer {plaintext}"}
request.client.host = "127.0.0.1"
# Provide the test session and make close() a no-op so the shared
# session is not torn down prematurely.
noop_close = MagicMock()
with patch("app.database.SessionLocal", return_value=mu_session), patch.object(mu_session, "close", noop_close):
result = get_current_owner_id(request)
assert result == "bearer-owner"
# Verify the resolved user was cached in request.state
assert request.state.api_token_user["id"] == "bearer-owner"
@pytest.mark.unit
def test_returns_none_for_invalid_bearer_token(self, mu_engine, mu_session):
"""get_current_owner_id should return None for an invalid Bearer token."""
from types import SimpleNamespace
from app.utils.user_scope import get_current_owner_id
request = MagicMock()
request.session = {}
request.state = SimpleNamespace()
request.headers = {"authorization": "Bearer de_invalid_token_value"}
request.client.host = "127.0.0.1"
noop_close = MagicMock()
with patch("app.database.SessionLocal", return_value=mu_session), patch.object(mu_session, "close", noop_close):
result = get_current_owner_id(request)
assert result is None
class TestOwnerIdFromUser:
"""Tests for the _owner_id_from_user helper."""
@pytest.mark.unit
def test_prefers_sub(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"sub": "s", "preferred_username": "u", "email": "e"}) == "s"
@pytest.mark.unit
def test_falls_back_to_preferred_username(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"preferred_username": "u", "email": "e"}) == "u"
@pytest.mark.unit
def test_falls_back_to_email(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"email": "e"}) == "e"
@pytest.mark.unit
def test_falls_back_to_id(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"id": "i"}) == "i"
@pytest.mark.unit
def test_returns_none_for_empty_dict(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({}) is None
class TestApplyOwnerFilter:
"""Tests for apply_owner_filter()."""
+55
View File
@@ -830,3 +830,58 @@ class TestUserNotificationService:
result = _send_email_notification({"smtp_host": "smtp.example.com"}, "Title", "Body")
assert result is False
class TestBenchmark:
@pytest.mark.unit
def test_update_preferences_benchmark(self, notif_engine, notif_session):
import statistics
import time
from app.main import app
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="webhook",
name="My Webhook",
config=json.dumps({"url": "https://x.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
client = _make_client(notif_engine, _OWNER)
try:
items_count = 100
preferences = []
for i in range(items_count):
preferences.append(
{
"event_type": f"event.type.{i}",
"channel_type": "webhook",
"is_enabled": True,
"target_id": target.id,
}
)
payload = {"preferences": preferences}
# Warm up
client.put("/api/user-notifications/preferences", json=payload)
times = []
for _ in range(5):
# Alter the values a bit so it's a real update
for p in payload["preferences"]:
p["is_enabled"] = not p["is_enabled"]
start = time.time()
resp = client.put("/api/user-notifications/preferences", json=payload)
end = time.time()
assert resp.status_code == 200
times.append(end - start)
print(f"\nAverage time: {statistics.mean(times):.4f}s")
finally:
_cleanup(app)
+27
View File
@@ -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
+699
View File
@@ -0,0 +1,699 @@
"""Tests for server-side session management and QR code login.
Covers:
* Session creation, validation, revocation, and cleanup
* "Log off everywhere" (revoke all sessions)
* QR login challenge creation, validation, claiming, and status polling
* Session management API endpoints (list, revoke, revoke-all)
* QR auth API endpoints (challenge, status, claim)
* Device info parsing from User-Agent strings
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.database import Base
from app.models import ApiToken, QRLoginChallenge, UserSession
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def db_session():
"""Provide an in-memory SQLite session with all tables created."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
TestSession = sessionmaker(bind=engine)
session = TestSession()
yield session
session.close()
Base.metadata.drop_all(engine)
@pytest.fixture()
def sample_user_id():
return "user@example.com"
# ---------------------------------------------------------------------------
# Model Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUserSessionModel:
"""Tests for the UserSession ORM model."""
def test_create_user_session(self, db_session: Session, sample_user_id: str):
"""Test creating a UserSession record."""
now = datetime.now(timezone.utc)
session = UserSession(
session_token=secrets.token_urlsafe(64),
user_id=sample_user_id,
ip_address="192.168.1.1",
user_agent="Mozilla/5.0",
device_info="Chrome on macOS",
expires_at=now + timedelta(days=30),
)
db_session.add(session)
db_session.commit()
assert session.id is not None
assert session.user_id == sample_user_id
assert session.is_revoked is False
assert session.device_info == "Chrome on macOS"
def test_session_default_values(self, db_session: Session, sample_user_id: str):
"""Test that default values are set correctly."""
session = UserSession(
session_token="test_token_123",
user_id=sample_user_id,
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
)
db_session.add(session)
db_session.commit()
assert session.is_revoked is False
assert session.revoked_at is None
@pytest.mark.unit
class TestQRLoginChallengeModel:
"""Tests for the QRLoginChallenge ORM model."""
def test_create_challenge(self, db_session: Session, sample_user_id: str):
"""Test creating a QRLoginChallenge record."""
challenge = QRLoginChallenge(
challenge_token=secrets.token_urlsafe(64),
user_id=sample_user_id,
created_by_ip="10.0.0.1",
expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
)
db_session.add(challenge)
db_session.commit()
assert challenge.id is not None
assert challenge.is_claimed is False
assert challenge.is_cancelled is False
def test_challenge_default_values(self, db_session: Session, sample_user_id: str):
"""Test that QRLoginChallenge defaults are correct."""
challenge = QRLoginChallenge(
challenge_token="challenge_test_123",
user_id=sample_user_id,
expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
)
db_session.add(challenge)
db_session.commit()
assert challenge.is_claimed is False
assert challenge.is_cancelled is False
assert challenge.claimed_at is None
assert challenge.device_name is None
# ---------------------------------------------------------------------------
# Session Manager Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSessionManager:
"""Tests for app/utils/session_manager.py functions."""
@patch("app.utils.session_manager.settings")
def test_get_session_lifetime_days_default(self, mock_settings):
"""Test default session lifetime."""
from app.utils.session_manager import get_session_lifetime_days
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
assert get_session_lifetime_days() == 30
@patch("app.utils.session_manager.settings")
def test_get_session_lifetime_days_custom(self, mock_settings):
"""Test custom session lifetime overrides default."""
from app.utils.session_manager import get_session_lifetime_days
mock_settings.session_lifetime_custom_days = 90
mock_settings.session_lifetime_days = 30
assert get_session_lifetime_days() == 90
@patch("app.utils.session_manager.settings")
def test_get_session_lifetime_days_minimum(self, mock_settings):
"""Test session lifetime has a minimum of 1 day."""
from app.utils.session_manager import get_session_lifetime_days
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 0
assert get_session_lifetime_days() == 1
@patch("app.utils.session_manager.settings")
def test_get_session_max_age_seconds(self, mock_settings):
"""Test session max age in seconds."""
from app.utils.session_manager import get_session_max_age_seconds
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
assert get_session_max_age_seconds() == 30 * 86400
@patch("app.utils.session_manager.settings")
def test_create_session(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test creating a server-side session."""
from app.utils.session_manager import create_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
mock_settings.qr_login_challenge_ttl_seconds = 120
user_session = create_session(
db_session,
user_id=sample_user_id,
ip_address="10.0.0.1",
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0",
)
assert user_session.id is not None
assert user_session.user_id == sample_user_id
assert user_session.ip_address == "10.0.0.1"
assert user_session.session_token is not None
assert len(user_session.session_token) > 32
assert user_session.is_revoked is False
assert user_session.device_info is not None
@patch("app.utils.session_manager.settings")
def test_validate_session_valid(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test validating a valid session."""
from app.utils.session_manager import create_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
result = validate_session(db_session, user_session.session_token)
assert result is not None
assert result.id == user_session.id
@patch("app.utils.session_manager.settings")
def test_validate_session_revoked(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that revoked sessions are rejected."""
from app.utils.session_manager import create_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
user_session.is_revoked = True
db_session.commit()
result = validate_session(db_session, user_session.session_token)
assert result is None
@patch("app.utils.session_manager.settings")
def test_validate_session_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that expired sessions are rejected."""
from app.utils.session_manager import create_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
user_session.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
db_session.commit()
result = validate_session(db_session, user_session.session_token)
assert result is None
def test_validate_session_empty_token(self, db_session: Session):
"""Test that empty token returns None."""
from app.utils.session_manager import validate_session
assert validate_session(db_session, "") is None
assert validate_session(db_session, None) is None
def test_validate_session_nonexistent_token(self, db_session: Session):
"""Test that nonexistent token returns None."""
from app.utils.session_manager import validate_session
assert validate_session(db_session, "nonexistent_token_xyz") is None
@patch("app.utils.session_manager.settings")
def test_revoke_session(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test revoking a single session."""
from app.utils.session_manager import create_session, revoke_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
assert revoke_session(db_session, user_session.id, sample_user_id) is True
# Session should now be invalid
assert validate_session(db_session, user_session.session_token) is None
@patch("app.utils.session_manager.settings")
def test_revoke_session_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a user cannot revoke another user's session."""
from app.utils.session_manager import create_session, revoke_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
assert revoke_session(db_session, user_session.id, "other_user@example.com") is False
@patch("app.utils.session_manager.settings")
def test_revoke_all_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test revoking all sessions for a user."""
from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
s1 = create_session(db_session, user_id=sample_user_id)
s2 = create_session(db_session, user_id=sample_user_id)
s3 = create_session(db_session, user_id=sample_user_id)
count = revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=False)
assert count == 3
# All sessions should be revoked
active = list_user_sessions(db_session, sample_user_id)
assert len(active) == 0
@patch("app.utils.session_manager.settings")
def test_revoke_all_except_current(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test revoking all sessions except the current one."""
from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
s1 = create_session(db_session, user_id=sample_user_id)
s2 = create_session(db_session, user_id=sample_user_id)
s3 = create_session(db_session, user_id=sample_user_id)
count = revoke_all_sessions(
db_session,
sample_user_id,
except_session_id=s1.id,
revoke_api_tokens=False,
)
assert count == 2
active = list_user_sessions(db_session, sample_user_id)
assert len(active) == 1
assert active[0].id == s1.id
@patch("app.utils.session_manager.settings")
def test_revoke_all_includes_api_tokens(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that revoke-all also revokes API tokens."""
from app.utils.session_manager import create_session, revoke_all_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
create_session(db_session, user_id=sample_user_id)
# Create an API token
token = ApiToken(
owner_id=sample_user_id,
name="Test Token",
token_hash="abc123hash",
token_prefix="de_abc12345",
)
db_session.add(token)
db_session.commit()
revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=True)
db_session.refresh(token)
assert token.is_active is False
@patch("app.utils.session_manager.settings")
def test_list_user_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test listing active sessions for a user."""
from app.utils.session_manager import create_session, list_user_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
create_session(db_session, user_id=sample_user_id)
create_session(db_session, user_id=sample_user_id)
create_session(db_session, user_id="other@example.com")
sessions = list_user_sessions(db_session, sample_user_id)
assert len(sessions) == 2
@patch("app.utils.session_manager.settings")
def test_cleanup_expired_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test cleaning up expired sessions."""
from app.utils.session_manager import cleanup_expired_sessions, create_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
# Create a session that expired 10 days ago
session = create_session(db_session, user_id=sample_user_id)
session.expires_at = datetime.now(timezone.utc) - timedelta(days=10)
db_session.commit()
count = cleanup_expired_sessions(db_session)
assert count == 1
# ---------------------------------------------------------------------------
# QR Login Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestQRLogin:
"""Tests for QR login challenge/claim flow."""
@patch("app.utils.session_manager.settings")
def test_create_qr_challenge(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test creating a QR login challenge."""
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, ip_address="10.0.0.1")
assert challenge.id is not None
assert challenge.user_id == sample_user_id
assert challenge.challenge_token is not None
assert len(challenge.challenge_token) > 32
assert challenge.is_claimed is False
assert challenge.created_by_ip == "10.0.0.1"
# SQLite returns naive datetimes; normalise before comparison
expires = challenge.expires_at
if expires.tzinfo is None:
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."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is not None
assert result.id == challenge.id
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that expired challenges are rejected."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is None
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that claimed challenges are rejected (replay protection)."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.is_claimed = True
db_session.commit()
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is None
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_cancelled(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that cancelled challenges are rejected."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.is_cancelled = True
db_session.commit()
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is None
def test_validate_qr_challenge_empty(self, db_session: Session):
"""Test that empty challenge token returns None."""
from app.utils.session_manager import validate_qr_challenge
assert validate_qr_challenge(db_session, "") is None
assert validate_qr_challenge(db_session, None) is None
@patch("app.utils.session_manager.settings")
def test_claim_qr_challenge_success(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test successfully claiming a QR challenge."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
result = claim_qr_challenge(
db_session,
challenge.challenge_token,
device_name="Christian's iPhone 15 Pro",
ip_address="192.168.1.100",
)
assert result is not None
assert result["token"].startswith("de_")
assert result["token_id"] is not None
assert result["owner_id"] == sample_user_id
assert "QR" in result["name"]
# Challenge should now be claimed
db_session.refresh(challenge)
assert challenge.is_claimed is True
assert challenge.claimed_by_ip == "192.168.1.100"
assert challenge.device_name == "Christian's iPhone 15 Pro"
@patch("app.utils.session_manager.settings")
def test_claim_qr_challenge_replay_protection(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a claimed challenge cannot be claimed again."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
# First claim succeeds
result1 = claim_qr_challenge(db_session, challenge.challenge_token)
assert result1 is not None
# Second claim fails (replay protection)
result2 = claim_qr_challenge(db_session, challenge.challenge_token)
assert result2 is None
@patch("app.utils.session_manager.settings")
def test_claim_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that expired challenges cannot be claimed."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
result = claim_qr_challenge(db_session, challenge.challenge_token)
assert result is None
def test_claim_qr_challenge_invalid_token(self, db_session: Session):
"""Test claiming with an invalid token."""
from app.utils.session_manager import claim_qr_challenge
result = claim_qr_challenge(db_session, "nonexistent_token_xyz")
assert result is None
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_pending(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test getting status of a pending challenge."""
from app.utils.session_manager import create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
status = get_challenge_status(db_session, challenge.id, sample_user_id)
assert status is not None
assert status["status"] == "pending"
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test getting status of a claimed challenge."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
claim_qr_challenge(db_session, challenge.challenge_token, device_name="Test Device")
status = get_challenge_status(db_session, challenge.id, sample_user_id)
assert status is not None
assert status["status"] == "claimed"
assert status["device_name"] == "Test Device"
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test getting status of an expired challenge."""
from app.utils.session_manager import create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
status = get_challenge_status(db_session, challenge.id, sample_user_id)
assert status["status"] == "expired"
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a user cannot see another user's challenge status."""
from app.utils.session_manager import create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
status = get_challenge_status(db_session, challenge.id, "other@example.com")
assert status is None
# ---------------------------------------------------------------------------
# Device Info Parsing Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeviceInfoParsing:
"""Tests for User-Agent parsing."""
def test_chrome_macos(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = _parse_device_info(ua)
assert "Chrome" in result
assert "macOS" in result
def test_safari_iphone(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
result = _parse_device_info(ua)
assert "Safari" in result
assert "iPhone" in result
def test_firefox_windows(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
result = _parse_device_info(ua)
assert "Firefox" in result
assert "Windows" in result
def test_edge_windows(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
result = _parse_device_info(ua)
assert "Edge" in result
assert "Windows" in result
def test_android_chrome(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.210 Mobile Safari/537.36"
result = _parse_device_info(ua)
assert "Chrome" in result
assert "Android" in result
def test_none_user_agent(self):
from app.utils.session_manager import _parse_device_info
assert _parse_device_info(None) is None
def test_empty_user_agent(self):
from app.utils.session_manager import _parse_device_info
assert _parse_device_info("") is None
# ---------------------------------------------------------------------------
# Config Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSessionConfig:
"""Tests for session-related configuration fields."""
def test_session_lifetime_days_field_exists(self):
"""Verify session_lifetime_days field is defined in Settings."""
from app.config import Settings
# Check the field exists in the model
assert "session_lifetime_days" in Settings.model_fields
def test_session_lifetime_custom_days_field_exists(self):
"""Verify session_lifetime_custom_days field is defined in Settings."""
from app.config import Settings
assert "session_lifetime_custom_days" in Settings.model_fields
def test_qr_login_challenge_ttl_field_exists(self):
"""Verify qr_login_challenge_ttl_seconds field is defined in Settings."""
from app.config import Settings
assert "qr_login_challenge_ttl_seconds" in Settings.model_fields
+31 -1
View File
@@ -289,7 +289,8 @@ class TestNotifySettingsUpdated:
call_args = mock_redis_instance.set.call_args[0]
assert call_args[0] == SETTINGS_VERSION_KEY
def test_does_not_raise_on_redis_failure(self):
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_redis_failure(self, mock_logger):
"""notify_settings_updated must not propagate Redis errors."""
from app.utils.settings_sync import notify_settings_updated
@@ -297,6 +298,35 @@ class TestNotifySettingsUpdated:
mock_redis_module.from_url.side_effect = Exception("Redis down")
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not publish settings update to Redis: Redis down")
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_reload_failure(self, mock_logger):
"""notify_settings_updated must not propagate settings reload errors."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
mock_reload.side_effect = Exception("Reload error")
# We mock redis so that we skip over the redis block, and mock ensure_ocr_languages_async to prevent its side effects.
with patch("app.utils.settings_sync.redis"):
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async"):
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not reload in-process settings: Reload error")
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_ocr_language_check_failure(self, mock_logger):
"""notify_settings_updated must not propagate OCR language check errors."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async") as mock_ensure:
mock_ensure.side_effect = Exception("OCR error")
# We mock redis and reload_settings_from_db so we only test the OCR block failure.
with patch("app.utils.settings_sync.redis"):
with patch("app.utils.config_loader.reload_settings_from_db"):
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR error")
@pytest.mark.unit
+211
View File
@@ -0,0 +1,211 @@
from unittest.mock import MagicMock, patch
import pytest
import app.utils.settings_sync
from app.utils.settings_sync import (
SETTINGS_VERSION_KEY,
notify_settings_updated,
register_settings_reload_signal,
)
@pytest.fixture
def reset_last_seen_version():
"""Reset the global variable before and after tests."""
app.utils.settings_sync._last_seen_version = ""
yield
app.utils.settings_sync._last_seen_version = ""
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
@patch("app.utils.settings_sync.time.time", return_value=12345.0)
def test_notify_settings_updated_success(mock_time, mock_ensure_ocr, mock_reload, mock_redis):
# Setup mock redis instance
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verify redis calls
mock_redis.assert_called_once()
mock_redis_instance.set.assert_called_once_with(SETTINGS_VERSION_KEY, "12345.0")
# Verify other calls
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_redis_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup mock redis to fail
mock_redis.side_effect = Exception("Redis connection failed")
notify_settings_updated()
# Verification: should continue and call reload and ocr despite redis failure
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not publish settings update to Redis: Redis connection failed" in caplog.text
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_reload_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup reload to fail
mock_reload.side_effect = Exception("Reload failed")
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verification: redis should be called, reload fails, ocr should still be called
mock_redis_instance.set.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not reload in-process settings: Reload failed" in caplog.text
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_ocr_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup ocr check to fail
mock_ensure_ocr.side_effect = Exception("OCR check failed")
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verification: all should be called, ocr failure logged
mock_redis_instance.set.assert_called_once()
mock_reload.assert_called_once()
assert "Could not schedule OCR language check: OCR check failed" in caplog.text
@patch("app.utils.settings_sync.task_prerun.connect")
def test_register_settings_reload_signal(mock_connect):
register_settings_reload_signal()
# It should register a signal with task_prerun
mock_connect.assert_called_once_with(weak=False)
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_new_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
# Capture the registered callback
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
mock_connect.assert_called_once_with(weak=False)
# Get the callback function
callback = mock_decorator.call_args[0][0]
# Setup redis to return a new version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"new_version"
mock_redis.return_value = mock_redis_instance
# Initial state check
assert app.utils.settings_sync._last_seen_version == ""
# Call the callback
callback(sender="test")
# Verification
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert app.utils.settings_sync._last_seen_version == "new_version"
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_same_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
# Set initial state
app.utils.settings_sync._last_seen_version = "existing_version"
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to return the SAME version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"existing_version"
mock_redis.return_value = mock_redis_instance
# Call the callback
callback(sender="test")
# Verification
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
# Should NOT reload or check OCR
mock_reload.assert_not_called()
mock_ensure_ocr.assert_not_called()
assert app.utils.settings_sync._last_seen_version == "existing_version"
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
def test_reload_if_stale_redis_error(mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog):
import logging
caplog.set_level(logging.DEBUG)
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to fail
mock_redis.side_effect = Exception("Redis error")
# Call the callback
callback(sender="test")
# Verification
mock_reload.assert_not_called()
assert "Settings version check skipped: Redis error" in caplog.text
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_ocr_error(
mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog
):
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to return a new version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"new_version"
mock_redis.return_value = mock_redis_instance
# Setup OCR check to fail
mock_ensure_ocr.side_effect = Exception("OCR error")
# Call the callback
callback(sender="test")
# Verification
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not schedule OCR language check on worker: OCR error" in caplog.text
assert app.utils.settings_sync._last_seen_version == "new_version"
+393
View File
@@ -0,0 +1,393 @@
"""Tests for the system reset feature (app/api/system_reset.py, app/utils/system_reset.py, app/views/system_reset.py)."""
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import (
DocumentMetadata,
FileProcessingStep,
FileRecord,
ProcessingLog,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def reset_workdir():
"""Create a temporary workdir populated with sample user data."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create data subdirectories with dummy files
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
d = Path(tmpdir) / subdir
d.mkdir()
(d / "sample.pdf").write_bytes(b"%PDF-1.4 fake")
# Create cache files
for cache in ("watch_folder_processed.json", "ftp_ingest_processed.json"):
(Path(tmpdir) / cache).write_text("{}")
# Create a per-user watch folder cache
(Path(tmpdir) / "user_wf_42.json").write_text("{}")
# Create a loose PDF in workdir root
(Path(tmpdir) / "abc123.pdf").write_bytes(b"%PDF-1.4 loose")
yield tmpdir
@pytest.fixture
def reset_db_session():
"""Fresh in-memory database with sample user data rows."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
session = Session()
# Seed with sample data
fr = FileRecord(
filehash="abc123",
original_filename="test.pdf",
local_filename="uuid.pdf",
file_size=1024,
mime_type="application/pdf",
)
session.add(fr)
session.flush()
session.add(ProcessingLog(file_id=fr.id, task_id="t1", step_name="hash_file", status="success"))
session.add(FileProcessingStep(file_id=fr.id, step_name="hash_file", status="success"))
session.add(DocumentMetadata(filename="test.pdf", sender="Alice", recipient="Bob"))
session.commit()
yield session
session.close()
Base.metadata.drop_all(bind=engine)
# ---------------------------------------------------------------------------
# Unit tests for app/utils/system_reset.py
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWipeWorkdirData:
"""Tests for _wipe_workdir_data()."""
def test_removes_data_subdirs(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
result = _wipe_workdir_data(reset_workdir)
# All data subdirectories should be gone
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
assert not (Path(reset_workdir) / subdir).exists()
assert result["deleted_dirs"] == 5
def test_removes_cache_files(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
result = _wipe_workdir_data(reset_workdir)
assert not (Path(reset_workdir) / "watch_folder_processed.json").exists()
assert not (Path(reset_workdir) / "ftp_ingest_processed.json").exists()
assert not (Path(reset_workdir) / "user_wf_42.json").exists()
assert result["deleted_files"] >= 3
def test_removes_loose_document_files(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
_wipe_workdir_data(reset_workdir)
assert not (Path(reset_workdir) / "abc123.pdf").exists()
def test_preserves_workdir_directory(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
_wipe_workdir_data(reset_workdir)
assert Path(reset_workdir).is_dir()
def test_handles_empty_workdir(self):
"""No errors when workdir has no data dirs or caches."""
from app.utils.system_reset import _wipe_workdir_data
with tempfile.TemporaryDirectory() as empty_dir:
result = _wipe_workdir_data(empty_dir)
assert result["deleted_dirs"] == 0
assert result["deleted_files"] == 0
@pytest.mark.unit
class TestWipeDatabase:
"""Tests for _wipe_database()."""
def test_deletes_all_user_data(self, reset_db_session):
from app.utils.system_reset import _wipe_database
result = _wipe_database(reset_db_session)
assert result.get("files", 0) >= 1
assert result.get("processing_logs", 0) >= 1
assert result.get("file_processing_steps", 0) >= 1
assert result.get("document_metadata", 0) >= 1
def test_tables_are_empty_after_wipe(self, reset_db_session):
from app.utils.system_reset import _wipe_database
_wipe_database(reset_db_session)
assert reset_db_session.query(FileRecord).count() == 0
assert reset_db_session.query(ProcessingLog).count() == 0
assert reset_db_session.query(FileProcessingStep).count() == 0
assert reset_db_session.query(DocumentMetadata).count() == 0
@pytest.mark.unit
class TestPerformFullReset:
"""Tests for perform_full_reset()."""
def test_wipes_db_and_filesystem(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_full_reset
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
result = perform_full_reset(reset_db_session)
assert "database" in result
assert "filesystem" in result
assert reset_db_session.query(FileRecord).count() == 0
assert not (Path(reset_workdir) / "original").exists()
@pytest.mark.unit
class TestPerformResetAndReimport:
"""Tests for perform_reset_and_reimport()."""
def test_copies_originals_to_reimport_then_wipes(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_reset_and_reimport
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
mock_settings.watch_folders = ""
mock_settings.watch_folder_delete_after_process = False
result = perform_reset_and_reimport(reset_db_session)
reimport_dir = Path(reset_workdir) / "reimport"
assert reimport_dir.is_dir()
assert result["reimport"]["files_moved"] >= 1
# DB should be wiped
assert reset_db_session.query(FileRecord).count() == 0
# Reimport folder should contain the original file
reimport_files = list(reimport_dir.iterdir())
assert len(reimport_files) >= 1
def test_configures_watch_folder(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_reset_and_reimport
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
mock_settings.watch_folders = "/some/other/folder"
mock_settings.watch_folder_delete_after_process = False
perform_reset_and_reimport(reset_db_session)
reimport_path = str(Path(reset_workdir) / "reimport")
# watch_folders should now include the reimport path
assert reimport_path in mock_settings.watch_folders
@pytest.mark.unit
class TestStartupReset:
"""Tests for perform_startup_reset()."""
def test_startup_reset_calls_full_reset(self):
from app.utils.system_reset import perform_startup_reset
with patch("app.utils.system_reset.perform_full_reset") as mock_reset:
with patch("app.database.SessionLocal") as mock_sl:
mock_db = mock_sl.return_value
perform_startup_reset()
mock_reset.assert_called_once_with(mock_db)
mock_db.close.assert_called_once()
def test_startup_reset_handles_errors(self):
from app.utils.system_reset import perform_startup_reset
with patch("app.utils.system_reset.perform_full_reset", side_effect=RuntimeError("boom")):
with patch("app.database.SessionLocal") as mock_sl:
mock_db = mock_sl.return_value
# Should not raise
perform_startup_reset()
mock_db.rollback.assert_called_once()
mock_db.close.assert_called_once()
# ---------------------------------------------------------------------------
# Integration tests for API endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSystemResetApi:
"""Tests for the /api/admin/system-reset/ endpoints."""
def test_full_reset_requires_admin(self, client):
"""Non-admin users get 403."""
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
assert response.status_code == 403
def test_full_reset_requires_feature_flag(self, client):
"""Returns 404 when ENABLE_FACTORY_RESET is false."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = False
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 404
def test_full_reset_requires_confirmation(self, client):
"""Wrong confirmation string gets 400."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "WRONG"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 400
def test_reimport_requires_confirmation(self, client):
"""Wrong confirmation string gets 400."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
response = client.post(
"/api/admin/system-reset/reimport",
json={"confirmation": "WRONG"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 400
def test_status_endpoint(self, client):
"""The status endpoint returns feature-flag state."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
response = client.get("/api/admin/system-reset/status")
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
data = response.json()
assert "enabled" in data
assert "factory_reset_on_startup" in data
def test_full_reset_success(self, client):
"""Full reset succeeds with correct confirmation and feature flag."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
with patch(
"app.utils.system_reset.perform_full_reset", return_value={"database": {}, "filesystem": {}}
):
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_reimport_success(self, client):
"""Reimport succeeds with correct confirmation."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
with patch(
"app.utils.system_reset.perform_reset_and_reimport",
return_value={"database": {}, "filesystem": {}, "reimport": {"files_moved": 3}},
):
response = client.post(
"/api/admin/system-reset/reimport",
json={"confirmation": "REIMPORT"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
assert response.json()["status"] == "ok"
# ---------------------------------------------------------------------------
# Integration tests for the view
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSystemResetView:
"""Tests for the /admin/system-reset view."""
def test_view_redirects_when_disabled(self, client):
"""When ENABLE_FACTORY_RESET=False, accessing the page redirects away."""
with client:
client.cookies.set("session", "test")
with patch("app.views.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = False
response = client.get("/admin/system-reset", follow_redirects=False)
# Redirect to /settings (302) when disabled, or to login (302/307) when unauthenticated
assert response.status_code in (302, 307)
def test_view_requires_auth(self, client):
"""Unauthenticated users are redirected away from the page."""
with patch("app.views.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
mock_s.factory_reset_on_startup = False
response = client.get("/admin/system-reset", follow_redirects=False)
# Should redirect to login since there's no active session
assert response.status_code in (302, 307)
+478
View File
@@ -0,0 +1,478 @@
"""Tests for document translation feature.
Covers:
- translate_to_default_language Celery task
- /api/files/{id}/translate on-the-fly translation endpoint
- /api/files/{id}/translation/default stored translation endpoint
- /files/{id}/text/default-language view endpoint
- _resolve_default_language helper
"""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.models import FileRecord, UserProfile
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def file_with_ocr(db_session):
"""Create a FileRecord with OCR text and detected language."""
record = FileRecord(
filehash="abc123translationtest",
local_filename="/tmp/test_translate.pdf",
file_size=1024,
mime_type="application/pdf",
original_filename="test_translate.pdf",
ocr_text="Dies ist ein Testdokument in deutscher Sprache.",
detected_language="de",
)
db_session.add(record)
db_session.commit()
db_session.refresh(record)
return record
@pytest.fixture
def file_with_translation(db_session):
"""Create a FileRecord with a persisted default-language translation."""
record = FileRecord(
filehash="def456translationtest",
local_filename="/tmp/test_translated.pdf",
file_size=2048,
mime_type="application/pdf",
original_filename="test_translated.pdf",
ocr_text="Ceci est un document de test en français.",
detected_language="fr",
default_language_text="This is a test document in French.",
default_language_code="en",
)
db_session.add(record)
db_session.commit()
db_session.refresh(record)
return record
@pytest.fixture
def file_without_ocr(db_session):
"""Create a FileRecord without OCR text."""
record = FileRecord(
filehash="ghi789translationtest",
local_filename="/tmp/test_no_ocr.pdf",
file_size=512,
mime_type="application/pdf",
original_filename="test_no_ocr.pdf",
)
db_session.add(record)
db_session.commit()
db_session.refresh(record)
return record
@pytest.fixture
def user_profile_with_language(db_session):
"""Create a UserProfile with a custom default_document_language."""
profile = UserProfile(
user_id="test-user-lang",
default_document_language="de",
)
db_session.add(profile)
db_session.commit()
db_session.refresh(profile)
return profile
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
class TestFileRecordTranslationFields:
"""Verify that the new translation columns exist on FileRecord."""
@pytest.mark.unit
def test_detected_language_column(self, file_with_ocr):
assert file_with_ocr.detected_language == "de"
@pytest.mark.unit
def test_default_language_text_column(self, file_with_translation):
assert file_with_translation.default_language_text == "This is a test document in French."
@pytest.mark.unit
def test_default_language_code_column(self, file_with_translation):
assert file_with_translation.default_language_code == "en"
@pytest.mark.unit
def test_translation_columns_nullable(self, file_with_ocr):
"""Translation columns should be NULL when no translation exists."""
assert file_with_ocr.default_language_text is None
assert file_with_ocr.default_language_code is None
class TestUserProfileDefaultLanguage:
"""Verify UserProfile.default_document_language column."""
@pytest.mark.unit
def test_default_document_language_set(self, user_profile_with_language):
assert user_profile_with_language.default_document_language == "de"
@pytest.mark.unit
def test_default_document_language_nullable(self, db_session):
profile = UserProfile(user_id="test-user-no-lang")
db_session.add(profile)
db_session.commit()
db_session.refresh(profile)
assert profile.default_document_language is None
# ---------------------------------------------------------------------------
# Celery task tests
# ---------------------------------------------------------------------------
class TestTranslateToDefaultLanguageTask:
"""Tests for the translate_to_default_language Celery task."""
@pytest.mark.unit
@patch("app.tasks.translate_to_default_language.get_ai_provider")
def test_translate_stores_result(self, mock_provider_fn, db_session, file_with_ocr):
"""Successful translation is persisted to the FileRecord."""
mock_provider = MagicMock()
mock_provider.chat_completion.return_value = "This is a test document in German."
mock_provider_fn.return_value = mock_provider
from app.tasks.translate_to_default_language import translate_to_default_language
# Patch SessionLocal to use our test session
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=db_session)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_session_cls.return_value = mock_ctx
task = translate_to_default_language
# Call the underlying function (not .delay) for synchronous testing
result = task.apply(
args=[file_with_ocr.id, file_with_ocr.ocr_text, "de"],
kwargs={"owner_id": None},
).get()
assert result["status"] == "success"
assert result["target_language"] == "en"
# Verify it was stored
db_session.refresh(file_with_ocr)
assert file_with_ocr.default_language_text == "This is a test document in German."
assert file_with_ocr.default_language_code == "en"
assert file_with_ocr.detected_language == "de"
@pytest.mark.unit
@patch("app.tasks.translate_to_default_language.get_ai_provider")
def test_skip_when_already_in_target_language(self, mock_provider_fn, db_session, file_with_ocr):
"""No translation when document language matches default target."""
file_with_ocr.detected_language = "en"
db_session.commit()
from app.tasks.translate_to_default_language import translate_to_default_language
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=db_session)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_session_cls.return_value = mock_ctx
result = translate_to_default_language.apply(
args=[file_with_ocr.id, file_with_ocr.ocr_text, "en"],
).get()
assert result["status"] == "skipped"
mock_provider_fn.assert_not_called()
@pytest.mark.unit
def test_resolve_default_language_global(self):
"""Falls back to the global setting when no user profile override."""
from app.tasks.translate_to_default_language import _resolve_default_language
with patch("app.tasks.translate_to_default_language.settings") as mock_settings:
mock_settings.default_document_language = "en"
assert _resolve_default_language(None) == "en"
@pytest.mark.unit
def test_resolve_default_language_user_override(self, db_session, user_profile_with_language):
"""Per-user override is used when available."""
from app.tasks.translate_to_default_language import _resolve_default_language
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=db_session)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_session_cls.return_value = mock_ctx
result = _resolve_default_language("test-user-lang")
assert result == "de"
# ---------------------------------------------------------------------------
# API endpoint tests
# ---------------------------------------------------------------------------
class TestDefaultTranslationEndpoint:
"""Tests for GET /api/files/{id}/translation/default."""
@pytest.mark.integration
def test_returns_default_translation(self, client: TestClient, file_with_translation):
response = client.get(f"/api/files/{file_with_translation.id}/translation/default")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in French."
assert data["default_language_code"] == "en"
assert data["detected_language"] == "fr"
assert data["file_id"] == file_with_translation.id
@pytest.mark.integration
def test_404_when_no_translation(self, client: TestClient, file_with_ocr):
response = client.get(f"/api/files/{file_with_ocr.id}/translation/default")
assert response.status_code == 404
@pytest.mark.integration
def test_404_for_nonexistent_file(self, client: TestClient):
response = client.get("/api/files/999999/translation/default")
assert response.status_code == 404
class TestOnTheFlyTranslateEndpoint:
"""Tests for GET /api/files/{id}/translate?lang=xx."""
@pytest.mark.integration
@patch("app.api.translation.get_ai_provider")
def test_translate_on_the_fly(self, mock_provider_fn, client: TestClient, file_with_ocr):
mock_provider = MagicMock()
mock_provider.chat_completion.return_value = "This is a test document in German language."
mock_provider_fn.return_value = mock_provider
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=en")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in German language."
assert data["target_language"] == "en"
assert data["cached"] is False
@pytest.mark.integration
def test_returns_cached_default_language(self, client: TestClient, file_with_translation):
"""If the requested language matches the stored default, return cached text."""
response = client.get(f"/api/files/{file_with_translation.id}/translate?lang=en")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in French."
assert data["cached"] is True
@pytest.mark.integration
def test_returns_original_when_same_language(self, client: TestClient, file_with_ocr):
"""Return the original text when target matches detected language."""
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=de")
assert response.status_code == 200
data = response.json()
assert data["text"] == file_with_ocr.ocr_text
assert data["cached"] is True
@pytest.mark.integration
def test_400_when_no_ocr_text(self, client: TestClient, file_without_ocr):
response = client.get(f"/api/files/{file_without_ocr.id}/translate?lang=en")
assert response.status_code == 400
@pytest.mark.integration
def test_missing_lang_param(self, client: TestClient, file_with_ocr):
response = client.get(f"/api/files/{file_with_ocr.id}/translate")
assert response.status_code == 422 # validation error
@pytest.mark.integration
def test_404_for_nonexistent_file(self, client: TestClient):
response = client.get("/api/files/999999/translate?lang=en")
assert response.status_code == 404
@pytest.mark.integration
@patch("app.api.translation.get_ai_provider")
def test_502_on_provider_error(self, mock_provider_fn, client: TestClient, file_with_ocr):
mock_provider = MagicMock()
mock_provider.chat_completion.side_effect = RuntimeError("AI error")
mock_provider_fn.return_value = mock_provider
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=fr")
assert response.status_code == 502
# ---------------------------------------------------------------------------
# View endpoint tests
# ---------------------------------------------------------------------------
class TestDefaultLanguageTextView:
"""Tests for GET /files/{id}/text/default-language."""
@pytest.mark.integration
def test_returns_default_language_text(self, client: TestClient, file_with_translation):
response = client.get(f"/files/{file_with_translation.id}/text/default-language")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in French."
assert data["language_code"] == "en"
assert data["detected_language"] == "fr"
@pytest.mark.integration
def test_404_when_no_default_text(self, client: TestClient, file_with_ocr):
response = client.get(f"/files/{file_with_ocr.id}/text/default-language")
assert response.status_code == 404
@pytest.mark.integration
def test_404_for_nonexistent_file(self, client: TestClient):
response = client.get("/files/999999/text/default-language")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------
class TestDefaultDocumentLanguageConfig:
"""Verify the DEFAULT_DOCUMENT_LANGUAGE setting."""
@pytest.mark.unit
def test_default_value_is_english(self):
from app.config import settings
assert settings.default_document_language == "en"
# ---------------------------------------------------------------------------
# Profile API integration tests
# ---------------------------------------------------------------------------
class TestProfileDefaultDocumentLanguage:
"""Tests for default_document_language in the profile API."""
@pytest.fixture
def prof_engine(self):
"""In-memory SQLite engine for profile tests."""
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from app.database import Base
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
@pytest.fixture
def prof_session(self, prof_engine):
"""DB session for profile tests."""
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=self.prof_engine if hasattr(self, "prof_engine") else prof_engine)
session = Session()
yield session
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_profile_includes_default_document_language(self, prof_engine):
"""GET handler returns default_document_language in response."""
from unittest.mock import MagicMock
from sqlalchemy.orm import sessionmaker
from app.api.profile import get_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser", "email": "lang@test.com"}}
result = await get_profile(req, session)
assert hasattr(result, "default_document_language")
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_update_default_document_language(self, prof_engine):
"""PATCH handler updates default_document_language."""
from unittest.mock import MagicMock
from sqlalchemy.orm import sessionmaker
from app.api.profile import ProfileUpdateRequest, update_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser2", "email": "lang2@test.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(default_document_language="de")
result = await update_profile(body, req, resp, session)
assert result.default_document_language == "de"
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_clear_default_document_language(self, prof_engine):
"""Setting default_document_language to empty string clears it."""
from unittest.mock import MagicMock
from sqlalchemy.orm import sessionmaker
from app.api.profile import ProfileUpdateRequest, update_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser3", "email": "lang3@test.com"}}
resp = MagicMock()
# Set
body = ProfileUpdateRequest(default_document_language="fr")
await update_profile(body, req, resp, session)
# Clear
body = ProfileUpdateRequest(default_document_language="")
result = await update_profile(body, req, resp, session)
assert result.default_document_language is None
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_reject_invalid_default_document_language(self, prof_engine):
"""Invalid language codes are rejected with 422."""
from unittest.mock import MagicMock
from fastapi import HTTPException
from sqlalchemy.orm import sessionmaker
from app.api.profile import ProfileUpdateRequest, update_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser4", "email": "lang4@test.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(default_document_language="xx_invalid")
with pytest.raises(HTTPException) as exc_info:
await update_profile(body, req, resp, session)
assert exc_info.value.status_code == 422
session.close()
+89
View File
@@ -121,6 +121,95 @@ class TestExtractMetadataFromFile:
assert result == {}
def test_extract_metadata_from_pdf(self, tmp_path):
"""Test extracting metadata from a PDF file using pypdf when JSON is missing."""
import pypdf
file_path = tmp_path / "test.pdf"
# Create a test PDF with metadata
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "Test Title",
"/Author": "Test Author",
"/Subject": "Test Document",
"/Keywords": "test, metadata, pypdf",
}
)
with open(file_path, "wb") as f:
writer.write(f)
result = extract_metadata_from_file(str(file_path))
# Keys are mapped to application-specific names
assert result.get("filename") == "Test Title"
assert result.get("absender") == "Test Author"
assert result.get("document_type") == "Test Document"
assert result.get("tags") == "test, metadata, pypdf"
def test_extracts_embedded_metadata_from_pdf(self, tmp_path):
"""Test that embedded PDF metadata is mapped to application-specific keys."""
import pypdf
file_path = tmp_path / "mapped.pdf"
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "Invoice 2024",
"/Author": "Acme Corp",
"/Subject": "invoice",
"/Keywords": "finance, billing",
}
)
with open(file_path, "wb") as f:
writer.write(f)
result = extract_metadata_from_file(str(file_path))
# Verify the PDF-to-app key mapping
assert result["filename"] == "Invoice 2024"
assert result["absender"] == "Acme Corp"
assert result["document_type"] == "invoice"
assert result["tags"] == "finance, billing"
def test_pdf_metadata_does_not_overwrite_json(self, tmp_path):
"""Test that JSON metadata takes precedence over embedded PDF metadata."""
import pypdf
file_path = tmp_path / "dual.pdf"
# Create a PDF with embedded metadata
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "PDF Title",
"/Author": "PDF Author",
"/Subject": "PDF Subject",
"/Keywords": "pdf, keywords",
}
)
with open(file_path, "wb") as f:
writer.write(f)
# Create a companion JSON file that sets some overlapping fields
json_metadata = {"filename": "JSON Filename", "absender": "JSON Author"}
json_path = tmp_path / "dual.json"
json_path.write_text(json.dumps(json_metadata))
result = extract_metadata_from_file(str(file_path))
# JSON values must not be overwritten by PDF metadata
assert result["filename"] == "JSON Filename"
assert result["absender"] == "JSON Author"
# Fields missing from JSON are filled from PDF metadata
assert result["document_type"] == "PDF Subject"
assert result["tags"] == "pdf, keywords"
@pytest.mark.unit
class TestAttachLogo:
+554
View File
@@ -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"
+208 -105
View File
@@ -2,10 +2,10 @@
Tests for URL-based file upload functionality
"""
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
import requests
@pytest.mark.unit
@@ -165,17 +165,24 @@ class TestURLUploadValidation:
class TestURLUploadEndpoint:
"""Integration tests for URL upload endpoint"""
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_requires_authentication(self, mock_process_document, mock_requests_get, client, monkeypatch):
def test_process_url_requires_authentication(self, mock_process_document, mock_stream, client, monkeypatch):
"""Test that endpoint requires authentication when auth is enabled"""
# Mock successful download to prevent actual HTTP requests
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -192,17 +199,24 @@ class TestURLUploadEndpoint:
# (like no mocking). We're just checking the endpoint exists and is reachable.
assert response.status_code != 404 # Endpoint should exist
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_success(self, mock_process_document, mock_requests_get, client, tmp_path):
def test_process_url_success(self, mock_process_document, mock_stream, client, tmp_path):
"""Test successful URL processing"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content here"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content here"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -219,8 +233,8 @@ class TestURLUploadEndpoint:
assert "filename" in data
assert "size" in data
@patch("app.api.url_upload.requests.get")
def test_process_url_blocks_private_ip(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_blocks_private_ip(self, mock_stream, client):
"""Test that private IPs are blocked"""
response = client.post("/api/process-url", json={"url": "http://192.168.1.1/file.pdf"})
@@ -229,10 +243,10 @@ class TestURLUploadEndpoint:
assert "private/internal" in data["detail"]
# Should not make HTTP request
mock_requests_get.assert_not_called()
mock_stream.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_blocks_localhost(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_blocks_localhost(self, mock_stream, client):
"""Test that localhost is blocked"""
response = client.post("/api/process-url", json={"url": "http://localhost/file.pdf"})
@@ -241,10 +255,10 @@ class TestURLUploadEndpoint:
assert "private/internal" in data["detail"]
# Should not make HTTP request
mock_requests_get.assert_not_called()
mock_stream.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_blocks_metadata_endpoint(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_blocks_metadata_endpoint(self, mock_stream, client):
"""Test that cloud metadata endpoints are blocked"""
response = client.post("/api/process-url", json={"url": "http://169.254.169.254/latest/meta-data/"})
@@ -254,17 +268,20 @@ class TestURLUploadEndpoint:
assert "metadata" in data["detail"] or "private" in data["detail"]
# Should not make HTTP request
mock_requests_get.assert_not_called()
mock_stream.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_invalid_file_type(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_invalid_file_type(self, mock_stream, client):
"""Test that invalid file types are rejected"""
# Mock response with executable content-type
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/x-executable"}
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/malware.exe"})
@@ -272,21 +289,24 @@ class TestURLUploadEndpoint:
data = response.json()
assert "Unsupported file type" in data["detail"]
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_file_too_large_by_header(self, mock_process_document, mock_requests_get, client):
def test_process_url_file_too_large_by_header(self, mock_process_document, mock_stream, client):
"""Test that files too large are rejected based on Content-Length header"""
from app.config import settings
# Mock response with large content-length
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {
"Content-Type": "application/pdf",
"Content-Length": str(settings.max_upload_size + 1000),
}
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/huge.pdf"})
@@ -297,10 +317,10 @@ class TestURLUploadEndpoint:
# Should not process document
mock_process_document.delay.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_timeout_error(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_timeout_error(self, mock_stream, client):
"""Test handling of timeout errors"""
mock_requests_get.side_effect = requests.exceptions.Timeout("Request timed out")
mock_stream.side_effect = httpx.TimeoutException("Request timed out")
response = client.post("/api/process-url", json={"url": "https://example.com/slow.pdf"})
@@ -308,10 +328,10 @@ class TestURLUploadEndpoint:
data = response.json()
assert "timeout" in data["detail"].lower()
@patch("app.api.url_upload.requests.get")
def test_process_url_connection_error(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_connection_error(self, mock_stream, client):
"""Test handling of connection errors"""
mock_requests_get.side_effect = requests.exceptions.ConnectionError("Failed to connect")
mock_stream.side_effect = httpx.ConnectError("Failed to connect")
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
@@ -319,15 +339,16 @@ class TestURLUploadEndpoint:
data = response.json()
assert "connect" in data["detail"].lower()
@patch("app.api.url_upload.requests.get")
def test_process_url_http_error_404(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_http_error_404(self, mock_stream, client):
"""Test handling of HTTP 404 errors"""
mock_response = Mock()
# When raising HTTPStatusError, httpx requires request and response arguments
# For our code, we just need it to hit the exception handler and check status code
mock_request = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
"404 Not Found", response=mock_response
)
mock_requests_get.return_value = mock_response
mock_stream.side_effect = httpx.HTTPStatusError("404 Not Found", request=mock_request, response=mock_response)
response = client.post("/api/process-url", json={"url": "https://example.com/notfound.pdf"})
@@ -335,17 +356,24 @@ class TestURLUploadEndpoint:
data = response.json()
assert "HTTP error" in data["detail"]
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_with_custom_filename(self, mock_process_document, mock_requests_get, client, tmp_path):
def test_process_url_with_custom_filename(self, mock_process_document, mock_stream, client, tmp_path):
"""Test URL upload with custom filename"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -361,17 +389,24 @@ class TestURLUploadEndpoint:
data = response.json()
assert data["filename"] == "my-document.pdf"
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_extracts_filename_from_url(self, mock_process_document, mock_requests_get, client, tmp_path):
def test_process_url_extracts_filename_from_url(self, mock_process_document, mock_stream, client, tmp_path):
"""Test that filename is extracted from URL when not provided"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -386,9 +421,9 @@ class TestURLUploadEndpoint:
# Should extract "annual-report.pdf" from URL
assert "annual-report" in data["filename"]
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_file_size_during_download(self, mock_process_document, mock_requests_get, client):
def test_process_url_file_size_during_download(self, mock_process_document, mock_stream, client):
"""Test that file size is checked during download"""
from app.config import settings
@@ -396,12 +431,19 @@ class TestURLUploadEndpoint:
large_chunk = b"x" * (settings.max_upload_size + 1000)
# Mock response without Content-Length header
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf"} # No Content-Length
mock_response.iter_content = Mock(return_value=[large_chunk])
async def mock_aiter_bytes(chunk_size=None):
yield large_chunk
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/big.pdf"})
@@ -412,10 +454,10 @@ class TestURLUploadEndpoint:
# Should not process document
mock_process_document.delay.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_request_exception(self, mock_requests_get, client):
"""Test handling of generic RequestException"""
mock_requests_get.side_effect = requests.exceptions.RequestException("Generic request error")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_request_exception(self, mock_stream, client):
"""Test handling of generic RequestError"""
mock_stream.side_effect = httpx.RequestError("Generic request error")
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
@@ -423,16 +465,23 @@ class TestURLUploadEndpoint:
data = response.json()
assert "Failed to download file" in data["detail"]
@patch("app.api.url_upload.requests.get")
def test_process_url_oserror_during_save(self, mock_requests_get, client, tmp_path, monkeypatch):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_oserror_during_save(self, mock_stream, client, tmp_path, monkeypatch):
"""Test handling of OSError when saving file"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock workdir to a non-existent path to trigger OSError
from app.config import settings
@@ -450,17 +499,24 @@ class TestURLUploadEndpoint:
# Restore original workdir
monkeypatch.setattr(settings, "workdir", original_workdir)
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_unexpected_exception(self, mock_process_document, mock_requests_get, client):
def test_process_url_unexpected_exception(self, mock_process_document, mock_stream, client):
"""Test handling of unexpected exceptions"""
# Mock successful download but process_document.delay raises unexpected error
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock process_document.delay to raise an unexpected exception
mock_process_document.delay.side_effect = RuntimeError("Unexpected processing error")
@@ -471,17 +527,24 @@ class TestURLUploadEndpoint:
data = response.json()
assert "Unexpected error" in data["detail"]
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_filename_without_extension(self, mock_process_document, mock_requests_get, client):
def test_process_url_filename_without_extension(self, mock_process_document, mock_stream, client):
"""Test that files without extensions are handled correctly"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -496,17 +559,24 @@ class TestURLUploadEndpoint:
# Should still work, just without extension
assert data["task_id"] == "test-task-id"
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_empty_path_uses_download(self, mock_process_document, mock_requests_get, client):
def test_process_url_empty_path_uses_download(self, mock_process_document, mock_stream, client):
"""Test that empty URL path defaults to 'download' filename"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -560,17 +630,24 @@ class TestURLUploadEndpoint:
# Link-local address
assert is_private_ip("169.254.1.1") is True
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_sanitizes_dangerous_filename(self, mock_process_document, mock_requests_get, client):
def test_process_url_sanitizes_dangerous_filename(self, mock_process_document, mock_stream, client):
"""Test that dangerous filenames are sanitized"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -671,18 +748,25 @@ class TestURLUploadCoverageGaps:
assert validate_file_type("", "filename_without_extension") is False
@patch("app.api.url_upload.sanitize_filename", return_value="")
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_sanitize_filename_returns_empty(
self, mock_process_document, mock_requests_get, mock_sanitize, client
self, mock_process_document, mock_stream, mock_sanitize, client
):
"""Test that when sanitize_filename returns empty string, filename defaults to 'download' (line 177)"""
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
mock_task = Mock()
mock_task.id = "test-task-id-sanitize"
@@ -695,17 +779,26 @@ class TestURLUploadCoverageGaps:
# When sanitize_filename returns "", safe_filename defaults to "download"
assert data["filename"] == "download"
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_skips_empty_chunks(self, mock_process_document, mock_requests_get, client):
def test_process_url_skips_empty_chunks(self, mock_process_document, mock_stream, client):
"""Test that empty bytes chunks are skipped during download (line 234->233 branch)"""
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf"}
# Mix empty bytes (falsy) with real content - covers the `if chunk:` False branch
mock_response.iter_content = Mock(return_value=[b"", b"PDF content", b""])
async def mock_aiter_bytes(chunk_size=None):
yield b""
yield b"PDF content"
yield b""
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
mock_task = Mock()
mock_task.id = "test-task-id-chunks"
@@ -719,9 +812,9 @@ class TestURLUploadCoverageGaps:
@patch("app.api.url_upload.os.remove")
@patch("app.api.url_upload.os.path.exists", return_value=True)
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_oserror_cleanup_removes_existing_file(
self, mock_requests_get, mock_exists, mock_remove, client, tmp_path, monkeypatch
self, mock_stream, mock_exists, mock_remove, client, tmp_path, monkeypatch
):
"""Test OSError handler removes the partial file when it exists (line 285)"""
import os
@@ -735,12 +828,19 @@ class TestURLUploadCoverageGaps:
monkeypatch.setattr(settings, "workdir", str(non_existent))
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
@@ -750,14 +850,17 @@ class TestURLUploadCoverageGaps:
mock_remove.assert_called_once()
@patch("app.api.url_upload.validate_file_type", side_effect=ValueError("unexpected internal error"))
@patch("app.api.url_upload.requests.get")
def test_process_url_unexpected_exception_with_no_file_created(self, mock_requests_get, mock_validate, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_unexpected_exception_with_no_file_created(self, mock_stream, mock_validate, client):
"""Test unexpected exception before target_path is assigned; no file cleanup attempted (line 291->293)"""
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf"}
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
+18
View File
@@ -129,6 +129,15 @@ class TestSetupWizardUndoSkip:
class TestDropboxSaveSettingsDbPersist:
"""Unit tests for save_dropbox_settings DB persistence."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.dropbox import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("app.api.dropbox.settings")
@patch("app.api.dropbox.notify_settings_updated")
@patch("app.api.dropbox.save_setting_to_db")
@@ -268,6 +277,15 @@ class TestGoogleDriveUpdateSettingsDbPersist:
class TestOneDriveSaveSettingsDbPersist:
"""Unit tests for save_onedrive_settings DB persistence."""
@pytest.fixture(autouse=True)
def _admin_override(self):
from app.api.onedrive import _require_admin
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
yield
fastapi_app.dependency_overrides.pop(_require_admin, None)
@patch("app.api.onedrive.settings")
@patch("app.api.onedrive.notify_settings_updated")
@patch("app.api.onedrive.save_setting_to_db")