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:
@@ -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 = {
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
):
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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()."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"})
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user