fix: resolve merge conflicts with main branch
Merge origin/main into feature branch, resolving 3 conflicts: - app/api/__init__.py: add classification_rules_router alongside new routers from main (audit_logs, i18n, mobile, compliance, translation) - app/models.py: keep ClassificationRuleModel alongside new models from main (MobileDevice, ComplianceTemplate, PipelineRoutingRule) - tests/conftest.py: import both ClassificationRuleModel and new models from main (AuditLog, ComplianceTemplate) Also renumber migration from 027 to 037 to chain from the latest migration on main (036_add_document_translation_fields). Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -61,10 +61,13 @@ from app.main import app as fastapi_app # noqa: E402
|
||||
# Import models to register them with SQLAlchemy Base
|
||||
from app.models import ( # noqa: F401, E402
|
||||
ApiToken,
|
||||
AuditLog,
|
||||
ClassificationRuleModel,
|
||||
ComplianceTemplate,
|
||||
DocumentMetadata,
|
||||
FileRecord,
|
||||
Pipeline,
|
||||
PipelineRoutingRule,
|
||||
PipelineStep,
|
||||
ProcessingLog,
|
||||
SavedSearch,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -116,9 +116,13 @@ class TestProtectedAPIEndpoints:
|
||||
}
|
||||
}
|
||||
|
||||
# Mock DB: no UserProfile found (no custom avatar)
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
import asyncio
|
||||
|
||||
result = asyncio.run(whoami_handler(mock_request))
|
||||
result = asyncio.run(whoami_handler(mock_request, mock_db))
|
||||
|
||||
assert result["id"] == "test123"
|
||||
assert result["email"] == "test@example.com"
|
||||
@@ -135,9 +139,10 @@ class TestProtectedAPIEndpoints:
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {}
|
||||
mock_db = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(whoami_handler(mock_request))
|
||||
asyncio.run(whoami_handler(mock_request, mock_db))
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Not logged in" in exc_info.value.detail
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -7,9 +7,9 @@ Targets the remaining uncovered branches from the 97.03% baseline:
|
||||
- 214 : test_google_drive_token — generic connection error (not token-related)
|
||||
- 302->306: get_google_drive_token_info — credentials already valid (no refresh)
|
||||
- 307->318: get_google_drive_token_info — credentials have no expiry
|
||||
- 395->397: save_dropbox_settings — refresh_token falsy inside use_oauth block
|
||||
- 449->451: save_dropbox_settings — refresh_token falsy in in-memory update
|
||||
- 468->470: save_dropbox_settings — folder_id falsy in db-persist block
|
||||
- 395->397: save_google_drive_settings — refresh_token falsy inside use_oauth block
|
||||
- 449->451: save_google_drive_settings — refresh_token falsy in in-memory update
|
||||
- 468->470: save_google_drive_settings — folder_id falsy in db-persist block
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@@ -152,10 +152,10 @@ class TestGetTokenInfoCredentialsBranches:
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSaveGoogleDriveSettingsFalsyFields:
|
||||
"""Cover branches 395->397, 449->451, 468->470 in save_dropbox_settings.
|
||||
"""Cover branches 395->397, 449->451, 468->470 in save_google_drive_settings.
|
||||
|
||||
Note: the Google Drive save endpoint is named save_dropbox_settings in the
|
||||
source (app/api/google_drive.py) due to an existing naming inconsistency.
|
||||
Note: the Google Drive save endpoint is named save_google_drive_settings in the
|
||||
source (app/api/google_drive.py).
|
||||
"""
|
||||
|
||||
@patch("app.api.google_drive.settings")
|
||||
@@ -167,7 +167,7 @@ class TestSaveGoogleDriveSettingsFalsyFields:
|
||||
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
|
||||
from app.api.google_drive import save_dropbox_settings
|
||||
from app.api.google_drive import save_google_drive_settings
|
||||
|
||||
mock_request = MagicMock(spec=StarletteRequest)
|
||||
mock_request.session = {}
|
||||
@@ -175,7 +175,7 @@ class TestSaveGoogleDriveSettingsFalsyFields:
|
||||
|
||||
with patch("app.api.google_drive.save_setting_to_db"):
|
||||
with patch("app.api.google_drive.notify_settings_updated"):
|
||||
result = await save_dropbox_settings(
|
||||
result = await save_google_drive_settings(
|
||||
request=mock_request,
|
||||
refresh_token="", # falsy → branches 395->397 and 449->451
|
||||
client_id="cid",
|
||||
|
||||
@@ -969,7 +969,7 @@ class TestConnectionTestEndpoint:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
assert "localhost" in data["message"].lower()
|
||||
assert "internal" in data["message"].lower() or "private" in data["message"].lower()
|
||||
|
||||
def test_test_webdav_blocks_file_scheme(self, int_client):
|
||||
"""WebDAV test blocks file:// scheme."""
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
"""Tests for the mobile API endpoints (app/api/mobile.py)."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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, MobileDevice
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OWNER = "mobile_user@example.com"
|
||||
_OTHER_OWNER = "other@example.com"
|
||||
_EXPO_TOKEN = "ExponentPushToken[test-token-abc123]"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mob_engine():
|
||||
"""In-memory SQLite engine for mobile 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 mob_session(mob_engine):
|
||||
"""DB session scoped to one test."""
|
||||
Session = sessionmaker(bind=mob_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def _make_client(mob_engine, owner_id: str = _OWNER) -> TestClient:
|
||||
"""Return a TestClient with *owner_id* injected as the authenticated user."""
|
||||
from app.api.mobile import _get_owner_id
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=mob_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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – /mobile/generate-token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGenerateMobileToken:
|
||||
"""Tests for POST /api/mobile/generate-token."""
|
||||
|
||||
def test_generate_token_success(self, mob_engine):
|
||||
"""Generating a mobile token returns a token string and metadata."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/generate-token",
|
||||
json={"device_name": "John's iPhone"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["token"].startswith("de_")
|
||||
assert data["token_id"] > 0
|
||||
assert "Mobile App" in data["name"]
|
||||
assert "John's iPhone" in data["name"]
|
||||
assert "created_at" in data
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_generate_token_default_device_name(self, mob_engine):
|
||||
"""A default device name is used if none is provided."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post("/api/mobile/generate-token", json={})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "Mobile App" in data["name"]
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_generate_token_persisted_in_db(self, mob_engine, mob_session):
|
||||
"""The generated token is stored in the api_tokens table."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/generate-token",
|
||||
json={"device_name": "Test Device"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
token_id = resp.json()["token_id"]
|
||||
|
||||
db_token = mob_session.get(ApiToken, token_id)
|
||||
assert db_token is not None
|
||||
assert db_token.owner_id == _OWNER
|
||||
assert "Mobile App" in db_token.name
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_generate_token_unauthenticated(self, mob_engine):
|
||||
"""Unauthenticated requests are rejected with 401."""
|
||||
from app.api.mobile import _get_owner_id
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=mob_engine)
|
||||
|
||||
def _override_get_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _raise_401():
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
app.dependency_overrides[_get_owner_id] = _raise_401
|
||||
|
||||
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
|
||||
try:
|
||||
resp = client.post("/api/mobile/generate-token", json={"device_name": "Test"})
|
||||
assert resp.status_code == 401
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – /mobile/register-device
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRegisterDevice:
|
||||
"""Tests for POST /api/mobile/register-device."""
|
||||
|
||||
def test_register_new_device(self, mob_engine, mob_session):
|
||||
"""Registering a new device persists it in mobile_devices."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={
|
||||
"push_token": _EXPO_TOKEN,
|
||||
"device_name": "Test iPhone",
|
||||
"platform": "ios",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["id"] > 0
|
||||
assert data["platform"] == "ios"
|
||||
assert data["is_active"] is True
|
||||
assert "ExponentPushToken" in data["push_token_preview"]
|
||||
|
||||
device = mob_session.get(MobileDevice, data["id"])
|
||||
assert device is not None
|
||||
assert device.push_token == _EXPO_TOKEN
|
||||
assert device.owner_id == _OWNER
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_register_same_token_is_idempotent(self, mob_engine, mob_session):
|
||||
"""Re-registering the same token reactivates the existing record."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp1 = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={"push_token": _EXPO_TOKEN, "platform": "ios"},
|
||||
)
|
||||
assert resp1.status_code == 201
|
||||
id1 = resp1.json()["id"]
|
||||
|
||||
resp2 = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={"push_token": _EXPO_TOKEN, "device_name": "Updated Name", "platform": "ios"},
|
||||
)
|
||||
assert resp2.status_code == 201
|
||||
id2 = resp2.json()["id"]
|
||||
|
||||
assert id1 == id2 # Same record reused
|
||||
|
||||
devices = mob_session.query(MobileDevice).filter(MobileDevice.owner_id == _OWNER).all()
|
||||
assert len(devices) == 1
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_register_invalid_platform(self, mob_engine):
|
||||
"""An invalid platform value is rejected with 422."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={"push_token": _EXPO_TOKEN, "platform": "windows"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_register_android_device(self, mob_engine):
|
||||
"""Android devices can be registered."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/mobile/register-device",
|
||||
json={
|
||||
"push_token": "ExponentPushToken[android-token-xyz]",
|
||||
"device_name": "Pixel 8",
|
||||
"platform": "android",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["platform"] == "android"
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – /mobile/devices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListDevices:
|
||||
"""Tests for GET /api/mobile/devices."""
|
||||
|
||||
def test_list_devices_empty(self, mob_engine):
|
||||
"""An empty list is returned when no devices are registered."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/devices")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_list_devices_returns_own_devices_only(self, mob_engine, mob_session):
|
||||
"""Only the current user's devices are returned."""
|
||||
from app.main import app
|
||||
|
||||
# Add devices for two different owners directly
|
||||
mob_session.add(
|
||||
MobileDevice(
|
||||
owner_id=_OWNER,
|
||||
push_token="ExponentPushToken[owner-token-12345]",
|
||||
platform="ios",
|
||||
)
|
||||
)
|
||||
mob_session.add(
|
||||
MobileDevice(
|
||||
owner_id=_OTHER_OWNER,
|
||||
push_token="ExponentPushToken[other-token-67890]",
|
||||
platform="android",
|
||||
)
|
||||
)
|
||||
mob_session.commit()
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/devices")
|
||||
assert resp.status_code == 200
|
||||
devices = resp.json()
|
||||
assert len(devices) == 1
|
||||
# The push_token_preview is the first 20 chars + "…"
|
||||
assert devices[0]["push_token_preview"].startswith("ExponentPushToken[ow")
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – DELETE /mobile/devices/{device_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeactivateDevice:
|
||||
"""Tests for DELETE /api/mobile/devices/{device_id}."""
|
||||
|
||||
def test_deactivate_own_device(self, mob_engine, mob_session):
|
||||
"""Deactivating a device sets is_active to False."""
|
||||
from app.main import app
|
||||
|
||||
device = MobileDevice(
|
||||
owner_id=_OWNER,
|
||||
push_token=_EXPO_TOKEN,
|
||||
platform="ios",
|
||||
is_active=True,
|
||||
)
|
||||
mob_session.add(device)
|
||||
mob_session.commit()
|
||||
mob_session.refresh(device)
|
||||
device_id = device.id
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.delete(f"/api/mobile/devices/{device_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
mob_session.expire_all()
|
||||
updated = mob_session.get(MobileDevice, device_id)
|
||||
assert updated is not None
|
||||
assert updated.is_active is False
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session):
|
||||
"""Attempting to deactivate another user's device returns 404."""
|
||||
from app.main import app
|
||||
|
||||
device = MobileDevice(
|
||||
owner_id=_OTHER_OWNER,
|
||||
push_token="ExponentPushToken[other-token]",
|
||||
platform="ios",
|
||||
is_active=True,
|
||||
)
|
||||
mob_session.add(device)
|
||||
mob_session.commit()
|
||||
mob_session.refresh(device)
|
||||
device_id = device.id
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.delete(f"/api/mobile/devices/{device_id}")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_deactivate_nonexistent_device_returns_404(self, mob_engine):
|
||||
"""Deactivating a device that does not exist returns 404."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.delete("/api/mobile/devices/99999")
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – /mobile/whoami
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWhoAmI:
|
||||
"""Tests for GET /api/mobile/whoami."""
|
||||
|
||||
def test_whoami_with_no_profile(self, mob_engine):
|
||||
"""Returns owner_id and inferred email even when no UserProfile exists."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/whoami")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["owner_id"] == _OWNER
|
||||
assert data["display_name"] is None
|
||||
# _OWNER contains "@" so email is inferred from owner_id
|
||||
assert data["email"] == _OWNER
|
||||
assert data["avatar_url"] is not None # Gravatar URL from email
|
||||
assert data["is_admin"] is False
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
def test_whoami_with_profile(self, mob_engine, mob_session):
|
||||
"""Returns full profile data when a UserProfile record exists."""
|
||||
from app.main import app
|
||||
from app.models import UserProfile
|
||||
|
||||
profile = UserProfile(
|
||||
user_id=_OWNER,
|
||||
display_name="Alice Test",
|
||||
)
|
||||
mob_session.add(profile)
|
||||
mob_session.commit()
|
||||
|
||||
client = _make_client(mob_engine)
|
||||
try:
|
||||
resp = client.get("/api/mobile/whoami")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["owner_id"] == _OWNER
|
||||
assert data["display_name"] == "Alice Test"
|
||||
# owner_id contains "@" so email is inferred from it
|
||||
assert data["email"] == _OWNER
|
||||
assert data["avatar_url"] is not None # Gravatar URL
|
||||
assert data["is_admin"] is False
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – push notification utility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPushNotificationUtility:
|
||||
"""Tests for app/utils/push_notification.py."""
|
||||
|
||||
def test_send_expo_push_empty_tokens(self):
|
||||
"""send_expo_push_notification with no tokens returns empty list."""
|
||||
from app.utils.push_notification import send_expo_push_notification
|
||||
|
||||
result = send_expo_push_notification([], "Title", "Body")
|
||||
assert result == []
|
||||
|
||||
def test_send_expo_push_calls_expo_api(self):
|
||||
"""send_expo_push_notification POSTs to the Expo push API."""
|
||||
from app.utils.push_notification import send_expo_push_notification
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"data": [{"status": "ok"}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("app.utils.push_notification.httpx.post", return_value=mock_response) as mock_post:
|
||||
result = send_expo_push_notification(
|
||||
tokens=["ExponentPushToken[abc]"],
|
||||
title="Test",
|
||||
body="Message",
|
||||
)
|
||||
|
||||
mock_post.assert_called_once()
|
||||
call_kwargs = mock_post.call_args
|
||||
assert "exp.host" in call_kwargs[0][0]
|
||||
payload = call_kwargs[1]["json"]
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["to"] == "ExponentPushToken[abc]"
|
||||
assert payload[0]["title"] == "Test"
|
||||
|
||||
def test_send_push_to_owner_no_devices(self, mob_engine):
|
||||
"""send_push_to_owner silently does nothing when no devices are registered."""
|
||||
from app.utils.push_notification import send_push_to_owner
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.filter.return_value.all.return_value = []
|
||||
mock_session.close = MagicMock()
|
||||
|
||||
with patch("app.utils.push_notification.SessionLocal", return_value=mock_session):
|
||||
with patch("app.utils.push_notification.send_expo_push_notification") as mock_send:
|
||||
send_push_to_owner("user@example.com", "Title", "Body")
|
||||
mock_send.assert_not_called()
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
def test_send_push_to_owner_with_devices(self):
|
||||
"""send_push_to_owner calls send_expo_push_notification with device tokens."""
|
||||
from app.utils.push_notification import send_push_to_owner
|
||||
|
||||
fake_device = MagicMock()
|
||||
fake_device.push_token = "ExponentPushToken[device1]"
|
||||
fake_device.is_active = True
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.query.return_value.filter.return_value.all.return_value = [fake_device]
|
||||
mock_session.close = MagicMock()
|
||||
|
||||
with patch("app.utils.push_notification.SessionLocal", return_value=mock_session):
|
||||
with patch("app.utils.push_notification.send_expo_push_notification") as mock_send:
|
||||
mock_send.return_value = [{"status": "ok"}]
|
||||
send_push_to_owner("user@example.com", "Processed!", "Your doc is ready.")
|
||||
|
||||
mock_send.assert_called_once()
|
||||
call_kwargs = mock_send.call_args[1]
|
||||
assert "ExponentPushToken[device1]" in call_kwargs["tokens"]
|
||||
@@ -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,597 @@
|
||||
"""Tests for app/api/profile.py — user self-service profile API.
|
||||
|
||||
Unit tests call handler functions directly with mock request objects.
|
||||
Integration tests use a dedicated TestClient with DB override.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
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 LocalUser, UserProfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Minimal valid 1×1 PNG image (base64-encoded) used across avatar upload tests
|
||||
_MINIMAL_VALID_PNG_BASE64 = (
|
||||
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/Z+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=="
|
||||
)
|
||||
_MINIMAL_VALID_PNG_BYTES = base64.b64decode(_MINIMAL_VALID_PNG_BASE64)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def prof_engine():
|
||||
"""In-memory SQLite engine scoped to one test."""
|
||||
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 prof_session(prof_engine):
|
||||
"""DB session for one profile test."""
|
||||
Session = sessionmaker(bind=prof_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def prof_client(prof_engine):
|
||||
"""TestClient with the in-memory DB injected."""
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=prof_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
|
||||
yield c
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — helper functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGravatarUrl:
|
||||
"""Tests for the _gravatar_url helper."""
|
||||
|
||||
def test_returns_gravatar_for_valid_email(self):
|
||||
from app.api.profile import _gravatar_url
|
||||
|
||||
url = _gravatar_url("Test@Example.COM")
|
||||
assert url.startswith("https://www.gravatar.com/avatar/")
|
||||
assert url.endswith("?d=identicon")
|
||||
|
||||
def test_fallback_for_none_email(self):
|
||||
from app.api.profile import _gravatar_url
|
||||
|
||||
url = _gravatar_url(None)
|
||||
assert url.startswith("https://www.gravatar.com/avatar/")
|
||||
assert url.endswith("?d=identicon")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetUserId:
|
||||
"""Tests for the _get_user_id helper."""
|
||||
|
||||
def test_extracts_sub(self):
|
||||
from app.api.profile import _get_user_id
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"sub": "sub-123", "email": "a@b.com"}}
|
||||
assert _get_user_id(req) == "sub-123"
|
||||
|
||||
def test_extracts_preferred_username_fallback(self):
|
||||
from app.api.profile import _get_user_id
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "alice", "email": "a@b.com"}}
|
||||
assert _get_user_id(req) == "alice"
|
||||
|
||||
def test_extracts_email_fallback(self):
|
||||
from app.api.profile import _get_user_id
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"email": "a@b.com"}}
|
||||
assert _get_user_id(req) == "a@b.com"
|
||||
|
||||
def test_raises_401_when_no_session_user(self):
|
||||
from app.api.profile import _get_user_id
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_get_user_id(req)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
def test_raises_401_when_no_identifier(self):
|
||||
from app.api.profile import _get_user_id
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"name": "Someone"}}
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_get_user_id(req)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — GET /api/profile handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetProfileHandler:
|
||||
"""Unit tests for the get_profile endpoint handler."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_profile_from_db(self, prof_session):
|
||||
"""get_profile reads from DB and returns correct data."""
|
||||
from app.api.profile import get_profile
|
||||
|
||||
profile = UserProfile(
|
||||
user_id="alice",
|
||||
display_name="Alice",
|
||||
preferred_language="fr",
|
||||
preferred_theme="dark",
|
||||
)
|
||||
prof_session.add(profile)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "alice", "email": "alice@example.com"}}
|
||||
|
||||
result = await get_profile(req, prof_session)
|
||||
assert result.user_id == "alice"
|
||||
assert result.display_name == "Alice"
|
||||
assert result.preferred_language == "fr"
|
||||
assert result.preferred_theme == "dark"
|
||||
assert result.avatar_url.startswith("https://www.gravatar.com/avatar/")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_custom_avatar_when_stored(self, prof_session):
|
||||
"""get_profile returns the data: URI when avatar_data is set."""
|
||||
from app.api.profile import get_profile
|
||||
|
||||
profile = UserProfile(
|
||||
user_id="bob",
|
||||
avatar_data="data:image/png;base64,abc",
|
||||
)
|
||||
prof_session.add(profile)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "bob", "email": "bob@example.com"}}
|
||||
|
||||
result = await get_profile(req, prof_session)
|
||||
assert result.avatar_url == "data:image/png;base64,abc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_profile_if_missing(self, prof_session):
|
||||
"""get_profile creates a stub profile row when none exists."""
|
||||
from app.api.profile import get_profile
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "newbie", "email": "newbie@example.com"}}
|
||||
|
||||
result = await get_profile(req, prof_session)
|
||||
assert result.user_id == "newbie"
|
||||
row = prof_session.query(UserProfile).filter_by(user_id="newbie").first()
|
||||
assert row is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — PATCH /api/profile handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUpdateProfileHandler:
|
||||
"""Unit tests for the update_profile endpoint handler."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_display_name(self, prof_session):
|
||||
"""update_profile updates display_name."""
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "carol", "email": "carol@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(display_name="Carol Smith")
|
||||
result = await update_profile(body, req, resp, prof_session)
|
||||
assert result.display_name == "Carol Smith"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_language(self, prof_session):
|
||||
"""update_profile updates preferred_language."""
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "dave", "email": "dave@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_language="de")
|
||||
result = await update_profile(body, req, resp, prof_session)
|
||||
assert result.preferred_language == "de"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_language_syncs_session_and_cookie(self, prof_session):
|
||||
"""update_profile syncs language to session and cookie."""
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
session = {"user": {"preferred_username": "dave2", "email": "dave2@example.com"}}
|
||||
req = MagicMock()
|
||||
req.session = session
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_language="fr")
|
||||
await update_profile(body, req, resp, prof_session)
|
||||
|
||||
assert session["preferred_language"] == "fr"
|
||||
resp.set_cookie.assert_called_once()
|
||||
cookie_kwargs = resp.set_cookie.call_args
|
||||
assert cookie_kwargs.kwargs["key"] == "docuelevate_lang"
|
||||
assert cookie_kwargs.kwargs["value"] == "fr"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_theme(self, prof_session):
|
||||
"""update_profile updates preferred_theme."""
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "eve", "email": "eve@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_theme="light")
|
||||
result = await update_profile(body, req, resp, prof_session)
|
||||
assert result.preferred_theme == "light"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_invalid_language(self, prof_session):
|
||||
"""update_profile raises 422 for unsupported language code."""
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "frank", "email": "frank@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_language="xx")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_profile(body, req, resp, prof_session)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_invalid_theme(self, prof_session):
|
||||
"""update_profile raises 422 for invalid theme value."""
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "grace", "email": "grace@example.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(preferred_theme="rainbow")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await update_profile(body, req, resp, prof_session)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — _hydrate_language_from_db (views/base.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHydrateLanguageFromDb:
|
||||
"""Tests for session hydration of preferred_language from DB."""
|
||||
|
||||
def test_hydrates_language_into_session(self, prof_session):
|
||||
"""When DB has a preferred_language, it should be set in the session."""
|
||||
from app.views.base import _hydrate_language_from_db
|
||||
|
||||
# Create a profile with a preferred_language
|
||||
profile = UserProfile(user_id="hydrate_user", preferred_language="fr")
|
||||
prof_session.add(profile)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {}
|
||||
session_user = {"preferred_username": "hydrate_user"}
|
||||
|
||||
# Patch SessionLocal to return our test session
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("app.views.base.SessionLocal", return_value=prof_session):
|
||||
_hydrate_language_from_db(req, session_user)
|
||||
|
||||
assert req.session.get("preferred_language") == "fr"
|
||||
|
||||
def test_no_hydration_when_no_db_preference(self, prof_session):
|
||||
"""When DB has no preferred_language, session should remain empty."""
|
||||
from app.views.base import _hydrate_language_from_db
|
||||
|
||||
profile = UserProfile(user_id="no_pref_user")
|
||||
prof_session.add(profile)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {}
|
||||
session_user = {"preferred_username": "no_pref_user"}
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("app.views.base.SessionLocal", return_value=prof_session):
|
||||
_hydrate_language_from_db(req, session_user)
|
||||
|
||||
assert "preferred_language" not in req.session
|
||||
|
||||
def test_no_hydration_when_no_user_id(self, prof_session):
|
||||
"""When session_user has no identifiable user_id, do nothing."""
|
||||
from app.views.base import _hydrate_language_from_db
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {}
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("app.views.base.SessionLocal", return_value=prof_session):
|
||||
_hydrate_language_from_db(req, {})
|
||||
|
||||
assert "preferred_language" not in req.session
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — POST /api/profile/avatar handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadAvatarHandler:
|
||||
"""Unit tests for the upload_avatar endpoint handler."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stores_base64_data_url(self, prof_session):
|
||||
"""upload_avatar stores the image as a data: URI."""
|
||||
from app.api.profile import upload_avatar
|
||||
|
||||
upload = MagicMock()
|
||||
upload.content_type = "image/png"
|
||||
upload.size = len(_MINIMAL_VALID_PNG_BYTES)
|
||||
upload.read = AsyncMock(return_value=_MINIMAL_VALID_PNG_BYTES)
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "avataruser", "email": "av@example.com"}}
|
||||
|
||||
result = await upload_avatar(req, prof_session, upload)
|
||||
assert result["avatar_url"].startswith("data:image/png;base64,")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_unsupported_mime(self, prof_session):
|
||||
"""upload_avatar raises 415 for non-image content types."""
|
||||
from app.api.profile import upload_avatar
|
||||
|
||||
upload = MagicMock()
|
||||
upload.content_type = "application/pdf"
|
||||
upload.size = 4
|
||||
upload.read = AsyncMock(return_value=b"%PDF")
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "pdfuser", "email": "pdf@example.com"}}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await upload_avatar(req, prof_session, upload)
|
||||
assert exc.value.status_code == 415
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_oversized_image(self, prof_session):
|
||||
"""upload_avatar raises 413 when image exceeds 2 MB."""
|
||||
from app.api.profile import upload_avatar
|
||||
|
||||
big_size = 2 * 1024 * 1024 + 1
|
||||
|
||||
upload = MagicMock()
|
||||
upload.content_type = "image/png"
|
||||
upload.size = big_size # triggers early size check
|
||||
upload.read = AsyncMock(return_value=b"x" * big_size)
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "biguser", "email": "big@example.com"}}
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await upload_avatar(req, prof_session, upload)
|
||||
assert exc.value.status_code == 413
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — DELETE /api/profile/avatar handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeleteAvatarHandler:
|
||||
"""Unit tests for the delete_avatar endpoint handler."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clears_avatar_data(self, prof_session):
|
||||
"""delete_avatar removes avatar_data and returns a Gravatar URL."""
|
||||
from app.api.profile import delete_avatar
|
||||
|
||||
profile = UserProfile(
|
||||
user_id="delavatar",
|
||||
avatar_data="data:image/png;base64,abc",
|
||||
)
|
||||
prof_session.add(profile)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "delavatar", "email": "del@example.com"}}
|
||||
|
||||
result = await delete_avatar(req, prof_session)
|
||||
assert result["avatar_url"].startswith("https://www.gravatar.com/avatar/")
|
||||
|
||||
row = prof_session.query(UserProfile).filter_by(user_id="delavatar").first()
|
||||
assert row.avatar_data is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — POST /api/profile/change-password handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestChangePasswordHandler:
|
||||
"""Unit tests for the change_password endpoint handler."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_non_local_user(self, prof_session):
|
||||
"""change_password raises 403 for OAuth-only accounts."""
|
||||
from app.api.profile import ChangePasswordRequest, change_password
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "oauthonly", "email": "oauth@example.com"}}
|
||||
|
||||
body = ChangePasswordRequest(
|
||||
current_password="old",
|
||||
new_password="newpassword1",
|
||||
new_password_confirm="newpassword1",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await change_password(body, req, prof_session)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_wrong_current_password(self, prof_session):
|
||||
"""change_password raises 403 when current password is wrong."""
|
||||
from app.api.profile import ChangePasswordRequest, change_password
|
||||
from app.utils.local_auth import hash_password
|
||||
|
||||
local_user = LocalUser(
|
||||
email="local@example.com",
|
||||
username="localwrong",
|
||||
hashed_password=hash_password("correctpassword"),
|
||||
is_active=True,
|
||||
)
|
||||
prof_session.add(local_user)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "localwrong", "email": "local@example.com"}}
|
||||
|
||||
body = ChangePasswordRequest(
|
||||
current_password="wrongpassword",
|
||||
new_password="newpassword1",
|
||||
new_password_confirm="newpassword1",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await change_password(body, req, prof_session)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_password_mismatch(self, prof_session):
|
||||
"""change_password raises 422 when new passwords do not match."""
|
||||
from app.api.profile import ChangePasswordRequest, change_password
|
||||
from app.utils.local_auth import hash_password
|
||||
|
||||
local_user = LocalUser(
|
||||
email="mismatch@example.com",
|
||||
username="mismatchpw",
|
||||
hashed_password=hash_password("currentpw"),
|
||||
is_active=True,
|
||||
)
|
||||
prof_session.add(local_user)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "mismatchpw", "email": "mismatch@example.com"}}
|
||||
|
||||
body = ChangePasswordRequest(
|
||||
current_password="currentpw",
|
||||
new_password="newpassword1",
|
||||
new_password_confirm="differentpassword",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await change_password(body, req, prof_session)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changes_password_successfully(self, prof_session):
|
||||
"""change_password updates hashed_password for correct input."""
|
||||
from app.api.profile import ChangePasswordRequest, change_password
|
||||
from app.utils.local_auth import hash_password, verify_password
|
||||
|
||||
local_user = LocalUser(
|
||||
email="success@example.com",
|
||||
username="successpw",
|
||||
hashed_password=hash_password("oldpassword"),
|
||||
is_active=True,
|
||||
)
|
||||
prof_session.add(local_user)
|
||||
prof_session.commit()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "successpw", "email": "success@example.com"}}
|
||||
|
||||
body = ChangePasswordRequest(
|
||||
current_password="oldpassword",
|
||||
new_password="newpassword1",
|
||||
new_password_confirm="newpassword1",
|
||||
)
|
||||
result = await change_password(body, req, prof_session)
|
||||
assert "successfully" in result["detail"].lower()
|
||||
|
||||
updated_user = prof_session.query(LocalUser).filter_by(username="successpw").first()
|
||||
assert verify_password("newpassword1", updated_user.hashed_password)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — HTTP endpoint registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestProfileEndpoints:
|
||||
"""Verify profile endpoints are registered and reachable."""
|
||||
|
||||
def test_get_profile_without_session_returns_401(self, prof_client):
|
||||
"""GET /api/profile returns 401 when no user in session."""
|
||||
response = prof_client.get("/api/profile")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_patch_profile_without_session_returns_401(self, prof_client):
|
||||
"""PATCH /api/profile returns 401 when no user in session."""
|
||||
response = prof_client.patch("/api/profile", json={"display_name": "Test"})
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_profile_page_accessible(self, prof_client):
|
||||
"""GET /profile page renders successfully (auth disabled in tests)."""
|
||||
response = prof_client.get("/profile", follow_redirects=False)
|
||||
# AUTH_ENABLED=False in tests so no redirect; page should render
|
||||
assert response.status_code in (200, 302)
|
||||
@@ -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
|
||||
@@ -68,11 +68,76 @@ def _make_client(tok_engine, owner_id: str = _OWNER) -> TestClient:
|
||||
return client
|
||||
|
||||
|
||||
def _make_unauthenticated_client(tok_engine) -> TestClient:
|
||||
"""Return a TestClient that only overrides ``get_db`` (no auth injection).
|
||||
|
||||
This exercises the real ``_get_owner_id`` → ``get_current_owner_id``
|
||||
authentication path. Any request made with this client that does not
|
||||
carry a valid session or Bearer token will receive a 401 from the
|
||||
actual auth code, not from a mocked dependency.
|
||||
|
||||
The caller is responsible for clearing overrides via ``_cleanup(app)``
|
||||
after the test completes (typically in a ``finally`` block).
|
||||
"""
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=tok_engine)
|
||||
|
||||
def _override_get_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -129,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/."""
|
||||
@@ -221,6 +326,7 @@ class TestTokenRevoke:
|
||||
|
||||
resp = client.delete(f"/api/api-tokens/{token_id}")
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "Token is already revoked"
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@@ -233,6 +339,93 @@ class TestTokenRevoke:
|
||||
try:
|
||||
resp = client.delete("/api/api-tokens/99999")
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["detail"] == "Token not found"
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_revoke_token_unauthenticated(self, tok_engine):
|
||||
"""Revoking a token without authentication should return 401.
|
||||
|
||||
Uses a client that only overrides ``get_db`` so that the real
|
||||
``_get_owner_id`` → ``get_current_owner_id`` path is exercised.
|
||||
Sending no session or Bearer credentials means ``get_current_owner_id``
|
||||
returns ``None``, and ``_get_owner_id`` raises a 401.
|
||||
"""
|
||||
from app.main import app
|
||||
|
||||
client = _make_unauthenticated_client(tok_engine)
|
||||
try:
|
||||
resp = client.delete("/api/api-tokens/1")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["detail"] == "Not authenticated"
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_revoke_token_database_error(self, tok_engine, tok_session):
|
||||
"""Revoking a token should rollback and raise 500 if database commit fails.
|
||||
|
||||
The test verifies two properties:
|
||||
1. ``db.rollback()`` is actually called when commit raises (not just that
|
||||
the endpoint returns 500).
|
||||
2. After the rollback the token remains active in the database.
|
||||
|
||||
To ensure the assertions are meaningful, the patched ``commit`` first
|
||||
flushes the session (so the changes *are* staged inside the transaction)
|
||||
before raising. Without a subsequent ``rollback()`` the flushed state
|
||||
would still be visible to other sessions, so the ``is_active`` check
|
||||
would catch a missing rollback call.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy.orm import Session as SASession
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
create_resp = client.post("/api/api-tokens/", json={"name": "DB Error Test"})
|
||||
token_id = create_resp.json()["id"]
|
||||
|
||||
# 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.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"
|
||||
|
||||
# After rollback the token must still be active in the database.
|
||||
db_token = tok_session.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||
assert db_token.is_active is True
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_revoke_token_invalid_id_format(self, tok_engine):
|
||||
"""Revoking a token with a non-integer ID should return 422 Unprocessable Entity."""
|
||||
from app.main import app
|
||||
|
||||
client = _make_client(tok_engine)
|
||||
try:
|
||||
resp = client.delete("/api/api-tokens/abc")
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@@ -416,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."""
|
||||
@@ -436,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
|
||||
|
||||
+41
-5
@@ -14,18 +14,38 @@ class TestWhoamiHandler:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_user_with_gravatar(self):
|
||||
"""Test that handler returns user data with gravatar URL."""
|
||||
"""Test that handler returns user data with gravatar URL when no custom avatar."""
|
||||
mock_request = MagicMock()
|
||||
email = "test@example.com"
|
||||
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
|
||||
|
||||
result = await whoami_handler(mock_request)
|
||||
# Mock DB: no UserProfile found (no custom avatar)
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
result = await whoami_handler(mock_request, mock_db)
|
||||
assert result["id"] == "1"
|
||||
assert result["name"] == "Test"
|
||||
# Should have gravatar URL
|
||||
# Should have gravatar URL since no custom avatar
|
||||
expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest()
|
||||
assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_custom_avatar_when_set(self):
|
||||
"""Test that handler returns custom avatar URL when profile has avatar_data."""
|
||||
mock_request = MagicMock()
|
||||
email = "test@example.com"
|
||||
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
|
||||
|
||||
# Mock DB: UserProfile with avatar_data
|
||||
mock_profile = MagicMock()
|
||||
mock_profile.avatar_data = "data:image/png;base64,abc123"
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_profile
|
||||
|
||||
result = await whoami_handler(mock_request, mock_db)
|
||||
assert result["picture"] == "data:image/png;base64,abc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_401_when_no_user(self):
|
||||
"""Test that 401 is raised when no user in session."""
|
||||
@@ -33,9 +53,10 @@ class TestWhoamiHandler:
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {}
|
||||
mock_db = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await whoami_handler(mock_request)
|
||||
await whoami_handler(mock_request, mock_db)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -45,11 +66,26 @@ class TestWhoamiHandler:
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"id": "1", "name": "Test"}}
|
||||
mock_db = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await whoami_handler(mock_request)
|
||||
await whoami_handler(mock_request, mock_db)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_gravatar_on_db_error(self):
|
||||
"""Test that gravatar is used when DB lookup raises an exception."""
|
||||
mock_request = MagicMock()
|
||||
email = "test@example.com"
|
||||
mock_request.session = {"user": {"id": "1", "email": email, "name": "Test"}}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = Exception("DB error")
|
||||
|
||||
result = await whoami_handler(mock_request, mock_db)
|
||||
expected_hash = md5(email.encode(), usedforsecurity=False).hexdigest()
|
||||
assert result["picture"] == f"https://www.gravatar.com/avatar/{expected_hash}?d=identicon"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestWhoamiEndpoints:
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
"""
|
||||
Tests for the comprehensive audit logging feature.
|
||||
|
||||
Covers the audit service (recording, querying, SIEM forwarding),
|
||||
the REST API endpoints, and the admin viewer page.
|
||||
"""
|
||||
|
||||
import json
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, 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 AuditLog
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def audit_db():
|
||||
"""Fresh in-memory database with all tables created."""
|
||||
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()
|
||||
yield session
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAuditLogModel:
|
||||
"""Verify the AuditLog ORM model."""
|
||||
|
||||
def test_create_minimal_entry(self, audit_db):
|
||||
"""Minimal required fields can be persisted."""
|
||||
entry = AuditLog(user="alice", action="login")
|
||||
audit_db.add(entry)
|
||||
audit_db.commit()
|
||||
audit_db.refresh(entry)
|
||||
assert entry.id is not None
|
||||
assert entry.user == "alice"
|
||||
assert entry.action == "login"
|
||||
assert entry.severity == "info" # server default
|
||||
|
||||
def test_create_full_entry(self, audit_db):
|
||||
"""All columns persist correctly."""
|
||||
entry = AuditLog(
|
||||
user="bob",
|
||||
action="document.create",
|
||||
resource_type="document",
|
||||
resource_id="42",
|
||||
ip_address="10.0.0.1",
|
||||
details='{"filename": "invoice.pdf"}',
|
||||
severity="warning",
|
||||
)
|
||||
audit_db.add(entry)
|
||||
audit_db.commit()
|
||||
audit_db.refresh(entry)
|
||||
assert entry.resource_type == "document"
|
||||
assert entry.resource_id == "42"
|
||||
assert entry.ip_address == "10.0.0.1"
|
||||
assert json.loads(entry.details) == {"filename": "invoice.pdf"}
|
||||
assert entry.severity == "warning"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAuditService:
|
||||
"""Verify the audit_service helper functions."""
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_record_event(self, mock_settings, audit_db):
|
||||
"""record_event persists a row and returns the entry."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import record_event
|
||||
|
||||
entry = record_event(
|
||||
audit_db,
|
||||
action="settings.update",
|
||||
user="admin",
|
||||
resource_type="settings",
|
||||
resource_id="openai_model",
|
||||
details={"old": "gpt-4", "new": "gpt-4o"},
|
||||
)
|
||||
assert entry.id is not None
|
||||
assert entry.action == "settings.update"
|
||||
assert entry.user == "admin"
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_query_events_no_filter(self, mock_settings, audit_db):
|
||||
"""query_events returns all events when no filter is supplied."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import query_events, record_event
|
||||
|
||||
for i in range(5):
|
||||
record_event(audit_db, action=f"action_{i}", user="sys")
|
||||
results = query_events(audit_db)
|
||||
assert len(results) == 5
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_query_events_filter_action(self, mock_settings, audit_db):
|
||||
"""query_events filters by action."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import query_events, record_event
|
||||
|
||||
record_event(audit_db, action="login", user="alice")
|
||||
record_event(audit_db, action="logout", user="alice")
|
||||
results = query_events(audit_db, action="login")
|
||||
assert len(results) == 1
|
||||
assert results[0].action == "login"
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_query_events_filter_user(self, mock_settings, audit_db):
|
||||
"""query_events filters by user."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import query_events, record_event
|
||||
|
||||
record_event(audit_db, action="login", user="alice")
|
||||
record_event(audit_db, action="login", user="bob")
|
||||
results = query_events(audit_db, user="bob")
|
||||
assert len(results) == 1
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_query_events_filter_severity(self, mock_settings, audit_db):
|
||||
"""query_events filters by severity."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import query_events, record_event
|
||||
|
||||
record_event(audit_db, action="fail", user="sys", severity="error")
|
||||
record_event(audit_db, action="ok", user="sys", severity="info")
|
||||
results = query_events(audit_db, severity="error")
|
||||
assert len(results) == 1
|
||||
assert results[0].severity == "error"
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_count_events(self, mock_settings, audit_db):
|
||||
"""count_events returns the correct total."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import count_events, record_event
|
||||
|
||||
for _ in range(3):
|
||||
record_event(audit_db, action="ping", user="sys")
|
||||
assert count_events(audit_db) == 3
|
||||
assert count_events(audit_db, action="ping") == 3
|
||||
assert count_events(audit_db, action="pong") == 0
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_query_events_pagination(self, mock_settings, audit_db):
|
||||
"""query_events respects limit and offset."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import query_events, record_event
|
||||
|
||||
for i in range(10):
|
||||
record_event(audit_db, action=f"a{i}", user="sys")
|
||||
page1 = query_events(audit_db, limit=3, offset=0)
|
||||
page2 = query_events(audit_db, limit=3, offset=3)
|
||||
assert len(page1) == 3
|
||||
assert len(page2) == 3
|
||||
assert page1[0].id != page2[0].id
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_query_events_filter_resource_type(self, mock_settings, audit_db):
|
||||
"""query_events filters by resource_type."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import query_events, record_event
|
||||
|
||||
record_event(audit_db, action="create", user="sys", resource_type="document")
|
||||
record_event(audit_db, action="create", user="sys", resource_type="user")
|
||||
results = query_events(audit_db, resource_type="document")
|
||||
assert len(results) == 1
|
||||
assert results[0].resource_type == "document"
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_query_events_filter_since_and_until(self, mock_settings, audit_db):
|
||||
"""query_events filters by since and until timestamps."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import query_events
|
||||
|
||||
# Insert two events directly with distinct timestamps (naive, as SQLite stores them)
|
||||
early = AuditLog(user="sys", action="early", severity="info", timestamp=datetime(2020, 1, 1))
|
||||
late = AuditLog(user="sys", action="late", severity="info", timestamp=datetime(2025, 1, 1))
|
||||
audit_db.add(early)
|
||||
audit_db.add(late)
|
||||
audit_db.commit()
|
||||
|
||||
since_ts = datetime(2022, 1, 1)
|
||||
results = query_events(audit_db, since=since_ts)
|
||||
assert all(r.timestamp >= since_ts for r in results)
|
||||
assert any(r.action == "late" for r in results)
|
||||
assert not any(r.action == "early" for r in results)
|
||||
|
||||
until_ts = datetime(2022, 1, 1)
|
||||
results = query_events(audit_db, until=until_ts)
|
||||
assert all(r.timestamp <= until_ts for r in results)
|
||||
assert any(r.action == "early" for r in results)
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_count_events_filters(self, mock_settings, audit_db):
|
||||
"""count_events filters by user, resource_type, severity, since, and until."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import count_events, record_event
|
||||
|
||||
record_event(audit_db, action="a", user="alice", resource_type="doc", severity="info")
|
||||
record_event(audit_db, action="b", user="bob", resource_type="user", severity="error")
|
||||
|
||||
assert count_events(audit_db, user="alice") == 1
|
||||
assert count_events(audit_db, resource_type="doc") == 1
|
||||
assert count_events(audit_db, severity="error") == 1
|
||||
|
||||
early = AuditLog(user="sys", action="early", severity="info", timestamp=datetime(2020, 1, 1))
|
||||
late = AuditLog(user="sys", action="late", severity="info", timestamp=datetime(2025, 1, 1))
|
||||
audit_db.add(early)
|
||||
audit_db.add(late)
|
||||
audit_db.commit()
|
||||
|
||||
since_ts = datetime(2022, 1, 1)
|
||||
assert count_events(audit_db, since=since_ts) >= 1
|
||||
until_ts = datetime(2022, 1, 1)
|
||||
assert count_events(audit_db, until=until_ts) >= 1
|
||||
|
||||
@patch("app.utils.audit_service._forward_to_siem")
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_record_event_siem_enabled(self, mock_settings, mock_forward, audit_db):
|
||||
"""record_event starts SIEM forwarding thread when siem is enabled."""
|
||||
mock_settings.audit_siem_enabled = True
|
||||
from app.utils.audit_service import record_event
|
||||
|
||||
entry = record_event(audit_db, action="login", user="alice")
|
||||
assert entry.id is not None
|
||||
mock_forward.assert_called_once()
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_record_event_from_request(self, mock_settings, audit_db):
|
||||
"""record_event_from_request extracts user and IP from the request."""
|
||||
mock_settings.audit_siem_enabled = False
|
||||
from app.utils.audit_service import record_event_from_request
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"preferred_username": "carol"}}
|
||||
mock_request.headers = {"X-Forwarded-For": "192.168.1.1"}
|
||||
mock_request.client = MagicMock()
|
||||
mock_request.client.host = "192.168.1.1"
|
||||
|
||||
with (
|
||||
patch("app.utils.audit_service.get_username", return_value="carol"),
|
||||
patch("app.utils.audit_service.get_client_ip", return_value="192.168.1.1"),
|
||||
):
|
||||
entry = record_event_from_request(
|
||||
audit_db,
|
||||
mock_request,
|
||||
action="document.view",
|
||||
resource_type="document",
|
||||
resource_id="99",
|
||||
)
|
||||
assert entry.user == "carol"
|
||||
assert entry.ip_address == "192.168.1.1"
|
||||
assert entry.action == "document.view"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SIEM forwarding tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSIEMForwarding:
|
||||
"""Verify SIEM transport helpers."""
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_build_siem_payload(self, mock_settings):
|
||||
"""_build_siem_payload returns a dict with all expected keys."""
|
||||
from app.utils.audit_service import _build_siem_payload
|
||||
|
||||
entry = AuditLog(
|
||||
id=1,
|
||||
user="admin",
|
||||
action="login",
|
||||
resource_type="session",
|
||||
timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
severity="info",
|
||||
)
|
||||
payload = _build_siem_payload(entry)
|
||||
assert payload["user"] == "admin"
|
||||
assert payload["action"] == "login"
|
||||
assert payload["source"] == "docuelevate"
|
||||
assert "timestamp" in payload
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.socket")
|
||||
def test_send_syslog_udp(self, mock_socket_mod, mock_settings):
|
||||
"""_send_syslog sends a UDP datagram to the configured host."""
|
||||
mock_settings.audit_siem_syslog_protocol = "udp"
|
||||
mock_settings.audit_siem_syslog_host = "127.0.0.1"
|
||||
mock_settings.audit_siem_syslog_port = 5140
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_mod.AF_INET = socket.AF_INET
|
||||
mock_socket_mod.SOCK_DGRAM = socket.SOCK_DGRAM
|
||||
mock_socket_mod.gethostname.return_value = "test-host"
|
||||
mock_socket_mod.socket.return_value.__enter__ = MagicMock(return_value=mock_sock)
|
||||
mock_socket_mod.socket.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.utils.audit_service import _send_syslog
|
||||
|
||||
_send_syslog({"user": "test", "action": "login", "severity": "info", "timestamp": "2026-01-01T00:00:00"})
|
||||
mock_sock.sendto.assert_called_once()
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.httpx")
|
||||
def test_send_http_generic(self, mock_httpx, mock_settings):
|
||||
"""_send_http POSTs JSON to a generic endpoint."""
|
||||
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
|
||||
mock_settings.audit_siem_http_token = "my-token"
|
||||
mock_settings.audit_siem_http_custom_headers = ""
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_client.post.return_value = mock_resp
|
||||
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.utils.audit_service import _send_http
|
||||
|
||||
_send_http({"user": "test", "action": "login"})
|
||||
mock_client.post.assert_called_once()
|
||||
call_kwargs = mock_client.post.call_args
|
||||
assert call_kwargs.kwargs["headers"]["Authorization"] == "Bearer my-token"
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.httpx")
|
||||
def test_send_http_splunk_hec(self, mock_httpx, mock_settings):
|
||||
"""_send_http wraps payload in Splunk HEC envelope when URL contains /services/collector."""
|
||||
mock_settings.audit_siem_http_url = "https://splunk:8088/services/collector/event"
|
||||
mock_settings.audit_siem_http_token = "hec-token"
|
||||
mock_settings.audit_siem_http_custom_headers = ""
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_client.post.return_value = mock_resp
|
||||
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.utils.audit_service import _send_http
|
||||
|
||||
_send_http({"user": "test", "action": "login"})
|
||||
call_kwargs = mock_client.post.call_args
|
||||
body = call_kwargs.kwargs["json"]
|
||||
assert "event" in body
|
||||
assert body["sourcetype"] == "docuelevate:audit"
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service._send_syslog")
|
||||
def test_forward_to_siem_syslog(self, mock_send_syslog, mock_settings):
|
||||
"""_forward_to_siem routes to _send_syslog when transport is syslog."""
|
||||
mock_settings.audit_siem_transport = "syslog"
|
||||
from app.utils.audit_service import _forward_to_siem
|
||||
|
||||
payload = {"user": "test", "action": "login", "severity": "info"}
|
||||
_forward_to_siem(payload)
|
||||
mock_send_syslog.assert_called_once_with(payload)
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service._send_http")
|
||||
def test_forward_to_siem_http(self, mock_send_http, mock_settings):
|
||||
"""_forward_to_siem routes to _send_http when transport is http."""
|
||||
mock_settings.audit_siem_transport = "http"
|
||||
from app.utils.audit_service import _forward_to_siem
|
||||
|
||||
payload = {"user": "test", "action": "login", "severity": "info"}
|
||||
_forward_to_siem(payload)
|
||||
mock_send_http.assert_called_once_with(payload)
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_forward_to_siem_unknown_transport(self, mock_settings):
|
||||
"""_forward_to_siem logs a warning for an unknown transport."""
|
||||
mock_settings.audit_siem_transport = "unknown_proto"
|
||||
from app.utils.audit_service import _forward_to_siem
|
||||
|
||||
# Should not raise; just log a warning
|
||||
_forward_to_siem({"user": "test", "action": "login"})
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_forward_to_siem_exception_is_caught(self, mock_settings):
|
||||
"""_forward_to_siem catches exceptions from transports and logs them."""
|
||||
mock_settings.audit_siem_transport = "syslog"
|
||||
from app.utils.audit_service import _forward_to_siem
|
||||
|
||||
with patch("app.utils.audit_service._send_syslog", side_effect=OSError("network error")):
|
||||
# Must not propagate
|
||||
_forward_to_siem({"user": "test", "action": "login"})
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.socket")
|
||||
def test_send_syslog_tcp(self, mock_socket_mod, mock_settings):
|
||||
"""_send_syslog opens a TCP stream socket when protocol is tcp."""
|
||||
mock_settings.audit_siem_syslog_protocol = "tcp"
|
||||
mock_settings.audit_siem_syslog_host = "127.0.0.1"
|
||||
mock_settings.audit_siem_syslog_port = 601
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_socket_mod.AF_INET = socket.AF_INET
|
||||
mock_socket_mod.SOCK_STREAM = socket.SOCK_STREAM
|
||||
mock_socket_mod.gethostname.return_value = "test-host"
|
||||
mock_socket_mod.socket.return_value.__enter__ = MagicMock(return_value=mock_sock)
|
||||
mock_socket_mod.socket.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.utils.audit_service import _send_syslog
|
||||
|
||||
_send_syslog({"user": "test", "action": "login", "severity": "info", "timestamp": "2026-01-01T00:00:00"})
|
||||
mock_sock.connect.assert_called_once()
|
||||
mock_sock.sendall.assert_called_once()
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.httpx")
|
||||
def test_send_http_no_url(self, mock_httpx, mock_settings):
|
||||
"""_send_http returns early and logs a warning when no URL is configured."""
|
||||
mock_settings.audit_siem_http_url = ""
|
||||
from app.utils.audit_service import _send_http
|
||||
|
||||
_send_http({"user": "test", "action": "login"})
|
||||
mock_httpx.Client.assert_not_called()
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.httpx")
|
||||
def test_send_http_no_token(self, mock_httpx, mock_settings):
|
||||
"""_send_http omits Authorization header when no token is configured."""
|
||||
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
|
||||
mock_settings.audit_siem_http_token = ""
|
||||
mock_settings.audit_siem_http_custom_headers = ""
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_client.post.return_value = mock_resp
|
||||
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.utils.audit_service import _send_http
|
||||
|
||||
_send_http({"user": "test", "action": "login"})
|
||||
call_kwargs = mock_client.post.call_args
|
||||
assert "Authorization" not in call_kwargs.kwargs["headers"]
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.httpx")
|
||||
def test_send_http_custom_headers_valid(self, mock_httpx, mock_settings):
|
||||
"""_send_http adds valid custom headers."""
|
||||
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
|
||||
mock_settings.audit_siem_http_token = ""
|
||||
mock_settings.audit_siem_http_custom_headers = "X-Tenant-ID: acme, X-Source: audit"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_client.post.return_value = mock_resp
|
||||
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.utils.audit_service import _send_http
|
||||
|
||||
_send_http({"user": "test", "action": "login"})
|
||||
call_kwargs = mock_client.post.call_args
|
||||
headers = call_kwargs.kwargs["headers"]
|
||||
assert headers.get("X-Tenant-ID") == "acme"
|
||||
assert headers.get("X-Source") == "audit"
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.httpx")
|
||||
def test_send_http_custom_headers_invalid_name(self, mock_httpx, mock_settings):
|
||||
"""_send_http skips custom headers with invalid names."""
|
||||
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
|
||||
mock_settings.audit_siem_http_token = ""
|
||||
mock_settings.audit_siem_http_custom_headers = "Bad Header!: value"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_client.post.return_value = mock_resp
|
||||
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.utils.audit_service import _send_http
|
||||
|
||||
_send_http({"user": "test", "action": "login"})
|
||||
call_kwargs = mock_client.post.call_args
|
||||
headers = call_kwargs.kwargs["headers"]
|
||||
assert "Bad Header!" not in headers
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.httpx")
|
||||
def test_send_http_custom_headers_protected_name(self, mock_httpx, mock_settings):
|
||||
"""_send_http skips custom headers that match protected names."""
|
||||
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
|
||||
mock_settings.audit_siem_http_token = ""
|
||||
mock_settings.audit_siem_http_custom_headers = "Authorization: evil-token"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_client.post.return_value = mock_resp
|
||||
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.utils.audit_service import _send_http
|
||||
|
||||
_send_http({"user": "test", "action": "login"})
|
||||
call_kwargs = mock_client.post.call_args
|
||||
headers = call_kwargs.kwargs["headers"]
|
||||
# Authorization should not have been overwritten by the custom header
|
||||
assert headers.get("Authorization") != "evil-token"
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
@patch("app.utils.audit_service.httpx")
|
||||
def test_send_http_custom_headers_no_colon(self, mock_httpx, mock_settings):
|
||||
"""_send_http ignores custom header entries that contain no colon separator."""
|
||||
mock_settings.audit_siem_http_url = "https://siem.example.com/ingest"
|
||||
mock_settings.audit_siem_http_token = ""
|
||||
mock_settings.audit_siem_http_custom_headers = "MalformedHeader"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_client.post.return_value = mock_resp
|
||||
mock_httpx.Client.return_value.__enter__ = MagicMock(return_value=mock_client)
|
||||
mock_httpx.Client.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from app.utils.audit_service import _send_http
|
||||
|
||||
# Should not raise; malformed entry is silently skipped
|
||||
_send_http({"user": "test", "action": "login"})
|
||||
mock_client.post.assert_called_once()
|
||||
|
||||
@patch("app.utils.audit_service.settings")
|
||||
def test_build_siem_payload_no_timestamp(self, mock_settings):
|
||||
"""_build_siem_payload uses current UTC time when entry.timestamp is None."""
|
||||
from app.utils.audit_service import _build_siem_payload
|
||||
|
||||
entry = AuditLog(user="admin", action="login", severity="info")
|
||||
entry.timestamp = None # type: ignore[assignment]
|
||||
payload = _build_siem_payload(entry)
|
||||
assert "timestamp" in payload
|
||||
# Should be a valid ISO timestamp string
|
||||
datetime.fromisoformat(payload["timestamp"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestAuditLogAPI:
|
||||
"""Test /api/audit-logs REST endpoints."""
|
||||
|
||||
def test_list_audit_logs_empty(self, client):
|
||||
"""GET /api/audit-logs returns empty list when no events exist."""
|
||||
resp = client.get("/api/audit-logs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_list_audit_logs_with_data(self, client, db_session):
|
||||
"""GET /api/audit-logs returns recorded events."""
|
||||
entry = AuditLog(user="tester", action="test.action", severity="info")
|
||||
db_session.add(entry)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/audit-logs")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["action"] == "test.action"
|
||||
|
||||
def test_list_audit_logs_filter_by_action(self, client, db_session):
|
||||
"""GET /api/audit-logs?action=x filters correctly."""
|
||||
db_session.add(AuditLog(user="a", action="login", severity="info"))
|
||||
db_session.add(AuditLog(user="a", action="logout", severity="info"))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/audit-logs?action=login")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_list_distinct_actions(self, client, db_session):
|
||||
"""GET /api/audit-logs/actions returns distinct action values."""
|
||||
db_session.add(AuditLog(user="a", action="login", severity="info"))
|
||||
db_session.add(AuditLog(user="b", action="login", severity="info"))
|
||||
db_session.add(AuditLog(user="a", action="logout", severity="info"))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/audit-logs/actions")
|
||||
assert resp.status_code == 200
|
||||
actions = resp.json()
|
||||
assert set(actions) == {"login", "logout"}
|
||||
|
||||
def test_list_distinct_users(self, client, db_session):
|
||||
"""GET /api/audit-logs/users returns distinct user values."""
|
||||
db_session.add(AuditLog(user="alice", action="x", severity="info"))
|
||||
db_session.add(AuditLog(user="bob", action="x", severity="info"))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/audit-logs/users")
|
||||
assert resp.status_code == 200
|
||||
users = resp.json()
|
||||
assert set(users) == {"alice", "bob"}
|
||||
|
||||
def test_list_audit_logs_pagination(self, client, db_session):
|
||||
"""GET /api/audit-logs supports limit/offset pagination."""
|
||||
for i in range(5):
|
||||
db_session.add(AuditLog(user="u", action=f"a{i}", severity="info"))
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/audit-logs?limit=2&offset=0")
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
assert data["total"] == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# View tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestAuditLogView:
|
||||
"""Test the admin audit-log viewer page."""
|
||||
|
||||
def test_audit_logs_page_loads(self, client):
|
||||
"""GET /admin/audit-logs returns 200 and renders the template."""
|
||||
resp = client.get("/admin/audit-logs")
|
||||
assert resp.status_code == 200
|
||||
assert "Audit Logs" in resp.text
|
||||
@@ -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,502 @@
|
||||
"""
|
||||
Tests for the compliance templates feature.
|
||||
|
||||
Covers:
|
||||
- app/models.py – ComplianceTemplate model
|
||||
- app/utils/compliance_service.py – service functions (seed, evaluate, apply)
|
||||
- app/api/compliance.py – REST API endpoints
|
||||
- app/views/compliance.py – admin view route
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models import ComplianceTemplate
|
||||
from app.utils.compliance_service import (
|
||||
COMPLIANCE_TEMPLATES,
|
||||
apply_template,
|
||||
evaluate_template_status,
|
||||
get_all_templates,
|
||||
get_compliance_summary,
|
||||
get_template_by_name,
|
||||
seed_compliance_templates,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ct_engine():
|
||||
"""In-memory SQLite engine for compliance template 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 ct_session(ct_engine):
|
||||
"""DB session scoped to one test."""
|
||||
Session = sessionmaker(bind=ct_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ct_client(ct_engine):
|
||||
"""TestClient with in-memory DB and admin override."""
|
||||
from app.api.compliance import _require_admin
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=ct_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_admin():
|
||||
return {"email": "admin@test.com", "is_admin": True}
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[_require_admin] = override_admin
|
||||
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_session(ct_session):
|
||||
"""Session with compliance templates already seeded."""
|
||||
seed_compliance_templates(ct_session)
|
||||
return ct_session
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seeded_client(ct_engine):
|
||||
"""TestClient with seeded compliance templates."""
|
||||
from app.api.compliance import _require_admin
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=ct_engine)
|
||||
session = Session()
|
||||
seed_compliance_templates(session)
|
||||
session.close()
|
||||
|
||||
def override_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_admin():
|
||||
return {"email": "admin@test.com", "is_admin": True}
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
app.dependency_overrides[_require_admin] = override_admin
|
||||
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestComplianceTemplateModel:
|
||||
"""Tests for the ComplianceTemplate database model."""
|
||||
|
||||
def test_create_template(self, ct_session):
|
||||
"""Test creating a compliance template."""
|
||||
template = ComplianceTemplate(
|
||||
name="test_template",
|
||||
display_name="Test Template",
|
||||
description="A test compliance template",
|
||||
settings_json='{"auth_enabled": "True"}',
|
||||
enabled=False,
|
||||
status="not_applied",
|
||||
)
|
||||
ct_session.add(template)
|
||||
ct_session.commit()
|
||||
|
||||
assert template.id is not None
|
||||
assert template.name == "test_template"
|
||||
assert template.display_name == "Test Template"
|
||||
assert template.enabled is False
|
||||
assert template.status == "not_applied"
|
||||
|
||||
def test_unique_name_constraint(self, ct_session):
|
||||
"""Test that template names must be unique."""
|
||||
t1 = ComplianceTemplate(
|
||||
name="unique_test",
|
||||
display_name="First",
|
||||
settings_json="{}",
|
||||
)
|
||||
ct_session.add(t1)
|
||||
ct_session.commit()
|
||||
|
||||
t2 = ComplianceTemplate(
|
||||
name="unique_test",
|
||||
display_name="Second",
|
||||
settings_json="{}",
|
||||
)
|
||||
ct_session.add(t2)
|
||||
with pytest.raises(IntegrityError):
|
||||
ct_session.commit()
|
||||
ct_session.rollback()
|
||||
|
||||
def test_default_values(self, ct_session):
|
||||
"""Test default column values."""
|
||||
template = ComplianceTemplate(
|
||||
name="defaults_test",
|
||||
display_name="Defaults",
|
||||
settings_json="{}",
|
||||
)
|
||||
ct_session.add(template)
|
||||
ct_session.commit()
|
||||
|
||||
assert template.enabled is False
|
||||
assert template.status == "not_applied"
|
||||
assert template.applied_at is None
|
||||
assert template.applied_by is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestComplianceService:
|
||||
"""Tests for compliance_service utility functions."""
|
||||
|
||||
def test_seed_creates_templates(self, ct_session):
|
||||
"""Test that seeding creates all three compliance templates."""
|
||||
seed_compliance_templates(ct_session)
|
||||
templates = ct_session.query(ComplianceTemplate).all()
|
||||
names = {t.name for t in templates}
|
||||
|
||||
assert "gdpr" in names
|
||||
assert "hipaa" in names
|
||||
assert "soc2" in names
|
||||
assert len(templates) == 3
|
||||
|
||||
def test_seed_is_idempotent(self, ct_session):
|
||||
"""Test that seeding twice does not create duplicates."""
|
||||
seed_compliance_templates(ct_session)
|
||||
seed_compliance_templates(ct_session)
|
||||
templates = ct_session.query(ComplianceTemplate).all()
|
||||
assert len(templates) == 3
|
||||
|
||||
def test_seed_updates_display_name(self, ct_session):
|
||||
"""Test that re-seeding updates display_name but preserves state."""
|
||||
seed_compliance_templates(ct_session)
|
||||
gdpr = ct_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
|
||||
gdpr.enabled = True
|
||||
ct_session.commit()
|
||||
|
||||
seed_compliance_templates(ct_session)
|
||||
gdpr = ct_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
|
||||
assert gdpr.enabled is True # User state preserved
|
||||
|
||||
def test_get_all_templates(self, seeded_session):
|
||||
"""Test getting all templates."""
|
||||
result = get_all_templates(seeded_session)
|
||||
assert len(result) == 3
|
||||
for t in result:
|
||||
assert "id" in t
|
||||
assert "name" in t
|
||||
assert "display_name" in t
|
||||
assert "checks" in t
|
||||
assert "check_count" in t
|
||||
|
||||
def test_get_template_by_name_exists(self, seeded_session):
|
||||
"""Test retrieving an existing template by name."""
|
||||
result = get_template_by_name(seeded_session, "gdpr")
|
||||
assert result is not None
|
||||
assert result.name == "gdpr"
|
||||
|
||||
def test_get_template_by_name_missing(self, seeded_session):
|
||||
"""Test retrieving a non-existent template."""
|
||||
result = get_template_by_name(seeded_session, "nonexistent")
|
||||
assert result is None
|
||||
|
||||
@patch("app.utils.settings_service.get_all_settings_from_db")
|
||||
def test_evaluate_template_compliant(self, mock_settings, seeded_session):
|
||||
"""Test evaluation when all checks pass."""
|
||||
mock_settings.return_value = {
|
||||
"auth_enabled": "True",
|
||||
"sentry_send_default_pii": "False",
|
||||
"security_headers_enabled": "True",
|
||||
"security_header_hsts_enabled": "True",
|
||||
"security_header_csp_enabled": "True",
|
||||
"security_header_x_frame_options_enabled": "True",
|
||||
"enable_deduplication": "True",
|
||||
}
|
||||
|
||||
result = evaluate_template_status(seeded_session, "gdpr")
|
||||
assert result["status"] == "compliant"
|
||||
assert result["passed"] == result["total"]
|
||||
assert result["failed"] == 0
|
||||
|
||||
@patch("app.utils.settings_service.get_all_settings_from_db")
|
||||
def test_evaluate_template_non_compliant(self, mock_settings, seeded_session):
|
||||
"""Test evaluation when no checks pass."""
|
||||
mock_settings.return_value = {}
|
||||
|
||||
with patch("app.config.settings") as mock_app:
|
||||
mock_app.auth_enabled = False
|
||||
mock_app.sentry_send_default_pii = True
|
||||
mock_app.security_headers_enabled = False
|
||||
mock_app.security_header_hsts_enabled = False
|
||||
mock_app.security_header_csp_enabled = False
|
||||
mock_app.security_header_x_frame_options_enabled = False
|
||||
mock_app.enable_deduplication = False
|
||||
|
||||
result = evaluate_template_status(seeded_session, "gdpr")
|
||||
assert result["status"] in ("non_compliant", "partial")
|
||||
assert result["failed"] > 0
|
||||
|
||||
def test_evaluate_unknown_template(self, seeded_session):
|
||||
"""Test evaluation of a non-existent template name."""
|
||||
result = evaluate_template_status(seeded_session, "unknown")
|
||||
assert result["status"] == "unknown"
|
||||
assert result["total"] == 0
|
||||
|
||||
@patch("app.utils.settings_service.save_setting_to_db")
|
||||
@patch("app.utils.settings_service.get_all_settings_from_db")
|
||||
def test_apply_template_success(self, mock_get_settings, mock_save, seeded_session):
|
||||
"""Test successfully applying a template."""
|
||||
mock_save.return_value = True
|
||||
mock_get_settings.return_value = {
|
||||
"auth_enabled": "True",
|
||||
"sentry_send_default_pii": "False",
|
||||
"security_headers_enabled": "True",
|
||||
"security_header_hsts_enabled": "True",
|
||||
"security_header_csp_enabled": "True",
|
||||
"security_header_x_frame_options_enabled": "True",
|
||||
"enable_deduplication": "True",
|
||||
}
|
||||
|
||||
result = apply_template(seeded_session, "gdpr", applied_by="test@admin.com")
|
||||
assert result["success"] is True
|
||||
assert result["template"] == "gdpr"
|
||||
assert "applied_settings" in result
|
||||
|
||||
# Verify template record updated
|
||||
gdpr = seeded_session.query(ComplianceTemplate).filter_by(name="gdpr").first()
|
||||
assert gdpr.enabled is True
|
||||
assert gdpr.applied_by == "test@admin.com"
|
||||
assert gdpr.applied_at is not None
|
||||
|
||||
def test_apply_unknown_template(self, seeded_session):
|
||||
"""Test applying a non-existent template."""
|
||||
result = apply_template(seeded_session, "nonexistent")
|
||||
assert result["success"] is False
|
||||
assert "error" in result
|
||||
|
||||
@patch("app.utils.settings_service.get_all_settings_from_db")
|
||||
def test_get_compliance_summary(self, mock_settings, seeded_session):
|
||||
"""Test compliance summary across all templates."""
|
||||
mock_settings.return_value = {}
|
||||
|
||||
result = get_compliance_summary(seeded_session)
|
||||
assert "overall_status" in result
|
||||
assert "total_checks" in result
|
||||
assert "total_passed" in result
|
||||
assert "total_failed" in result
|
||||
assert "templates" in result
|
||||
assert len(result["templates"]) == 3
|
||||
|
||||
def test_compliance_templates_have_checks(self):
|
||||
"""Test that all built-in templates have compliance checks."""
|
||||
for name, defn in COMPLIANCE_TEMPLATES.items():
|
||||
assert "checks" in defn, f"Template {name} missing checks"
|
||||
assert len(defn["checks"]) > 0, f"Template {name} has no checks"
|
||||
for check in defn["checks"]:
|
||||
assert "key" in check
|
||||
assert "expected" in check
|
||||
assert "label" in check
|
||||
assert "description" in check
|
||||
|
||||
def test_compliance_templates_have_settings(self):
|
||||
"""Test that all built-in templates have settings to apply."""
|
||||
for name, defn in COMPLIANCE_TEMPLATES.items():
|
||||
assert "settings" in defn, f"Template {name} missing settings"
|
||||
assert len(defn["settings"]) > 0, f"Template {name} has no settings"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestComplianceAPI:
|
||||
"""Tests for compliance API endpoints."""
|
||||
|
||||
def test_list_templates(self, seeded_client):
|
||||
"""Test GET /api/compliance/templates."""
|
||||
resp = seeded_client.get("/api/compliance/templates")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 3
|
||||
names = {t["name"] for t in data}
|
||||
assert names == {"gdpr", "hipaa", "soc2"}
|
||||
|
||||
def test_get_single_template(self, seeded_client):
|
||||
"""Test GET /api/compliance/templates/gdpr."""
|
||||
resp = seeded_client.get("/api/compliance/templates/gdpr")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
data = resp.json()
|
||||
assert data["name"] == "gdpr"
|
||||
assert "display_name" in data
|
||||
assert "checks" in data
|
||||
|
||||
def test_get_nonexistent_template(self, seeded_client):
|
||||
"""Test GET /api/compliance/templates/unknown returns 404."""
|
||||
resp = seeded_client.get("/api/compliance/templates/unknown")
|
||||
assert resp.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_get_template_status(self, seeded_client):
|
||||
"""Test GET /api/compliance/templates/gdpr/status."""
|
||||
resp = seeded_client.get("/api/compliance/templates/gdpr/status")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
data = resp.json()
|
||||
assert "status" in data
|
||||
assert "total" in data
|
||||
assert "passed" in data
|
||||
assert "failed" in data
|
||||
assert "check_results" in data
|
||||
|
||||
def test_apply_template(self, seeded_client):
|
||||
"""Test POST /api/compliance/templates/gdpr/apply."""
|
||||
resp = seeded_client.post("/api/compliance/templates/gdpr/apply")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["template"] == "gdpr"
|
||||
assert "applied_settings" in data
|
||||
|
||||
def test_apply_nonexistent_template(self, seeded_client):
|
||||
"""Test POST /api/compliance/templates/unknown/apply returns 404."""
|
||||
resp = seeded_client.post("/api/compliance/templates/unknown/apply")
|
||||
assert resp.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_compliance_summary(self, seeded_client):
|
||||
"""Test GET /api/compliance/summary."""
|
||||
resp = seeded_client.get("/api/compliance/summary")
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
data = resp.json()
|
||||
assert "overall_status" in data
|
||||
assert "total_checks" in data
|
||||
assert "templates" in data
|
||||
assert len(data["templates"]) == 3
|
||||
|
||||
def test_templates_require_admin(self, ct_engine):
|
||||
"""Test that endpoints require admin access."""
|
||||
from app.main import app
|
||||
|
||||
def override_db():
|
||||
Session = sessionmaker(bind=ct_engine)
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
# Do NOT override _require_admin so it checks session
|
||||
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
||||
resp = client.get("/api/compliance/templates")
|
||||
assert resp.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# View tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestComplianceView:
|
||||
"""Tests for the compliance view route."""
|
||||
|
||||
@patch("app.views.compliance.templates")
|
||||
@patch("app.views.compliance.settings")
|
||||
@pytest.mark.asyncio
|
||||
async def test_compliance_page_admin(self, mock_settings, mock_templates):
|
||||
"""Test compliance page renders for admin users."""
|
||||
from app.views.compliance import compliance_page
|
||||
|
||||
mock_settings.version = "1.0.0"
|
||||
mock_request = Mock()
|
||||
mock_request.session = {"user": {"id": "admin", "is_admin": True}}
|
||||
|
||||
await compliance_page(mock_request)
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
assert call_args[0][0] == "compliance.html"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compliance_page_non_admin_redirects(self):
|
||||
"""Test compliance page redirects non-admin users."""
|
||||
from app.views.compliance import compliance_page
|
||||
|
||||
mock_request = Mock()
|
||||
mock_request.session = {"user": {"id": "user1", "is_admin": False}}
|
||||
|
||||
result = await compliance_page(mock_request)
|
||||
assert result.status_code == 302
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config / settings metadata tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestComplianceConfig:
|
||||
"""Tests for compliance configuration settings."""
|
||||
|
||||
def test_compliance_enabled_default(self):
|
||||
"""Test that compliance_enabled defaults to True."""
|
||||
from app.config import settings
|
||||
|
||||
assert hasattr(settings, "compliance_enabled")
|
||||
assert settings.compliance_enabled is True
|
||||
|
||||
def test_compliance_enabled_in_setting_metadata(self):
|
||||
"""Test that compliance_enabled has SETTING_METADATA entry."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert "compliance_enabled" in SETTING_METADATA
|
||||
meta = SETTING_METADATA["compliance_enabled"]
|
||||
assert meta["category"] == "Feature Flags"
|
||||
assert meta["type"] == "boolean"
|
||||
@@ -196,8 +196,12 @@ class TestValidateAuthConfig:
|
||||
mock_settings.authentik_client_id = None
|
||||
mock_settings.authentik_client_secret = None
|
||||
mock_settings.authentik_config_url = None
|
||||
mock_settings.social_auth_google_enabled = False
|
||||
mock_settings.social_auth_microsoft_enabled = False
|
||||
mock_settings.social_auth_apple_enabled = False
|
||||
mock_settings.social_auth_dropbox_enabled = False
|
||||
result = validate_auth_config()
|
||||
assert "Neither simple authentication nor OIDC are properly configured" in result
|
||||
assert "Neither simple authentication, OIDC, nor social login are properly configured" in result
|
||||
|
||||
def test_auth_enabled_oidc_missing_provider_name(self):
|
||||
"""Test validation when OIDC is configured but provider name is missing."""
|
||||
@@ -210,6 +214,10 @@ class TestValidateAuthConfig:
|
||||
mock_settings.authentik_client_secret = "client_secret"
|
||||
mock_settings.authentik_config_url = "https://example.com"
|
||||
mock_settings.oauth_provider_name = None
|
||||
mock_settings.social_auth_google_enabled = False
|
||||
mock_settings.social_auth_microsoft_enabled = False
|
||||
mock_settings.social_auth_apple_enabled = False
|
||||
mock_settings.social_auth_dropbox_enabled = False
|
||||
result = validate_auth_config()
|
||||
assert "OAUTH_PROVIDER_NAME is not configured but OIDC is enabled" in result
|
||||
|
||||
@@ -223,6 +231,10 @@ class TestValidateAuthConfig:
|
||||
mock_settings.authentik_client_id = None
|
||||
mock_settings.authentik_client_secret = None
|
||||
mock_settings.authentik_config_url = None
|
||||
mock_settings.social_auth_google_enabled = False
|
||||
mock_settings.social_auth_microsoft_enabled = False
|
||||
mock_settings.social_auth_apple_enabled = False
|
||||
mock_settings.social_auth_dropbox_enabled = False
|
||||
result = validate_auth_config()
|
||||
assert len(result) == 0
|
||||
|
||||
@@ -237,6 +249,10 @@ class TestValidateAuthConfig:
|
||||
mock_settings.authentik_client_secret = "client_secret"
|
||||
mock_settings.authentik_config_url = "https://example.com"
|
||||
mock_settings.oauth_provider_name = "Authentik"
|
||||
mock_settings.social_auth_google_enabled = False
|
||||
mock_settings.social_auth_microsoft_enabled = False
|
||||
mock_settings.social_auth_apple_enabled = False
|
||||
mock_settings.social_auth_dropbox_enabled = False
|
||||
result = validate_auth_config()
|
||||
assert len(result) == 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
|
||||
@@ -523,7 +523,7 @@ class TestURLUploadAdditionalCoverage:
|
||||
"""Cover DNS resolution failure branch (lines 67-72)."""
|
||||
import socket as _socket
|
||||
|
||||
from app.api.url_upload import is_private_ip
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
with patch("socket.getaddrinfo", side_effect=_socket.gaierror("nope")):
|
||||
result = is_private_ip("nonexistent.invalid.hostname.test")
|
||||
@@ -531,7 +531,7 @@ class TestURLUploadAdditionalCoverage:
|
||||
|
||||
def test_is_private_ip_hostname_resolves_to_private(self):
|
||||
"""Cover branch where hostname resolves to a private IP (line 64-65)."""
|
||||
from app.api.url_upload import is_private_ip
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
with patch("socket.getaddrinfo") as mock_gai:
|
||||
# Simulate resolving to a private IP
|
||||
@@ -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",
|
||||
"icloud",
|
||||
]
|
||||
return [patch(f"app.tasks.send_to_all._should_upload_to_{s}", return_value=False) for s in services]
|
||||
|
||||
@@ -693,6 +694,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_icloud", return_value=False),
|
||||
patch("app.tasks.send_to_all.SessionLocal") as mock_session_cls,
|
||||
):
|
||||
ms.workdir = str(tmp_path)
|
||||
@@ -804,6 +806,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_icloud", return_value=False),
|
||||
patch("app.tasks.send_to_all.SessionLocal"),
|
||||
):
|
||||
ms.workdir = str(tmp_path)
|
||||
@@ -863,6 +866,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_icloud", return_value=False),
|
||||
patch("app.tasks.send_to_all.SessionLocal"),
|
||||
):
|
||||
ms.workdir = str(tmp_path)
|
||||
|
||||
@@ -124,6 +124,101 @@ class TestInitDb:
|
||||
|
||||
test_engine.dispose()
|
||||
|
||||
def test_init_db_creates_shared_links_for_database_missing_table(self, tmp_path):
|
||||
"""Regression test: databases at revision 026 that skipped 025_add_shared_links.
|
||||
|
||||
Migration 025_add_shared_links was inserted into the chain between
|
||||
024_add_api_tokens and 025_add_user_notifications after some databases
|
||||
had already been migrated past that point. Migration 027 creates the
|
||||
table idempotently so those databases are repaired.
|
||||
"""
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
|
||||
db_path = str(tmp_path / "regression_shared_links.db")
|
||||
test_engine = create_engine(f"sqlite:///{db_path}")
|
||||
|
||||
# Set up a database at revision 026 but WITHOUT the shared_links table.
|
||||
# This simulates a DB that was migrated before 025_add_shared_links
|
||||
# was inserted into the chain.
|
||||
with test_engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE files ("
|
||||
"id INTEGER PRIMARY KEY, filehash VARCHAR NOT NULL, "
|
||||
"original_filename VARCHAR, local_filename VARCHAR NOT NULL, "
|
||||
"original_file_path VARCHAR, processed_file_path VARCHAR, "
|
||||
"file_size INTEGER NOT NULL, mime_type VARCHAR, "
|
||||
"is_duplicate BOOLEAN DEFAULT 0 NOT NULL, duplicate_of_id INTEGER, "
|
||||
"ocr_text TEXT, ai_metadata TEXT, document_title VARCHAR, "
|
||||
"ocr_quality_score INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE processing_logs ("
|
||||
"id INTEGER PRIMARY KEY, file_id INTEGER, task_id VARCHAR, "
|
||||
"step_name VARCHAR, status VARCHAR, message VARCHAR, detail TEXT, "
|
||||
"timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE file_processing_steps ("
|
||||
"id INTEGER PRIMARY KEY, file_id INTEGER NOT NULL, "
|
||||
"step_name VARCHAR NOT NULL, status VARCHAR NOT NULL, "
|
||||
"started_at DATETIME, completed_at DATETIME, error_message TEXT, "
|
||||
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
||||
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE saved_searches ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT, user_id VARCHAR NOT NULL, "
|
||||
"name VARCHAR NOT NULL, filters TEXT NOT NULL, "
|
||||
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
||||
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
||||
"UNIQUE (user_id, name))"
|
||||
)
|
||||
)
|
||||
# user_imap_accounts was created by migration 022 and must exist
|
||||
# before migration 032 (ALTER TABLE ... ADD COLUMN) can run.
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE user_imap_accounts ("
|
||||
"id INTEGER PRIMARY KEY, owner_id VARCHAR NOT NULL, "
|
||||
"name VARCHAR(255) NOT NULL, host VARCHAR(255) NOT NULL, "
|
||||
"port INTEGER NOT NULL DEFAULT 993, username VARCHAR(255) NOT NULL, "
|
||||
"password VARCHAR(1024) NOT NULL, use_ssl BOOLEAN NOT NULL DEFAULT 1, "
|
||||
"delete_after_process BOOLEAN NOT NULL DEFAULT 0, "
|
||||
"is_active BOOLEAN NOT NULL DEFAULT 1, "
|
||||
"last_checked_at DATETIME, last_error TEXT, "
|
||||
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
|
||||
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)"
|
||||
)
|
||||
)
|
||||
conn.execute(text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"))
|
||||
conn.execute(text("INSERT INTO alembic_version VALUES ('026_add_scheduled_jobs')"))
|
||||
|
||||
with patch("app.database.engine", test_engine), patch("app.database.DB_URL", f"sqlite:///{db_path}"):
|
||||
init_db()
|
||||
|
||||
inspector = sa_inspect(test_engine)
|
||||
table_names = inspector.get_table_names()
|
||||
assert "shared_links" in table_names
|
||||
|
||||
# Verify the shared_links table has the expected columns.
|
||||
columns = {col["name"] for col in inspector.get_columns("shared_links")}
|
||||
assert "id" in columns
|
||||
assert "token" in columns
|
||||
assert "file_id" in columns
|
||||
assert "owner_id" in columns
|
||||
assert "expires_at" in columns
|
||||
assert "is_active" in columns
|
||||
|
||||
test_engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetDb:
|
||||
@@ -576,6 +671,75 @@ class TestMultiVersionMigrations:
|
||||
|
||||
engine.dispose()
|
||||
|
||||
def test_migration_drops_unique_index_with_quoted_name(self, tmp_path):
|
||||
"""Test that a unique filehash index whose name requires quoting is dropped correctly.
|
||||
|
||||
Index names containing special characters such as dashes must be quoted by
|
||||
the dialect's identifier_preparer before being interpolated into raw SQL.
|
||||
This test verifies that _run_schema_migrations() handles such names without
|
||||
SQL errors and leaves the underlying table and its data intact.
|
||||
"""
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app.database import _run_schema_migrations
|
||||
|
||||
db_path = str(tmp_path / "quoted_index.db")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
|
||||
# Build a files table that contains all columns the migration expects,
|
||||
# then add a unique index on filehash whose name contains a dash — a
|
||||
# character that requires quoting by the dialect's identifier_preparer.
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE files ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"filename VARCHAR, "
|
||||
"filehash VARCHAR, "
|
||||
"original_file_path VARCHAR, "
|
||||
"processed_file_path VARCHAR, "
|
||||
"is_duplicate BOOLEAN DEFAULT FALSE NOT NULL, "
|
||||
"duplicate_of_id INTEGER)"
|
||||
)
|
||||
)
|
||||
# Index name deliberately contains a dash to exercise the quoting path.
|
||||
conn.execute(text('CREATE UNIQUE INDEX "ix-filehash-unique" ON files (filehash)'))
|
||||
conn.execute(text("INSERT INTO files (filename, filehash) VALUES ('doc.pdf', 'abc123')"))
|
||||
|
||||
# Confirm the index exists before migration.
|
||||
inspector = inspect(engine)
|
||||
pre_indexes = [idx["name"] for idx in inspector.get_indexes("files")]
|
||||
assert "ix-filehash-unique" in pre_indexes
|
||||
|
||||
# Run migration — must not raise despite the special character in the index name.
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
_run_schema_migrations(engine)
|
||||
|
||||
# The unique index on filehash must have been dropped.
|
||||
inspector = inspect(engine)
|
||||
post_indexes = inspector.get_indexes("files")
|
||||
remaining_unique_filehash = [
|
||||
idx for idx in post_indexes if idx.get("unique") and "filehash" in idx.get("column_names", [])
|
||||
]
|
||||
assert remaining_unique_filehash == [], (
|
||||
f"Expected unique filehash index to be dropped, but found: {remaining_unique_filehash}"
|
||||
)
|
||||
|
||||
# The table and its data must still be intact.
|
||||
files_columns = {col["name"] for col in inspector.get_columns("files")}
|
||||
assert "id" in files_columns
|
||||
assert "filename" in files_columns
|
||||
assert "filehash" in files_columns
|
||||
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(text("SELECT filename, filehash FROM files WHERE filehash = 'abc123'")).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "doc.pdf"
|
||||
assert row[1] == "abc123"
|
||||
|
||||
engine.dispose()
|
||||
|
||||
def test_migration_exception_handling(self, tmp_path):
|
||||
"""Test that migration handles exceptions gracefully for index operations."""
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
@@ -345,7 +345,7 @@ class TestSettingsPageWizardLink:
|
||||
template_path = Path(__file__).resolve().parent.parent / "frontend" / "templates" / "settings.html"
|
||||
content = template_path.read_text()
|
||||
assert "/database-wizard" in content
|
||||
assert "DB Wizard" in content
|
||||
assert "settings.db_wizard_btn" in content # i18n key (resolves to "DB Wizard")
|
||||
|
||||
def test_settings_template_has_help_link_rendering(self):
|
||||
"""Test that the settings template renders help_link metadata."""
|
||||
|
||||
@@ -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,419 @@
|
||||
"""
|
||||
Tests for the GraphQL API endpoint at /graphql.
|
||||
|
||||
Covers:
|
||||
- Schema introspection (endpoint availability + GraphiQL)
|
||||
- Query: documents (list, single, auth-gated)
|
||||
- Query: pipelines (list, single)
|
||||
- Query: settings (admin-only)
|
||||
- Query: users (admin-only)
|
||||
- Pagination and limit clamping
|
||||
- Sensitive setting keys are excluded
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.models import ApplicationSettings, FileRecord, Pipeline, PipelineStep, UserProfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def gql(client: TestClient, query: str, variables: dict | None = None) -> dict:
|
||||
"""Execute a GraphQL POST request and return the parsed JSON body."""
|
||||
payload: dict = {"query": query}
|
||||
if variables:
|
||||
payload["variables"] = variables
|
||||
response = client.post("/graphql", json=payload)
|
||||
assert response.status_code == 200, f"Unexpected status {response.status_code}: {response.text}"
|
||||
return response.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def file_record(db_session) -> FileRecord:
|
||||
rec = FileRecord(
|
||||
owner_id="user1",
|
||||
original_filename="invoice.pdf",
|
||||
local_filename="/workdir/tmp/invoice.pdf",
|
||||
file_size=1024,
|
||||
mime_type="application/pdf",
|
||||
filehash="abc123",
|
||||
)
|
||||
db_session.add(rec)
|
||||
db_session.commit()
|
||||
db_session.refresh(rec)
|
||||
return rec
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def pipeline_record(db_session) -> Pipeline:
|
||||
p = Pipeline(
|
||||
owner_id="user1",
|
||||
name="Test Pipeline",
|
||||
description="A pipeline for tests",
|
||||
is_default=False,
|
||||
is_active=True,
|
||||
)
|
||||
db_session.add(p)
|
||||
db_session.commit()
|
||||
db_session.refresh(p)
|
||||
|
||||
step = PipelineStep(
|
||||
pipeline_id=p.id,
|
||||
position=0,
|
||||
step_type="ocr",
|
||||
label="Run OCR",
|
||||
enabled=True,
|
||||
)
|
||||
db_session.add(step)
|
||||
db_session.commit()
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def setting_record(db_session) -> ApplicationSettings:
|
||||
s = ApplicationSettings(key="max_upload_size", value="104857600")
|
||||
db_session.add(s)
|
||||
db_session.commit()
|
||||
db_session.refresh(s)
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user_profile(db_session) -> UserProfile:
|
||||
profile = UserProfile(
|
||||
user_id="user1",
|
||||
display_name="Test User",
|
||||
is_blocked=False,
|
||||
subscription_tier="free",
|
||||
onboarding_completed=False,
|
||||
)
|
||||
db_session.add(profile)
|
||||
db_session.commit()
|
||||
db_session.refresh(profile)
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: endpoint availability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGraphQLEndpoint:
|
||||
"""Verify the /graphql endpoint is reachable and introspectable."""
|
||||
|
||||
def test_graphql_post_exists(self, client: TestClient):
|
||||
"""POST /graphql returns 200 for a valid introspection query."""
|
||||
result = gql(client, "{ __schema { queryType { name } } }")
|
||||
assert "data" in result
|
||||
assert result["data"]["__schema"]["queryType"]["name"] == "Query"
|
||||
|
||||
def test_graphql_get_returns_graphiql(self, client: TestClient):
|
||||
"""GET /graphql returns the GraphiQL playground HTML."""
|
||||
response = client.get("/graphql", headers={"Accept": "text/html"})
|
||||
assert response.status_code == 200
|
||||
assert "graphiql" in response.text.lower() or "graphql" in response.text.lower()
|
||||
|
||||
def test_graphql_schema_has_expected_types(self, client: TestClient):
|
||||
"""Schema exposes DocumentType, PipelineType, SettingType, UserType."""
|
||||
result = gql(
|
||||
client,
|
||||
"""
|
||||
{
|
||||
__schema {
|
||||
types { name }
|
||||
}
|
||||
}
|
||||
""",
|
||||
)
|
||||
type_names = {t["name"] for t in result["data"]["__schema"]["types"]}
|
||||
for expected in ("DocumentType", "PipelineType", "SettingType", "UserType"):
|
||||
assert expected in type_names, f"{expected} not found in schema"
|
||||
|
||||
def test_graphql_query_fields(self, client: TestClient):
|
||||
"""Root Query has documents, document, pipelines, pipeline, settings, users, user fields."""
|
||||
result = gql(
|
||||
client,
|
||||
"""
|
||||
{
|
||||
__type(name: "Query") {
|
||||
fields { name }
|
||||
}
|
||||
}
|
||||
""",
|
||||
)
|
||||
field_names = {f["name"] for f in result["data"]["__type"]["fields"]}
|
||||
for expected in ("documents", "document", "pipelines", "pipeline", "settings", "users", "user"):
|
||||
assert expected in field_names, f"Query field '{expected}' missing from schema"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: documents queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestDocumentsQuery:
|
||||
"""Tests for the documents and document queries."""
|
||||
|
||||
def test_list_documents_empty(self, client: TestClient):
|
||||
result = gql(client, "{ documents { id originalFilename } }")
|
||||
assert "errors" not in result
|
||||
assert result["data"]["documents"] == []
|
||||
|
||||
def test_list_documents_returns_records(self, client: TestClient, file_record: FileRecord):
|
||||
result = gql(client, "{ documents { id originalFilename mimeType fileSize } }")
|
||||
assert "errors" not in result
|
||||
docs = result["data"]["documents"]
|
||||
assert len(docs) == 1
|
||||
assert docs[0]["id"] == file_record.id
|
||||
assert docs[0]["originalFilename"] == "invoice.pdf"
|
||||
assert docs[0]["mimeType"] == "application/pdf"
|
||||
assert docs[0]["fileSize"] == 1024
|
||||
|
||||
def test_get_single_document(self, client: TestClient, file_record: FileRecord):
|
||||
result = gql(
|
||||
client,
|
||||
"query($id: Int!) { document(id: $id) { id originalFilename } }",
|
||||
variables={"id": file_record.id},
|
||||
)
|
||||
assert "errors" not in result
|
||||
assert result["data"]["document"]["id"] == file_record.id
|
||||
|
||||
def test_get_nonexistent_document_returns_null(self, client: TestClient):
|
||||
result = gql(client, "{ document(id: 99999) { id } }")
|
||||
assert "errors" not in result
|
||||
assert result["data"]["document"] is None
|
||||
|
||||
def test_documents_pagination(self, client: TestClient, db_session):
|
||||
for i in range(5):
|
||||
db_session.add(
|
||||
FileRecord(
|
||||
owner_id="user1",
|
||||
original_filename=f"doc{i}.pdf",
|
||||
local_filename=f"/workdir/tmp/doc{i}.pdf",
|
||||
file_size=100,
|
||||
filehash=f"hash{i}",
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
result_page1 = gql(client, "{ documents(limit: 2, offset: 0) { id } }")
|
||||
result_page2 = gql(client, "{ documents(limit: 2, offset: 2) { id } }")
|
||||
assert "errors" not in result_page1
|
||||
assert "errors" not in result_page2
|
||||
assert len(result_page1["data"]["documents"]) == 2
|
||||
assert len(result_page2["data"]["documents"]) == 2
|
||||
|
||||
def test_documents_limit_clamped_to_100(self, client: TestClient, db_session):
|
||||
# Requesting more than 100 should be silently clamped to 100
|
||||
for i in range(5):
|
||||
db_session.add(
|
||||
FileRecord(
|
||||
owner_id="user1",
|
||||
original_filename=f"big{i}.pdf",
|
||||
local_filename=f"/workdir/tmp/big{i}.pdf",
|
||||
file_size=100,
|
||||
filehash=f"bighash{i}",
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
result = gql(client, "{ documents(limit: 999) { id } }")
|
||||
assert "errors" not in result
|
||||
# Just verify it doesn't error and returns something
|
||||
assert isinstance(result["data"]["documents"], list)
|
||||
|
||||
def test_documents_filter_by_owner(self, client: TestClient, db_session):
|
||||
db_session.add(
|
||||
FileRecord(
|
||||
owner_id="alice",
|
||||
original_filename="alice.pdf",
|
||||
local_filename="/workdir/tmp/alice.pdf",
|
||||
file_size=100,
|
||||
filehash="alicehash",
|
||||
)
|
||||
)
|
||||
db_session.add(
|
||||
FileRecord(
|
||||
owner_id="bob",
|
||||
original_filename="bob.pdf",
|
||||
local_filename="/workdir/tmp/bob.pdf",
|
||||
file_size=200,
|
||||
filehash="bobhash",
|
||||
)
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
result = gql(client, '{ documents(ownerId: "alice") { id originalFilename } }')
|
||||
assert "errors" not in result
|
||||
docs = result["data"]["documents"]
|
||||
assert all(d["originalFilename"] == "alice.pdf" for d in docs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: pipelines queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestPipelinesQuery:
|
||||
"""Tests for the pipelines and pipeline queries."""
|
||||
|
||||
def test_list_pipelines_empty(self, client: TestClient):
|
||||
result = gql(client, "{ pipelines { id name } }")
|
||||
assert "errors" not in result
|
||||
assert result["data"]["pipelines"] == []
|
||||
|
||||
def test_list_pipelines_with_steps(self, client: TestClient, pipeline_record: Pipeline):
|
||||
result = gql(
|
||||
client,
|
||||
"""
|
||||
{
|
||||
pipelines {
|
||||
id name description isDefault isActive
|
||||
steps { id stepType position enabled }
|
||||
}
|
||||
}
|
||||
""",
|
||||
)
|
||||
assert "errors" not in result
|
||||
pipelines = result["data"]["pipelines"]
|
||||
assert len(pipelines) == 1
|
||||
assert pipelines[0]["name"] == "Test Pipeline"
|
||||
assert len(pipelines[0]["steps"]) == 1
|
||||
assert pipelines[0]["steps"][0]["stepType"] == "ocr"
|
||||
|
||||
def test_get_single_pipeline(self, client: TestClient, pipeline_record: Pipeline):
|
||||
result = gql(
|
||||
client,
|
||||
"query($id: Int!) { pipeline(id: $id) { id name steps { stepType } } }",
|
||||
variables={"id": pipeline_record.id},
|
||||
)
|
||||
assert "errors" not in result
|
||||
assert result["data"]["pipeline"]["id"] == pipeline_record.id
|
||||
assert result["data"]["pipeline"]["steps"][0]["stepType"] == "ocr"
|
||||
|
||||
def test_get_nonexistent_pipeline_returns_null(self, client: TestClient):
|
||||
result = gql(client, "{ pipeline(id: 99999) { id } }")
|
||||
assert "errors" not in result
|
||||
assert result["data"]["pipeline"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: settings query (admin-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSettingsQuery:
|
||||
"""Tests for the settings query."""
|
||||
|
||||
def test_settings_returns_data_when_no_auth(self, client: TestClient, setting_record: ApplicationSettings):
|
||||
"""When AUTH_ENABLED=False, settings are accessible (no auth required)."""
|
||||
result = gql(client, "{ settings { key value } }")
|
||||
assert "errors" not in result
|
||||
keys = [s["key"] for s in result["data"]["settings"]]
|
||||
assert "max_upload_size" in keys
|
||||
|
||||
def test_sensitive_settings_excluded(self, client: TestClient, db_session):
|
||||
"""Sensitive setting keys must never appear in the response."""
|
||||
sensitive_keys = [
|
||||
"openai_api_key",
|
||||
"session_secret",
|
||||
"azure_ai_key",
|
||||
"smtp_password",
|
||||
]
|
||||
for key in sensitive_keys:
|
||||
db_session.add(ApplicationSettings(key=key, value="super-secret"))
|
||||
db_session.commit()
|
||||
|
||||
result = gql(client, "{ settings { key value } }")
|
||||
assert "errors" not in result
|
||||
returned_keys = {s["key"] for s in result["data"]["settings"]}
|
||||
for key in sensitive_keys:
|
||||
assert key not in returned_keys, f"Sensitive key '{key}' was returned by GraphQL settings query"
|
||||
|
||||
def test_settings_auth_required_when_auth_enabled(self, client: TestClient):
|
||||
"""When AUTH_ENABLED=True and no user, settings query must return an error."""
|
||||
from app.config import settings as app_settings
|
||||
|
||||
with patch.object(app_settings, "auth_enabled", True):
|
||||
result = gql(client, "{ settings { key } }")
|
||||
# Should have errors because no user is authenticated
|
||||
assert "errors" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: users query (admin-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUsersQuery:
|
||||
"""Tests for the users and user queries."""
|
||||
|
||||
def test_users_returns_profiles_when_no_auth(self, client: TestClient, user_profile: UserProfile):
|
||||
"""When AUTH_ENABLED=False, users are accessible."""
|
||||
result = gql(client, "{ users { userId displayName subscriptionTier } }")
|
||||
assert "errors" not in result
|
||||
users = result["data"]["users"]
|
||||
assert any(u["userId"] == "user1" for u in users)
|
||||
|
||||
def test_get_user_by_id(self, client: TestClient, user_profile: UserProfile):
|
||||
result = gql(
|
||||
client,
|
||||
'query { user(userId: "user1") { userId displayName isBlocked } }',
|
||||
)
|
||||
assert "errors" not in result
|
||||
assert result["data"]["user"]["userId"] == "user1"
|
||||
assert result["data"]["user"]["displayName"] == "Test User"
|
||||
assert result["data"]["user"]["isBlocked"] is False
|
||||
|
||||
def test_get_nonexistent_user_returns_null(self, client: TestClient):
|
||||
result = gql(client, '{ user(userId: "nobody") { userId } }')
|
||||
assert "errors" not in result
|
||||
assert result["data"]["user"] is None
|
||||
|
||||
def test_users_auth_required_when_auth_enabled(self, client: TestClient):
|
||||
"""When AUTH_ENABLED=True and no user, users query must return an error."""
|
||||
from app.config import settings as app_settings
|
||||
|
||||
with patch.object(app_settings, "auth_enabled", True):
|
||||
result = gql(client, "{ users { userId } }")
|
||||
assert "errors" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: auth enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGraphQLAuth:
|
||||
"""Verify auth is enforced for all queries when AUTH_ENABLED=True."""
|
||||
|
||||
def test_documents_auth_required_when_auth_enabled(self, client: TestClient):
|
||||
from app.config import settings as app_settings
|
||||
|
||||
with patch.object(app_settings, "auth_enabled", True):
|
||||
result = gql(client, "{ documents { id } }")
|
||||
assert "errors" in result
|
||||
|
||||
def test_pipelines_auth_required_when_auth_enabled(self, client: TestClient):
|
||||
from app.config import settings as app_settings
|
||||
|
||||
with patch.object(app_settings, "auth_enabled", True):
|
||||
result = gql(client, "{ pipelines { id } }")
|
||||
assert "errors" in result
|
||||
@@ -0,0 +1,542 @@
|
||||
"""Tests for the i18n (internationalization) and l10n (localization) utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.utils.i18n import (
|
||||
DEFAULT_LANGUAGE,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
SUPPORTED_LANGUAGES,
|
||||
_parse_accept_language,
|
||||
detect_language,
|
||||
format_date,
|
||||
format_datetime,
|
||||
format_number,
|
||||
get_language_info,
|
||||
get_suggested_languages,
|
||||
reload_translations,
|
||||
translate,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Translation file integrity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTranslationFiles:
|
||||
"""Verify that all translation JSON files are valid and complete."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache(self) -> None:
|
||||
"""Clear translation cache before each test."""
|
||||
reload_translations()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_all_translation_files_exist(self) -> None:
|
||||
"""Every supported language must have a corresponding JSON file."""
|
||||
translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations"
|
||||
for lang in SUPPORTED_LANGUAGES:
|
||||
filepath = translations_dir / f"{lang['code']}.json"
|
||||
assert filepath.is_file(), f"Missing translation file for {lang['code']}"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_all_translation_files_are_valid_json(self) -> None:
|
||||
"""All translation files must be parseable JSON."""
|
||||
translations_dir = Path(__file__).resolve().parent.parent / "frontend" / "translations"
|
||||
for lang in SUPPORTED_LANGUAGES:
|
||||
filepath = translations_dir / f"{lang['code']}.json"
|
||||
data = json.loads(filepath.read_text(encoding="utf-8"))
|
||||
assert isinstance(data, dict), f"{lang['code']}.json must be a dict"
|
||||
assert len(data) > 0, f"{lang['code']}.json must not be empty"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core translate() function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTranslate:
|
||||
"""Tests for the translate() function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache(self) -> None:
|
||||
reload_translations()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_known_key_returns_non_empty_string(self) -> None:
|
||||
"""A valid key should resolve to a non-empty string (not the key itself)."""
|
||||
result = translate("nav.dashboard", "en")
|
||||
assert isinstance(result, str)
|
||||
assert result != ""
|
||||
assert result != "nav.dashboard"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_non_english_locale_returns_value(self) -> None:
|
||||
"""Non-English locales should return a non-empty translated value."""
|
||||
for locale in ("de", "fr", "zh"):
|
||||
result = translate("nav.dashboard", locale)
|
||||
assert isinstance(result, str)
|
||||
assert result != ""
|
||||
assert result != "nav.dashboard"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_fallback_to_english(self) -> None:
|
||||
"""Unknown locale falls back to English translation."""
|
||||
result = translate("nav.dashboard", "xx")
|
||||
english = translate("nav.dashboard", "en")
|
||||
assert result == english
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_missing_key_returns_key(self) -> None:
|
||||
"""Missing key falls back to the key itself."""
|
||||
result = translate("nonexistent.key", "en")
|
||||
assert result == "nonexistent.key"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_none_locale_uses_default(self) -> None:
|
||||
"""None locale defaults to English."""
|
||||
result = translate("nav.dashboard", None)
|
||||
english = translate("nav.dashboard", "en")
|
||||
assert result == english
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_with_kwargs(self) -> None:
|
||||
"""Placeholders should be interpolated via kwargs."""
|
||||
result = translate("footer.copyright", "en", year="2025")
|
||||
assert "2025" in result
|
||||
assert result != "footer.copyright"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translate_with_kwargs_german(self) -> None:
|
||||
"""Placeholder interpolation in German."""
|
||||
result = translate("language.changed", "de", language="English")
|
||||
assert "English" in result
|
||||
assert result != "language.changed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accept-Language header parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseAcceptLanguage:
|
||||
"""Tests for parsing the Accept-Language HTTP header."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_simple_language(self) -> None:
|
||||
assert _parse_accept_language("de") == "de"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_language_with_region(self) -> None:
|
||||
assert _parse_accept_language("de-DE") == "de"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_multiple_languages_quality(self) -> None:
|
||||
result = _parse_accept_language("fr;q=0.9, de;q=1.0, en;q=0.8")
|
||||
assert result == "de"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unsupported_language_fallback(self) -> None:
|
||||
result = _parse_accept_language("xx, yy")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_empty_header(self) -> None:
|
||||
assert _parse_accept_language("") is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_complex_accept_language(self) -> None:
|
||||
header = "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7"
|
||||
result = _parse_accept_language(header)
|
||||
assert result == "zh"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Language detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectLanguage:
|
||||
"""Tests for detecting language from request context."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_session_preference_takes_priority(self) -> None:
|
||||
request = MagicMock()
|
||||
request.session = {"preferred_language": "de"}
|
||||
request.cookies = {}
|
||||
request.headers = {}
|
||||
assert detect_language(request) == "de"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cookie_fallback(self) -> None:
|
||||
request = MagicMock()
|
||||
request.session = {}
|
||||
request.cookies = {"docuelevate_lang": "fr"}
|
||||
request.headers = {}
|
||||
assert detect_language(request) == "fr"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_accept_language_fallback(self) -> None:
|
||||
request = MagicMock()
|
||||
request.session = {}
|
||||
request.cookies = {}
|
||||
request.headers = {"accept-language": "es-ES,es;q=0.9"}
|
||||
assert detect_language(request) == "es"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_fallback(self) -> None:
|
||||
request = MagicMock()
|
||||
request.session = {}
|
||||
request.cookies = {}
|
||||
request.headers = {}
|
||||
assert detect_language(request) == DEFAULT_LANGUAGE
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_session_language_ignored(self) -> None:
|
||||
request = MagicMock()
|
||||
request.session = {"preferred_language": "invalid"}
|
||||
request.cookies = {"docuelevate_lang": "it"}
|
||||
request.headers = {}
|
||||
assert detect_language(request) == "it"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Localization helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestL10nFormatters:
|
||||
"""Tests for locale-aware formatting functions."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_date_english(self) -> None:
|
||||
d = date(2025, 3, 15)
|
||||
result = format_date(d, "en")
|
||||
assert "March" in result
|
||||
assert "15" in result
|
||||
assert "2025" in result
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_date_german(self) -> None:
|
||||
d = date(2025, 3, 15)
|
||||
result = format_date(d, "de")
|
||||
assert "15." in result
|
||||
assert "2025" in result
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_date_short(self) -> None:
|
||||
d = date(2025, 3, 15)
|
||||
result = format_date(d, "en", short=True)
|
||||
assert result == "03/15/2025"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_date_short_german(self) -> None:
|
||||
d = date(2025, 3, 15)
|
||||
result = format_date(d, "de", short=True)
|
||||
assert result == "15.03.2025"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_date_none(self) -> None:
|
||||
assert format_date(None) == ""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_datetime_none(self) -> None:
|
||||
assert format_datetime(None) == ""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_number_english(self) -> None:
|
||||
result = format_number(1234567, "en")
|
||||
assert result == "1,234,567"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_number_german(self) -> None:
|
||||
result = format_number(1234567, "de")
|
||||
assert result == "1.234.567"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_number_float_english(self) -> None:
|
||||
result = format_number(1234.56, "en")
|
||||
assert result == "1,234.56"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_number_float_german(self) -> None:
|
||||
result = format_number(1234.56, "de")
|
||||
assert result == "1.234,56"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_datetime_chinese(self) -> None:
|
||||
dt = datetime(2025, 3, 15, 14, 30)
|
||||
result = format_datetime(dt, "zh")
|
||||
assert "2025" in result
|
||||
assert "03" in result
|
||||
assert "15" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_language_info()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetLanguageInfo:
|
||||
"""Tests for get_language_info() utility."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_known_language(self) -> None:
|
||||
info = get_language_info("de")
|
||||
assert info is not None
|
||||
assert info["name"] == "German"
|
||||
assert info["native"] == "Deutsch"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unknown_language(self) -> None:
|
||||
assert get_language_info("xx") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_suggested_languages()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetSuggestedLanguages:
|
||||
"""Tests for get_suggested_languages() utility."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_returns_at_most_six(self) -> None:
|
||||
"""Result must contain at most 6 languages."""
|
||||
result = get_suggested_languages("en", "en,de;q=0.9,fr;q=0.8,es;q=0.7,it;q=0.6,pt;q=0.5,nl;q=0.4,zh;q=0.3")
|
||||
assert len(result) <= 6
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_current_locale_is_first(self) -> None:
|
||||
"""Currently active language must always be the first entry."""
|
||||
result = get_suggested_languages("de", "")
|
||||
assert result[0]["code"] == "de"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_includes_browser_preference(self) -> None:
|
||||
"""Languages from Accept-Language header should be included."""
|
||||
result = get_suggested_languages("en", "fr;q=0.9,de;q=0.8")
|
||||
codes = [lang["code"] for lang in result]
|
||||
assert "fr" in codes
|
||||
assert "de" in codes
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_fallback_to_popular_languages(self) -> None:
|
||||
"""Popular languages fill remaining slots when no browser prefs given."""
|
||||
result = get_suggested_languages("en", "")
|
||||
codes = [lang["code"] for lang in result]
|
||||
# en is current; popular fallbacks like zh, es, fr should be present
|
||||
assert "en" in codes
|
||||
# At least one other popular language should appear
|
||||
popular = {"zh", "es", "ar", "fr", "de", "ja", "pt", "hi", "ko"}
|
||||
assert popular & set(codes)
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_duplicates(self) -> None:
|
||||
"""No language code should appear more than once."""
|
||||
result = get_suggested_languages("fr", "fr;q=1.0,de;q=0.9")
|
||||
codes = [lang["code"] for lang in result]
|
||||
assert len(codes) == len(set(codes))
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_all_entries_are_valid_languages(self) -> None:
|
||||
"""Every returned entry must be a dict with required language fields."""
|
||||
result = get_suggested_languages("es", "ca;q=0.9")
|
||||
for entry in result:
|
||||
assert "code" in entry
|
||||
assert "name" in entry
|
||||
assert "native" in entry
|
||||
assert "flag" in entry
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unknown_locale_falls_back_gracefully(self) -> None:
|
||||
"""An unsupported current_locale must not crash and still return results."""
|
||||
result = get_suggested_languages("xx", "")
|
||||
assert len(result) > 0 # popular fallbacks still returned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SUPPORTED_LANGUAGES metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSupportedLanguages:
|
||||
"""Tests for language metadata constants."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ten_languages_supported(self) -> None:
|
||||
assert len(SUPPORTED_LANGUAGES) == 77
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_supported_codes_set(self) -> None:
|
||||
expected = {
|
||||
"en",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"it",
|
||||
"pt",
|
||||
"nl",
|
||||
"nb",
|
||||
"no",
|
||||
"da",
|
||||
"sv",
|
||||
"fi",
|
||||
"is",
|
||||
"ga",
|
||||
"lb",
|
||||
"ca",
|
||||
"cy",
|
||||
"fy",
|
||||
"gl",
|
||||
"li",
|
||||
"vls",
|
||||
"nds",
|
||||
"pl",
|
||||
"cs",
|
||||
"sk",
|
||||
"hu",
|
||||
"sl",
|
||||
"hr",
|
||||
"ro",
|
||||
"bg",
|
||||
"el",
|
||||
"et",
|
||||
"lv",
|
||||
"lt",
|
||||
"sr",
|
||||
"tr",
|
||||
"uk",
|
||||
"he",
|
||||
"ar",
|
||||
"fa",
|
||||
"af",
|
||||
"zh",
|
||||
"zh-TW",
|
||||
"ja",
|
||||
"ko",
|
||||
"vi",
|
||||
"pa",
|
||||
"kn",
|
||||
"hi",
|
||||
"bn",
|
||||
"gu",
|
||||
"ml",
|
||||
"mr",
|
||||
"ta",
|
||||
"te",
|
||||
"ur",
|
||||
"si",
|
||||
"ne",
|
||||
"th",
|
||||
"km",
|
||||
"id",
|
||||
"ms",
|
||||
"jv",
|
||||
"tl",
|
||||
"mn",
|
||||
"kk",
|
||||
"uz",
|
||||
"az",
|
||||
"hy",
|
||||
"ka",
|
||||
"sw",
|
||||
"am",
|
||||
"ha",
|
||||
"yo",
|
||||
"ig",
|
||||
"zu",
|
||||
"eo",
|
||||
}
|
||||
assert SUPPORTED_LANGUAGE_CODES == expected
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_language_is_english(self) -> None:
|
||||
assert DEFAULT_LANGUAGE == "en"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestI18nAPI:
|
||||
"""Tests for the i18n API endpoints."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_list_languages(self, client: TestClient) -> None:
|
||||
"""GET /api/i18n/languages should return all supported languages."""
|
||||
response = client.get("/api/i18n/languages")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "languages" in data
|
||||
assert len(data["languages"]) == 77
|
||||
assert data["default"] == "en"
|
||||
# Verify each language has required fields
|
||||
for lang in data["languages"]:
|
||||
assert "code" in lang
|
||||
assert "name" in lang
|
||||
assert "native" in lang
|
||||
assert "flag" in lang
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_set_language(self, client: TestClient) -> None:
|
||||
"""POST /api/i18n/language should set language preference."""
|
||||
response = client.post(
|
||||
"/api/i18n/language",
|
||||
json={"language": "de"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["language"] == "de"
|
||||
# Verify cookie was set
|
||||
assert "docuelevate_lang" in response.cookies
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_set_language_invalid_falls_back_to_default(self, client: TestClient) -> None:
|
||||
"""Invalid language code should fall back to default."""
|
||||
response = client.post(
|
||||
"/api/i18n/language",
|
||||
json={"language": "invalid"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["language"] == "en"
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_set_language_persists_in_cookie(self, client: TestClient) -> None:
|
||||
"""Language setting should be persisted in a cookie."""
|
||||
client.post("/api/i18n/language", json={"language": "fr"})
|
||||
# Subsequent requests should detect the language from cookie
|
||||
response = client.get("/api/i18n/languages")
|
||||
data = response.json()
|
||||
assert data["current"] == "fr"
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_base_html_uses_current_locale(self, client: TestClient) -> None:
|
||||
"""The base template should set lang attribute to current locale."""
|
||||
# Set language to German
|
||||
client.post("/api/i18n/language", json={"language": "de"})
|
||||
# Load homepage
|
||||
response = client.get("/", follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
# The lang attribute should reflect the locale
|
||||
assert 'lang="de"' in response.text or 'lang="en"' in response.text
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_language_selector_in_nav(self, client: TestClient) -> None:
|
||||
"""The navigation should contain the language selector with flag and search."""
|
||||
response = client.get("/", follow_redirects=True)
|
||||
if response.status_code == 200:
|
||||
# The selector renders a flag emoji (not the old fa-globe icon) and the
|
||||
# setLanguage JS helper for switching languages.
|
||||
assert "setLanguage" in response.text
|
||||
# The search input for filtering all languages must be present.
|
||||
assert "langSearch" in response.text
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tests for app/api/imap_profiles.py and app/utils/allowed_types category helpers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.allowed_types import (
|
||||
ALL_CATEGORIES,
|
||||
DEFAULT_CATEGORIES,
|
||||
FILE_TYPE_CATEGORIES,
|
||||
get_allowed_types_for_categories,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFileTypeCategories:
|
||||
"""Tests for FILE_TYPE_CATEGORIES and get_allowed_types_for_categories."""
|
||||
|
||||
def test_all_category_keys_present(self):
|
||||
"""Test that the six expected categories exist."""
|
||||
assert set(FILE_TYPE_CATEGORIES.keys()) == {"pdf", "office", "opendocument", "text", "web", "images"}
|
||||
|
||||
def test_each_category_has_required_fields(self):
|
||||
"""Test that every category entry has label, description, mime_types, extensions."""
|
||||
for key, info in FILE_TYPE_CATEGORIES.items():
|
||||
assert "label" in info, f"Category '{key}' missing 'label'"
|
||||
assert "description" in info, f"Category '{key}' missing 'description'"
|
||||
assert "mime_types" in info, f"Category '{key}' missing 'mime_types'"
|
||||
assert "extensions" in info, f"Category '{key}' missing 'extensions'"
|
||||
|
||||
def test_pdf_category_contains_pdf_mime(self):
|
||||
"""Test that the pdf category includes application/pdf."""
|
||||
assert "application/pdf" in FILE_TYPE_CATEGORIES["pdf"]["mime_types"]
|
||||
assert ".pdf" in FILE_TYPE_CATEGORIES["pdf"]["extensions"]
|
||||
|
||||
def test_images_category_contains_jpeg(self):
|
||||
"""Test that the images category includes image/jpeg."""
|
||||
assert "image/jpeg" in FILE_TYPE_CATEGORIES["images"]["mime_types"]
|
||||
assert ".jpg" in FILE_TYPE_CATEGORIES["images"]["extensions"]
|
||||
assert ".png" in FILE_TYPE_CATEGORIES["images"]["extensions"]
|
||||
|
||||
def test_get_allowed_types_for_default_categories(self):
|
||||
"""Test that DEFAULT_CATEGORIES excludes image MIME types."""
|
||||
mime_types, extensions = get_allowed_types_for_categories(DEFAULT_CATEGORIES)
|
||||
assert "application/pdf" in mime_types
|
||||
assert "application/msword" in mime_types
|
||||
# images should NOT be in the default
|
||||
assert "image/jpeg" not in mime_types
|
||||
assert ".jpg" not in extensions
|
||||
|
||||
def test_get_allowed_types_for_all_categories(self):
|
||||
"""Test that ALL_CATEGORIES includes image MIME types."""
|
||||
mime_types, extensions = get_allowed_types_for_categories(ALL_CATEGORIES)
|
||||
assert "image/jpeg" in mime_types
|
||||
assert ".jpg" in extensions
|
||||
assert "application/pdf" in mime_types
|
||||
|
||||
def test_get_allowed_types_returns_frozensets(self):
|
||||
"""Test that returned sets are frozensets."""
|
||||
mime_types, extensions = get_allowed_types_for_categories(["pdf"])
|
||||
assert isinstance(mime_types, frozenset)
|
||||
assert isinstance(extensions, frozenset)
|
||||
|
||||
def test_get_allowed_types_unknown_category_ignored(self):
|
||||
"""Test that unknown category keys are silently ignored."""
|
||||
mime_types, extensions = get_allowed_types_for_categories(["pdf", "nonexistent_category"])
|
||||
assert "application/pdf" in mime_types # 'pdf' still works
|
||||
# No crash for unknown key
|
||||
|
||||
def test_get_allowed_types_empty_list(self):
|
||||
"""Test empty category list returns empty sets."""
|
||||
mime_types, extensions = get_allowed_types_for_categories([])
|
||||
assert mime_types == frozenset()
|
||||
assert extensions == frozenset()
|
||||
|
||||
def test_default_categories_excludes_images(self):
|
||||
"""Test that DEFAULT_CATEGORIES does not include 'images'."""
|
||||
assert "images" not in DEFAULT_CATEGORIES
|
||||
|
||||
def test_all_categories_includes_images(self):
|
||||
"""Test that ALL_CATEGORIES includes 'images'."""
|
||||
assert "images" in ALL_CATEGORIES
|
||||
|
||||
def test_all_categories_is_superset_of_default(self):
|
||||
"""Test that ALL_CATEGORIES contains all DEFAULT_CATEGORIES."""
|
||||
for cat in DEFAULT_CATEGORIES:
|
||||
assert cat in ALL_CATEGORIES
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestImapProfilesApiLogic:
|
||||
"""Tests for ingestion profile validation helpers."""
|
||||
|
||||
def test_validate_categories_accepts_valid_keys(self):
|
||||
"""Test that valid category keys pass validation."""
|
||||
from app.api.imap_profiles import _validate_categories
|
||||
|
||||
result = _validate_categories(["pdf", "office", "images"])
|
||||
assert set(result) == {"pdf", "office", "images"}
|
||||
|
||||
def test_validate_categories_rejects_unknown_key(self):
|
||||
"""Test that unknown category keys raise 422."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.imap_profiles import _validate_categories
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_categories(["pdf", "nonexistent"])
|
||||
assert exc_info.value.status_code == 422
|
||||
assert "nonexistent" in str(exc_info.value.detail)
|
||||
|
||||
def test_validate_categories_deduplicates(self):
|
||||
"""Test that duplicate category keys are de-duplicated while preserving order."""
|
||||
from app.api.imap_profiles import _validate_categories
|
||||
|
||||
result = _validate_categories(["pdf", "pdf", "office", "pdf"])
|
||||
assert result == ["pdf", "office"]
|
||||
|
||||
def test_to_response_serializes_profile(self, tmp_path):
|
||||
"""Test _to_response produces expected dict shape."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.api.imap_profiles import _to_response
|
||||
|
||||
profile = MagicMock()
|
||||
profile.id = 42
|
||||
profile.name = "My Profile"
|
||||
profile.description = "Test description"
|
||||
profile.owner_id = "user@example.com"
|
||||
profile.allowed_categories = '["pdf","office"]'
|
||||
profile.is_builtin = False
|
||||
profile.created_at = None
|
||||
profile.updated_at = None
|
||||
|
||||
result = _to_response(profile)
|
||||
assert result["id"] == 42
|
||||
assert result["name"] == "My Profile"
|
||||
assert result["allowed_categories"] == ["pdf", "office"]
|
||||
assert len(result["categories_detail"]) == 2
|
||||
assert result["categories_detail"][0]["key"] == "pdf"
|
||||
assert result["is_builtin"] is False
|
||||
|
||||
def test_to_response_handles_invalid_categories_json(self):
|
||||
"""Test _to_response gracefully handles invalid JSON in allowed_categories."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.api.imap_profiles import _to_response
|
||||
|
||||
profile = MagicMock()
|
||||
profile.id = 1
|
||||
profile.name = "Broken"
|
||||
profile.description = None
|
||||
profile.owner_id = None
|
||||
profile.allowed_categories = "this is not valid json {"
|
||||
profile.is_builtin = True
|
||||
profile.created_at = None
|
||||
profile.updated_at = None
|
||||
|
||||
result = _to_response(profile)
|
||||
assert result["allowed_categories"] == []
|
||||
@@ -24,6 +24,7 @@ from app.tasks.imap_tasks import (
|
||||
release_lock,
|
||||
save_processed_emails,
|
||||
)
|
||||
from app.utils.allowed_types import ALL_CATEGORIES, DEFAULT_CATEGORIES, get_allowed_types_for_categories
|
||||
|
||||
_TEST_CREDENTIAL = "pass" # noqa: S105
|
||||
|
||||
@@ -179,6 +180,81 @@ class TestFetchAttachmentsAndEnqueue:
|
||||
assert result is True
|
||||
mock_convert.delay.assert_called_once()
|
||||
|
||||
@patch("app.tasks.imap_tasks.process_document")
|
||||
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
||||
def test_skips_image_when_documents_only(self, mock_convert, mock_process):
|
||||
"""Test that image attachments are skipped with the default (documents-only) categories."""
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Photo"
|
||||
msg.add_attachment(b"\xff\xd8\xff", maintype="image", subtype="jpeg", filename="photo.jpg")
|
||||
|
||||
doc_mime, doc_ext = get_allowed_types_for_categories(DEFAULT_CATEGORIES)
|
||||
result = fetch_attachments_and_enqueue(msg, effective_mime_types=doc_mime, effective_extensions=doc_ext)
|
||||
assert result is False
|
||||
mock_process.delay.assert_not_called()
|
||||
mock_convert.delay.assert_not_called()
|
||||
|
||||
@patch("app.tasks.imap_tasks.process_document")
|
||||
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
||||
def test_processes_image_when_all_categories(self, mock_convert, mock_process, tmp_path):
|
||||
"""Test that image attachments are processed when images category is included."""
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Photo"
|
||||
msg.add_attachment(b"\xff\xd8\xff", maintype="image", subtype="jpeg", filename="photo.jpg")
|
||||
|
||||
all_mime, all_ext = get_allowed_types_for_categories(ALL_CATEGORIES)
|
||||
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
result = fetch_attachments_and_enqueue(msg, effective_mime_types=all_mime, effective_extensions=all_ext)
|
||||
|
||||
assert result is True
|
||||
mock_convert.delay.assert_called_once()
|
||||
|
||||
@patch("app.tasks.imap_tasks.process_document")
|
||||
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
||||
def test_skips_image_with_image_extension_documents_only(self, mock_convert, mock_process):
|
||||
"""Test image files identified by extension are skipped with documents-only categories."""
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Screenshot"
|
||||
msg.add_attachment(b"\x89PNG", maintype="application", subtype="octet-stream", filename="screenshot.png")
|
||||
|
||||
doc_mime, doc_ext = get_allowed_types_for_categories(DEFAULT_CATEGORIES)
|
||||
result = fetch_attachments_and_enqueue(msg, effective_mime_types=doc_mime, effective_extensions=doc_ext)
|
||||
assert result is False
|
||||
mock_process.delay.assert_not_called()
|
||||
mock_convert.delay.assert_not_called()
|
||||
|
||||
@patch("app.tasks.imap_tasks.process_document")
|
||||
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
||||
def test_processes_pdf_regardless_of_categories(self, mock_convert, mock_process, tmp_path):
|
||||
"""Test that PDFs are always processed (pdf category always included in DEFAULT_CATEGORIES)."""
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Invoice"
|
||||
msg.add_attachment(b"%PDF-1.4", maintype="application", subtype="pdf", filename="invoice.pdf")
|
||||
|
||||
doc_mime, doc_ext = get_allowed_types_for_categories(DEFAULT_CATEGORIES)
|
||||
with patch("app.tasks.imap_tasks.settings") as mock_settings:
|
||||
mock_settings.workdir = str(tmp_path)
|
||||
result = fetch_attachments_and_enqueue(msg, effective_mime_types=doc_mime, effective_extensions=doc_ext)
|
||||
|
||||
assert result is True
|
||||
mock_process.delay.assert_called_once()
|
||||
|
||||
@patch("app.tasks.imap_tasks.process_document")
|
||||
@patch("app.tasks.imap_tasks.convert_to_pdf")
|
||||
def test_uses_default_categories_when_no_types_provided(self, mock_convert, mock_process):
|
||||
"""Test that images are skipped when no effective_mime_types / extensions are passed (defaults to DEFAULT_CATEGORIES)."""
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Photo"
|
||||
msg.add_attachment(b"\xff\xd8\xff", maintype="image", subtype="jpeg", filename="photo.jpg")
|
||||
|
||||
# No effective_mime_types passed → function defaults to DEFAULT_CATEGORIES (no images)
|
||||
result = fetch_attachments_and_enqueue(msg)
|
||||
|
||||
assert result is False
|
||||
mock_process.delay.assert_not_called()
|
||||
mock_convert.delay.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEmailAlreadyHasLabel:
|
||||
|
||||
@@ -613,6 +613,8 @@ async def test_local_login_success(la_session, active_user):
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.form = AsyncMock(return_value={"username": "activeuser", "password": "password123"})
|
||||
mock_request.session = {}
|
||||
mock_request.headers.get.return_value = None
|
||||
mock_request.client = None
|
||||
|
||||
result = await auth(mock_request, db=la_session)
|
||||
assert result.status_code == 302
|
||||
@@ -634,6 +636,8 @@ async def test_local_login_by_email(la_session, active_user):
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.form = AsyncMock(return_value={"username": "active@example.com", "password": "password123"})
|
||||
mock_request.session = {}
|
||||
mock_request.headers.get.return_value = None
|
||||
mock_request.client = None
|
||||
|
||||
result = await auth(mock_request, db=la_session)
|
||||
assert result.status_code == 302
|
||||
@@ -747,6 +751,85 @@ async def test_single_user_mode_skips_local_user_table(la_session, active_user):
|
||||
assert mock_request.session["user"]["id"] == "admin"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Case-insensitive login
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(settings, "multi_user_enabled", True)
|
||||
async def test_local_login_username_case_insensitive(la_session, active_user):
|
||||
"""auth() accepts username with different casing (e.g. 'ActiveUser' for 'activeuser')."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.auth import auth
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.form = AsyncMock(return_value={"username": "ActiveUser", "password": "password123"})
|
||||
mock_request.session = {}
|
||||
mock_request.headers.get.return_value = None
|
||||
mock_request.client = None
|
||||
|
||||
result = await auth(mock_request, db=la_session)
|
||||
assert result.status_code == 302
|
||||
assert "user" in mock_request.session
|
||||
assert mock_request.session["user"]["email"] == "active@example.com"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
@patch.object(settings, "multi_user_enabled", True)
|
||||
async def test_local_login_email_case_insensitive(la_session, active_user):
|
||||
"""auth() accepts email with different casing (e.g. 'Active@Example.com' for 'active@example.com')."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.auth import auth
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.form = AsyncMock(return_value={"username": "Active@Example.com", "password": "password123"})
|
||||
mock_request.session = {}
|
||||
mock_request.headers.get.return_value = None
|
||||
mock_request.client = None
|
||||
|
||||
result = await auth(mock_request, db=la_session)
|
||||
assert result.status_code == 302
|
||||
assert "user" in mock_request.session
|
||||
assert mock_request.session["user"]["email"] == "active@example.com"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_login_username_case_insensitive():
|
||||
"""auth() admin credential check is case-insensitive for username."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.auth import auth
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.form = AsyncMock(return_value={"username": "ADMIN", "password": "adminpass"})
|
||||
mock_request.session = {}
|
||||
mock_request.headers.get.return_value = None
|
||||
mock_request.client = None
|
||||
|
||||
with patch("app.auth.settings") as mock_settings:
|
||||
mock_settings.admin_username = "admin"
|
||||
mock_settings.admin_password = "adminpass"
|
||||
mock_settings.multi_user_enabled = False
|
||||
|
||||
result = await auth(mock_request, db=MagicMock())
|
||||
|
||||
assert result.status_code == 302
|
||||
assert "user" in mock_request.session
|
||||
assert mock_request.session["user"]["is_admin"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: admin local user management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -212,9 +212,10 @@ class TestExtractMetadataFilenameValidation:
|
||||
"""Test that invalid filename formats are rejected."""
|
||||
import re
|
||||
|
||||
# Valid pattern from extract_metadata_with_gpt.py
|
||||
# TODO: Consider extracting this to a shared constant to avoid duplication
|
||||
valid_pattern = r"^[\w\-\. ]+$"
|
||||
from app.utils.filename_utils import VALID_FILENAME_PATTERN
|
||||
|
||||
# Valid pattern from app.utils.filename_utils
|
||||
valid_pattern = VALID_FILENAME_PATTERN
|
||||
|
||||
# Test valid filenames
|
||||
valid_filenames = [
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
"""Tests for the routing rules API and routing engine.
|
||||
|
||||
Covers CRUD operations for routing rules, rule evaluation (dry-run and engine),
|
||||
operator logic, access control, and edge cases.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import FileRecord, Pipeline, PipelineRoutingRule
|
||||
from app.utils.routing_engine import (
|
||||
BUILTIN_FIELDS,
|
||||
VALID_OPERATORS,
|
||||
_evaluate_condition,
|
||||
_resolve_field,
|
||||
_to_float,
|
||||
build_document_properties,
|
||||
evaluate_routing_rules,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_pipeline(db_session, name="Test Pipeline", owner_id="testuser", is_active=True):
|
||||
"""Insert a minimal Pipeline and return it."""
|
||||
p = Pipeline(owner_id=owner_id, name=name, is_default=False, is_active=is_active)
|
||||
db_session.add(p)
|
||||
db_session.commit()
|
||||
db_session.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
def _make_rule(db_session, target_pipeline_id, **kwargs):
|
||||
"""Insert a PipelineRoutingRule and return it."""
|
||||
defaults = {
|
||||
"owner_id": "testuser",
|
||||
"name": "Test Rule",
|
||||
"position": 0,
|
||||
"field": "file_type",
|
||||
"operator": "equals",
|
||||
"value": "application/pdf",
|
||||
"is_active": True,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
defaults["target_pipeline_id"] = target_pipeline_id
|
||||
rule = PipelineRoutingRule(**defaults)
|
||||
db_session.add(rule)
|
||||
db_session.commit()
|
||||
db_session.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
def _make_file_record(db_session, **kwargs):
|
||||
"""Insert a minimal FileRecord and return it."""
|
||||
defaults = {
|
||||
"owner_id": "testuser",
|
||||
"filehash": "abc123",
|
||||
"original_filename": "test.pdf",
|
||||
"local_filename": "/tmp/test.pdf",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/pdf",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
fr = FileRecord(**defaults)
|
||||
db_session.add(fr)
|
||||
db_session.commit()
|
||||
db_session.refresh(fr)
|
||||
return fr
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Unit tests – routing engine
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestResolveField:
|
||||
"""Tests for _resolve_field()."""
|
||||
|
||||
def test_builtin_field(self):
|
||||
"""Built-in fields are resolved directly from the dict."""
|
||||
props = {"file_type": "application/pdf", "size": 1024}
|
||||
assert _resolve_field("file_type", props) == "application/pdf"
|
||||
assert _resolve_field("size", props) == 1024
|
||||
|
||||
def test_category_alias(self):
|
||||
"""'category' is an alias for 'document_type'."""
|
||||
props = {"document_type": "Invoice"}
|
||||
assert _resolve_field("category", props) == "Invoice"
|
||||
|
||||
def test_metadata_field(self):
|
||||
"""'metadata.<key>' resolves from the nested metadata dict."""
|
||||
props = {"metadata": {"sender": "Acme Corp", "amount": 100.50}}
|
||||
assert _resolve_field("metadata.sender", props) == "Acme Corp"
|
||||
assert _resolve_field("metadata.amount", props) == 100.50
|
||||
|
||||
def test_missing_metadata_key(self):
|
||||
"""Missing metadata key returns None."""
|
||||
props = {"metadata": {"sender": "Acme"}}
|
||||
assert _resolve_field("metadata.missing_key", props) is None
|
||||
|
||||
def test_missing_metadata_dict(self):
|
||||
"""Missing metadata dict returns None."""
|
||||
props = {}
|
||||
assert _resolve_field("metadata.sender", props) is None
|
||||
|
||||
def test_missing_builtin_field(self):
|
||||
"""Missing built-in field returns None."""
|
||||
props = {}
|
||||
assert _resolve_field("file_type", props) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestToFloat:
|
||||
"""Tests for _to_float()."""
|
||||
|
||||
def test_int_value(self):
|
||||
assert _to_float(42) == 42.0
|
||||
|
||||
def test_float_value(self):
|
||||
assert _to_float(3.14) == 3.14
|
||||
|
||||
def test_string_number(self):
|
||||
assert _to_float("100") == 100.0
|
||||
|
||||
def test_none_returns_none(self):
|
||||
assert _to_float(None) is None
|
||||
|
||||
def test_non_numeric_string_returns_none(self):
|
||||
assert _to_float("not-a-number") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEvaluateCondition:
|
||||
"""Tests for _evaluate_condition()."""
|
||||
|
||||
def test_equals_match(self):
|
||||
assert _evaluate_condition("application/pdf", "equals", "application/pdf") is True
|
||||
|
||||
def test_equals_case_insensitive(self):
|
||||
assert _evaluate_condition("Application/PDF", "equals", "application/pdf") is True
|
||||
|
||||
def test_equals_no_match(self):
|
||||
assert _evaluate_condition("image/png", "equals", "application/pdf") is False
|
||||
|
||||
def test_not_equals_match(self):
|
||||
assert _evaluate_condition("image/png", "not_equals", "application/pdf") is True
|
||||
|
||||
def test_not_equals_no_match(self):
|
||||
assert _evaluate_condition("application/pdf", "not_equals", "application/pdf") is False
|
||||
|
||||
def test_contains_match(self):
|
||||
assert _evaluate_condition("invoice_2024.pdf", "contains", "invoice") is True
|
||||
|
||||
def test_contains_case_insensitive(self):
|
||||
assert _evaluate_condition("INVOICE_2024.PDF", "contains", "invoice") is True
|
||||
|
||||
def test_contains_no_match(self):
|
||||
assert _evaluate_condition("receipt.pdf", "contains", "invoice") is False
|
||||
|
||||
def test_not_contains_match(self):
|
||||
assert _evaluate_condition("receipt.pdf", "not_contains", "invoice") is True
|
||||
|
||||
def test_not_contains_no_match(self):
|
||||
assert _evaluate_condition("invoice_2024.pdf", "not_contains", "invoice") is False
|
||||
|
||||
def test_regex_match(self):
|
||||
assert _evaluate_condition("invoice_2024.pdf", "regex", r"invoice_\d+\.pdf") is True
|
||||
|
||||
def test_regex_no_match(self):
|
||||
assert _evaluate_condition("receipt.pdf", "regex", r"invoice_\d+\.pdf") is False
|
||||
|
||||
def test_regex_case_insensitive(self):
|
||||
assert _evaluate_condition("INVOICE_2024.PDF", "regex", r"invoice_\d+\.pdf") is True
|
||||
|
||||
def test_regex_invalid_pattern(self):
|
||||
"""Invalid regex should return False, not raise."""
|
||||
assert _evaluate_condition("test", "regex", r"[invalid") is False
|
||||
|
||||
def test_gt(self):
|
||||
assert _evaluate_condition(2048, "gt", "1024") is True
|
||||
assert _evaluate_condition(1024, "gt", "1024") is False
|
||||
|
||||
def test_lt(self):
|
||||
assert _evaluate_condition(512, "lt", "1024") is True
|
||||
assert _evaluate_condition(1024, "lt", "1024") is False
|
||||
|
||||
def test_gte(self):
|
||||
assert _evaluate_condition(1024, "gte", "1024") is True
|
||||
assert _evaluate_condition(2048, "gte", "1024") is True
|
||||
assert _evaluate_condition(512, "gte", "1024") is False
|
||||
|
||||
def test_lte(self):
|
||||
assert _evaluate_condition(1024, "lte", "1024") is True
|
||||
assert _evaluate_condition(512, "lte", "1024") is True
|
||||
assert _evaluate_condition(2048, "lte", "1024") is False
|
||||
|
||||
def test_none_actual_returns_false(self):
|
||||
"""When the actual value is None, most operators return False."""
|
||||
assert _evaluate_condition(None, "equals", "test") is False
|
||||
assert _evaluate_condition(None, "contains", "test") is False
|
||||
assert _evaluate_condition(None, "regex", "test") is False
|
||||
assert _evaluate_condition(None, "gt", "10") is False
|
||||
|
||||
def test_none_actual_not_equals_returns_true(self):
|
||||
"""not_equals should return True when actual is None."""
|
||||
assert _evaluate_condition(None, "not_equals", "test") is True
|
||||
|
||||
def test_none_actual_not_contains_returns_true(self):
|
||||
"""not_contains should return True when actual is None."""
|
||||
assert _evaluate_condition(None, "not_contains", "test") is True
|
||||
|
||||
def test_non_numeric_gt_returns_false(self):
|
||||
"""Non-numeric values should return False for numeric operators."""
|
||||
assert _evaluate_condition("abc", "gt", "100") is False
|
||||
|
||||
def test_unknown_operator_returns_false(self):
|
||||
"""Unknown operator should return False."""
|
||||
assert _evaluate_condition("test", "unknown_op", "test") is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestBuildDocumentProperties:
|
||||
"""Tests for build_document_properties()."""
|
||||
|
||||
def test_basic_properties(self):
|
||||
"""Properties are extracted from FileRecord attributes."""
|
||||
fr = _MockFileRecord(
|
||||
mime_type="application/pdf",
|
||||
original_filename="test.pdf",
|
||||
file_size=2048,
|
||||
ai_metadata=json.dumps({"document_type": "Invoice", "sender": "Acme"}),
|
||||
)
|
||||
props = build_document_properties(fr)
|
||||
assert props["file_type"] == "application/pdf"
|
||||
assert props["filename"] == "test.pdf"
|
||||
assert props["size"] == 2048
|
||||
assert props["document_type"] == "Invoice"
|
||||
assert props["metadata"]["sender"] == "Acme"
|
||||
|
||||
def test_no_metadata(self):
|
||||
"""When ai_metadata is None, metadata is an empty dict."""
|
||||
fr = _MockFileRecord(mime_type="image/png", original_filename="img.png", file_size=512, ai_metadata=None)
|
||||
props = build_document_properties(fr)
|
||||
assert props["metadata"] == {}
|
||||
assert props["document_type"] is None
|
||||
|
||||
def test_invalid_metadata_json(self):
|
||||
"""Invalid JSON in ai_metadata should result in empty metadata."""
|
||||
fr = _MockFileRecord(
|
||||
mime_type="application/pdf",
|
||||
original_filename="test.pdf",
|
||||
file_size=1024,
|
||||
ai_metadata="not-json",
|
||||
)
|
||||
props = build_document_properties(fr)
|
||||
assert props["metadata"] == {}
|
||||
|
||||
|
||||
class _MockFileRecord:
|
||||
"""Lightweight stand-in for FileRecord in unit tests."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Unit tests – evaluate_routing_rules (DB-backed)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEvaluateRoutingRules:
|
||||
"""Tests for evaluate_routing_rules() with a real DB session."""
|
||||
|
||||
def test_first_match_wins(self, db_session):
|
||||
"""The first matching rule (by position) is used."""
|
||||
p1 = _make_pipeline(db_session, name="Pipeline A")
|
||||
p2 = _make_pipeline(db_session, name="Pipeline B")
|
||||
|
||||
_make_rule(db_session, p1.id, position=0, field="file_type", operator="equals", value="application/pdf")
|
||||
_make_rule(db_session, p2.id, position=1, field="file_type", operator="equals", value="application/pdf")
|
||||
|
||||
doc = {"file_type": "application/pdf", "metadata": {}}
|
||||
result = evaluate_routing_rules(db_session, "testuser", doc)
|
||||
assert result is not None
|
||||
assert result.id == p1.id
|
||||
|
||||
def test_no_match_returns_none(self, db_session):
|
||||
"""When no rule matches, None is returned (caller uses default)."""
|
||||
p = _make_pipeline(db_session, name="Pipeline A")
|
||||
_make_rule(db_session, p.id, field="file_type", operator="equals", value="image/png")
|
||||
|
||||
doc = {"file_type": "application/pdf", "metadata": {}}
|
||||
result = evaluate_routing_rules(db_session, "testuser", doc)
|
||||
assert result is None
|
||||
|
||||
def test_inactive_rule_skipped(self, db_session):
|
||||
"""Inactive rules are not evaluated."""
|
||||
p = _make_pipeline(db_session, name="Pipeline A")
|
||||
_make_rule(db_session, p.id, is_active=False)
|
||||
|
||||
doc = {"file_type": "application/pdf", "metadata": {}}
|
||||
result = evaluate_routing_rules(db_session, "testuser", doc)
|
||||
assert result is None
|
||||
|
||||
def test_inactive_pipeline_skipped(self, db_session):
|
||||
"""Matching rule with inactive target pipeline is skipped."""
|
||||
p = _make_pipeline(db_session, name="Inactive Pipeline", is_active=False)
|
||||
_make_rule(db_session, p.id)
|
||||
|
||||
doc = {"file_type": "application/pdf", "metadata": {}}
|
||||
result = evaluate_routing_rules(db_session, "testuser", doc)
|
||||
assert result is None
|
||||
|
||||
def test_system_rules_evaluated_after_user_rules(self, db_session):
|
||||
"""System rules (owner_id=NULL) are evaluated after user-specific rules."""
|
||||
p_user = _make_pipeline(db_session, name="User Pipeline")
|
||||
p_system = _make_pipeline(db_session, name="System Pipeline", owner_id=None)
|
||||
|
||||
# System rule at position 0, user rule at position 1 — user should still win.
|
||||
_make_rule(
|
||||
db_session,
|
||||
p_system.id,
|
||||
owner_id=None,
|
||||
position=0,
|
||||
field="file_type",
|
||||
operator="equals",
|
||||
value="application/pdf",
|
||||
name="System Rule",
|
||||
)
|
||||
_make_rule(
|
||||
db_session,
|
||||
p_user.id,
|
||||
owner_id="testuser",
|
||||
position=1,
|
||||
field="file_type",
|
||||
operator="equals",
|
||||
value="application/pdf",
|
||||
name="User Rule",
|
||||
)
|
||||
|
||||
doc = {"file_type": "application/pdf", "metadata": {}}
|
||||
result = evaluate_routing_rules(db_session, "testuser", doc)
|
||||
assert result is not None
|
||||
assert result.id == p_user.id
|
||||
|
||||
def test_metadata_field_routing(self, db_session):
|
||||
"""Rules can match on metadata.* fields."""
|
||||
p = _make_pipeline(db_session, name="Invoice Pipeline")
|
||||
_make_rule(
|
||||
db_session,
|
||||
p.id,
|
||||
field="metadata.sender",
|
||||
operator="contains",
|
||||
value="acme",
|
||||
)
|
||||
|
||||
doc = {"file_type": "application/pdf", "metadata": {"sender": "Acme Corporation"}}
|
||||
result = evaluate_routing_rules(db_session, "testuser", doc)
|
||||
assert result is not None
|
||||
assert result.id == p.id
|
||||
|
||||
def test_size_routing(self, db_session):
|
||||
"""Rules can match on file size with numeric comparison."""
|
||||
p = _make_pipeline(db_session, name="Large File Pipeline")
|
||||
_make_rule(
|
||||
db_session,
|
||||
p.id,
|
||||
field="size",
|
||||
operator="gt",
|
||||
value="1048576",
|
||||
)
|
||||
|
||||
# 2 MB file
|
||||
doc = {"size": 2097152, "metadata": {}}
|
||||
result = evaluate_routing_rules(db_session, "testuser", doc)
|
||||
assert result is not None
|
||||
assert result.id == p.id
|
||||
|
||||
def test_regex_routing(self, db_session):
|
||||
"""Rules can match using regex on filename."""
|
||||
p = _make_pipeline(db_session, name="Invoice Pipeline")
|
||||
_make_rule(
|
||||
db_session,
|
||||
p.id,
|
||||
field="filename",
|
||||
operator="regex",
|
||||
value=r"invoice_\d{4}.*",
|
||||
)
|
||||
|
||||
doc = {"filename": "invoice_2024_q1.pdf", "metadata": {}}
|
||||
result = evaluate_routing_rules(db_session, "testuser", doc)
|
||||
assert result is not None
|
||||
assert result.id == p.id
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# API tests – operators endpoint (public)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOperatorsEndpoint:
|
||||
"""Tests for the /api/routing-rules/operators catalogue endpoint."""
|
||||
|
||||
def test_operators_returns_lists(self, client):
|
||||
"""GET /api/routing-rules/operators returns operators and fields."""
|
||||
r = client.get("/api/routing-rules/operators")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "operators" in data
|
||||
assert "builtin_fields" in data
|
||||
assert "metadata_prefix" in data
|
||||
assert set(data["operators"]) == VALID_OPERATORS
|
||||
assert set(data["builtin_fields"]) == BUILTIN_FIELDS
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# API tests – CRUD
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestRoutingRuleCRUD:
|
||||
"""Tests for the routing rules CRUD API endpoints."""
|
||||
|
||||
def test_create_rule(self, client, db_session):
|
||||
"""POST /api/routing-rules creates a new rule."""
|
||||
p = _make_pipeline(db_session, name="Target Pipeline")
|
||||
r = client.post(
|
||||
"/api/routing-rules",
|
||||
json={
|
||||
"name": "Route PDFs",
|
||||
"field": "file_type",
|
||||
"operator": "equals",
|
||||
"value": "application/pdf",
|
||||
"target_pipeline_id": p.id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
data = r.json()
|
||||
assert data["name"] == "Route PDFs"
|
||||
assert data["field"] == "file_type"
|
||||
assert data["operator"] == "equals"
|
||||
assert data["target_pipeline_id"] == p.id
|
||||
assert data["is_active"] is True
|
||||
|
||||
def test_create_rule_invalid_field(self, client, db_session):
|
||||
"""POST with an invalid field returns 422."""
|
||||
p = _make_pipeline(db_session, name="Target")
|
||||
r = client.post(
|
||||
"/api/routing-rules",
|
||||
json={
|
||||
"name": "Bad field",
|
||||
"field": "invalid_field",
|
||||
"operator": "equals",
|
||||
"value": "test",
|
||||
"target_pipeline_id": p.id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_create_rule_invalid_operator(self, client, db_session):
|
||||
"""POST with an invalid operator returns 422."""
|
||||
p = _make_pipeline(db_session, name="Target")
|
||||
r = client.post(
|
||||
"/api/routing-rules",
|
||||
json={
|
||||
"name": "Bad op",
|
||||
"field": "file_type",
|
||||
"operator": "invalid_op",
|
||||
"value": "test",
|
||||
"target_pipeline_id": p.id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_create_rule_missing_pipeline(self, client):
|
||||
"""POST referencing a nonexistent pipeline returns 404."""
|
||||
r = client.post(
|
||||
"/api/routing-rules",
|
||||
json={
|
||||
"name": "No pipeline",
|
||||
"field": "file_type",
|
||||
"operator": "equals",
|
||||
"value": "application/pdf",
|
||||
"target_pipeline_id": 99999,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_list_rules(self, client, db_session):
|
||||
"""GET /api/routing-rules returns the user's rules."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
_make_rule(db_session, p.id, name="Rule 1", position=0, owner_id="anonymous")
|
||||
_make_rule(db_session, p.id, name="Rule 2", position=1, owner_id="anonymous")
|
||||
|
||||
r = client.get("/api/routing-rules")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data) >= 2
|
||||
names = [d["name"] for d in data]
|
||||
assert "Rule 1" in names
|
||||
assert "Rule 2" in names
|
||||
|
||||
def test_get_rule(self, client, db_session):
|
||||
"""GET /api/routing-rules/{id} returns a specific rule."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
rule = _make_rule(db_session, p.id, name="My Rule", owner_id="anonymous")
|
||||
|
||||
r = client.get(f"/api/routing-rules/{rule.id}")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["name"] == "My Rule"
|
||||
|
||||
def test_get_rule_not_found(self, client):
|
||||
"""GET /api/routing-rules/{id} returns 404 for nonexistent rule."""
|
||||
r = client.get("/api/routing-rules/99999")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_update_rule(self, client, db_session):
|
||||
"""PUT /api/routing-rules/{id} updates a rule."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
rule = _make_rule(db_session, p.id, name="Old Name", owner_id="anonymous")
|
||||
|
||||
r = client.put(
|
||||
f"/api/routing-rules/{rule.id}",
|
||||
json={"name": "New Name", "operator": "contains"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["name"] == "New Name"
|
||||
assert data["operator"] == "contains"
|
||||
|
||||
def test_update_rule_invalid_field(self, client, db_session):
|
||||
"""PUT with invalid field returns 422."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
rule = _make_rule(db_session, p.id, owner_id="anonymous")
|
||||
|
||||
r = client.put(f"/api/routing-rules/{rule.id}", json={"field": "bad_field"})
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_update_rule_invalid_operator(self, client, db_session):
|
||||
"""PUT with invalid operator returns 422."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
rule = _make_rule(db_session, p.id, owner_id="anonymous")
|
||||
|
||||
r = client.put(f"/api/routing-rules/{rule.id}", json={"operator": "bad_op"})
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_update_rule_missing_pipeline(self, client, db_session):
|
||||
"""PUT with nonexistent target pipeline returns 404."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
rule = _make_rule(db_session, p.id, owner_id="anonymous")
|
||||
|
||||
r = client.put(f"/api/routing-rules/{rule.id}", json={"target_pipeline_id": 99999})
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_delete_rule(self, client, db_session):
|
||||
"""DELETE /api/routing-rules/{id} removes a rule."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
rule = _make_rule(db_session, p.id, owner_id="anonymous")
|
||||
|
||||
r = client.delete(f"/api/routing-rules/{rule.id}")
|
||||
assert r.status_code == 204
|
||||
|
||||
# Verify it's gone
|
||||
r = client.get(f"/api/routing-rules/{rule.id}")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_delete_rule_not_found(self, client):
|
||||
"""DELETE for nonexistent rule returns 404."""
|
||||
r = client.delete("/api/routing-rules/99999")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# API tests – reorder
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReorderRoutingRules:
|
||||
"""Tests for the PUT /api/routing-rules/reorder endpoint."""
|
||||
|
||||
def test_reorder_rules(self, client, db_session):
|
||||
"""Reordering updates the position of rules."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
r1 = _make_rule(db_session, p.id, name="A", position=0, owner_id="anonymous")
|
||||
r2 = _make_rule(db_session, p.id, name="B", position=1, owner_id="anonymous")
|
||||
|
||||
r = client.put(
|
||||
"/api/routing-rules/reorder",
|
||||
json={"rule_ids": [r2.id, r1.id]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data[0]["id"] == r2.id
|
||||
assert data[0]["position"] == 0
|
||||
assert data[1]["id"] == r1.id
|
||||
assert data[1]["position"] == 1
|
||||
|
||||
def test_reorder_invalid_ids(self, client, db_session):
|
||||
"""Reorder with invalid IDs returns 422."""
|
||||
r = client.put(
|
||||
"/api/routing-rules/reorder",
|
||||
json={"rule_ids": [99999]},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# API tests – evaluate (dry-run)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEvaluateEndpoint:
|
||||
"""Tests for the POST /api/routing-rules/evaluate dry-run endpoint."""
|
||||
|
||||
def test_evaluate_match(self, client, db_session):
|
||||
"""Evaluate returns the matching rule and target pipeline."""
|
||||
p = _make_pipeline(db_session, name="Invoice Pipeline")
|
||||
_make_rule(
|
||||
db_session,
|
||||
p.id,
|
||||
name="PDF Route",
|
||||
field="file_type",
|
||||
operator="equals",
|
||||
value="application/pdf",
|
||||
owner_id="anonymous",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/routing-rules/evaluate",
|
||||
json={"file_type": "application/pdf"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["matched"] is True
|
||||
assert data["rule"]["name"] == "PDF Route"
|
||||
assert data["target_pipeline"]["id"] == p.id
|
||||
|
||||
def test_evaluate_no_match(self, client, db_session):
|
||||
"""Evaluate returns matched=False when no rule applies."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
_make_rule(
|
||||
db_session,
|
||||
p.id,
|
||||
name="PNG Route",
|
||||
field="file_type",
|
||||
operator="equals",
|
||||
value="image/png",
|
||||
owner_id="anonymous",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/routing-rules/evaluate",
|
||||
json={"file_type": "application/pdf"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["matched"] is False
|
||||
assert data["rule"] is None
|
||||
|
||||
def test_evaluate_with_metadata(self, client, db_session):
|
||||
"""Evaluate with metadata.* fields works."""
|
||||
p = _make_pipeline(db_session, name="Invoice Pipeline")
|
||||
_make_rule(
|
||||
db_session,
|
||||
p.id,
|
||||
name="Invoice Route",
|
||||
field="metadata.sender",
|
||||
operator="contains",
|
||||
value="acme",
|
||||
owner_id="anonymous",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/routing-rules/evaluate",
|
||||
json={"metadata": {"sender": "Acme Corporation"}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["matched"] is True
|
||||
|
||||
def test_evaluate_with_size(self, client, db_session):
|
||||
"""Evaluate with size comparisons works."""
|
||||
p = _make_pipeline(db_session, name="Large Pipeline")
|
||||
_make_rule(
|
||||
db_session,
|
||||
p.id,
|
||||
name="Large Files",
|
||||
field="size",
|
||||
operator="gt",
|
||||
value="1000000",
|
||||
owner_id="anonymous",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/routing-rules/evaluate",
|
||||
json={"size": 2000000},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["matched"] is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# API tests – metadata field validation
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFieldValidation:
|
||||
"""Tests for field validation in routing rule creation."""
|
||||
|
||||
def test_metadata_prefix_accepted(self, client, db_session):
|
||||
"""Fields with 'metadata.' prefix are valid."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
r = client.post(
|
||||
"/api/routing-rules",
|
||||
json={
|
||||
"name": "Metadata Rule",
|
||||
"field": "metadata.sender",
|
||||
"operator": "equals",
|
||||
"value": "test",
|
||||
"target_pipeline_id": p.id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
def test_bare_metadata_rejected(self, client, db_session):
|
||||
"""Just 'metadata.' without a key suffix is invalid."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
r = client.post(
|
||||
"/api/routing-rules",
|
||||
json={
|
||||
"name": "Bad Metadata",
|
||||
"field": "metadata.",
|
||||
"operator": "equals",
|
||||
"value": "test",
|
||||
"target_pipeline_id": p.id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
@pytest.mark.parametrize("field", sorted(BUILTIN_FIELDS))
|
||||
def test_builtin_fields_accepted(self, client, db_session, field):
|
||||
"""All built-in fields are accepted."""
|
||||
p = _make_pipeline(db_session, name=f"Pipeline for {field}")
|
||||
r = client.post(
|
||||
"/api/routing-rules",
|
||||
json={
|
||||
"name": f"Rule for {field}",
|
||||
"field": field,
|
||||
"operator": "equals",
|
||||
"value": "test",
|
||||
"target_pipeline_id": p.id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# API tests – auto-position
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAutoPosition:
|
||||
"""Tests for automatic position assignment."""
|
||||
|
||||
def test_auto_position_increments(self, client, db_session):
|
||||
"""Rules created without position get incrementing positions."""
|
||||
p = _make_pipeline(db_session, name="Pipeline")
|
||||
|
||||
r1 = client.post(
|
||||
"/api/routing-rules",
|
||||
json={
|
||||
"name": "First",
|
||||
"field": "file_type",
|
||||
"operator": "equals",
|
||||
"value": "application/pdf",
|
||||
"target_pipeline_id": p.id,
|
||||
},
|
||||
)
|
||||
r2 = client.post(
|
||||
"/api/routing-rules",
|
||||
json={
|
||||
"name": "Second",
|
||||
"field": "file_type",
|
||||
"operator": "equals",
|
||||
"value": "image/png",
|
||||
"target_pipeline_id": p.id,
|
||||
},
|
||||
)
|
||||
assert r1.status_code == 201
|
||||
assert r2.status_code == 201
|
||||
assert r2.json()["position"] > r1.json()["position"]
|
||||
+187
-4
@@ -9,6 +9,7 @@ from app.tasks.send_to_all import (
|
||||
_should_upload_to_email,
|
||||
_should_upload_to_ftp,
|
||||
_should_upload_to_google_drive,
|
||||
_should_upload_to_icloud,
|
||||
_should_upload_to_nextcloud,
|
||||
_should_upload_to_onedrive,
|
||||
_should_upload_to_paperless,
|
||||
@@ -145,6 +146,137 @@ class TestShouldUploadFunctions:
|
||||
|
||||
assert _should_upload_to_s3() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_icloud_configured(self, mock_settings):
|
||||
"""Test iCloud upload check."""
|
||||
mock_settings.icloud_username = "user@example.com"
|
||||
mock_settings.icloud_password = "app-specific-password"
|
||||
|
||||
assert _should_upload_to_icloud() is True
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_should_upload_to_icloud_not_configured(self, mock_settings):
|
||||
"""Test iCloud upload check when not configured."""
|
||||
mock_settings.icloud_username = None
|
||||
mock_settings.icloud_password = None
|
||||
|
||||
assert _should_upload_to_icloud() is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestShouldUploadEnabledFlag:
|
||||
"""Test that the _should_upload_to_* functions respect the explicit enabled flag."""
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_dropbox_disabled_with_credentials(self, mock_settings):
|
||||
"""Test Dropbox upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.dropbox_enabled = False
|
||||
mock_settings.dropbox_app_key = "key"
|
||||
mock_settings.dropbox_app_secret = "secret"
|
||||
mock_settings.dropbox_refresh_token = "token"
|
||||
|
||||
assert _should_upload_to_dropbox() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_nextcloud_disabled_with_credentials(self, mock_settings):
|
||||
"""Test Nextcloud upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.nextcloud_enabled = False
|
||||
mock_settings.nextcloud_upload_url = "https://nextcloud.example.com"
|
||||
mock_settings.nextcloud_username = "user"
|
||||
mock_settings.nextcloud_password = "pass"
|
||||
|
||||
assert _should_upload_to_nextcloud() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_paperless_disabled_with_credentials(self, mock_settings):
|
||||
"""Test Paperless upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.paperless_enabled = False
|
||||
mock_settings.paperless_ngx_api_token = "token"
|
||||
mock_settings.paperless_host = "https://paperless.example.com"
|
||||
|
||||
assert _should_upload_to_paperless() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_google_drive_disabled_with_credentials(self, mock_settings):
|
||||
"""Test Google Drive upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.google_drive_enabled = False
|
||||
mock_settings.google_drive_use_oauth = False
|
||||
mock_settings.google_drive_credentials_json = '{"type": "service_account"}'
|
||||
mock_settings.google_drive_folder_id = "folder_id"
|
||||
|
||||
assert _should_upload_to_google_drive() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_webdav_disabled_with_credentials(self, mock_settings):
|
||||
"""Test WebDAV upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.webdav_enabled = False
|
||||
mock_settings.webdav_url = "https://webdav.example.com"
|
||||
mock_settings.webdav_username = "user"
|
||||
mock_settings.webdav_password = "pass"
|
||||
|
||||
assert _should_upload_to_webdav() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_ftp_disabled_with_credentials(self, mock_settings):
|
||||
"""Test FTP upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.ftp_enabled = False
|
||||
mock_settings.ftp_host = "ftp.example.com"
|
||||
mock_settings.ftp_username = "user"
|
||||
mock_settings.ftp_password = "pass"
|
||||
|
||||
assert _should_upload_to_ftp() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_sftp_disabled_with_credentials(self, mock_settings):
|
||||
"""Test SFTP upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.sftp_enabled = False
|
||||
mock_settings.sftp_host = "sftp.example.com"
|
||||
mock_settings.sftp_username = "user"
|
||||
mock_settings.sftp_password = "pass"
|
||||
mock_settings.sftp_private_key = None
|
||||
|
||||
assert _should_upload_to_sftp() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_email_disabled_with_credentials(self, mock_settings):
|
||||
"""Test email upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.dest_email_enabled = False
|
||||
mock_settings.dest_email_host = "smtp.example.com"
|
||||
mock_settings.dest_email_username = "user"
|
||||
mock_settings.dest_email_password = "pass"
|
||||
mock_settings.dest_email_default_recipient = "recipient@example.com"
|
||||
|
||||
assert _should_upload_to_email() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_onedrive_disabled_with_credentials(self, mock_settings):
|
||||
"""Test OneDrive upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.onedrive_enabled = False
|
||||
mock_settings.onedrive_client_id = "client_id"
|
||||
mock_settings.onedrive_client_secret = "client_secret"
|
||||
mock_settings.onedrive_refresh_token = "refresh_token"
|
||||
|
||||
assert _should_upload_to_onedrive() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_s3_disabled_with_credentials(self, mock_settings):
|
||||
"""Test S3 upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.s3_enabled = False
|
||||
mock_settings.s3_bucket_name = "my-bucket"
|
||||
mock_settings.aws_access_key_id = "key_id"
|
||||
mock_settings.aws_secret_access_key = "secret_key"
|
||||
|
||||
assert _should_upload_to_s3() is False
|
||||
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
def test_icloud_disabled_with_credentials(self, mock_settings):
|
||||
"""Test iCloud upload is blocked when disabled even with valid credentials."""
|
||||
mock_settings.icloud_enabled = False
|
||||
mock_settings.icloud_username = "user@example.com"
|
||||
mock_settings.icloud_password = "app-specific-password"
|
||||
|
||||
assert _should_upload_to_icloud() is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetConfiguredServicesFromValidator:
|
||||
@@ -154,9 +286,9 @@ class TestGetConfiguredServicesFromValidator:
|
||||
def test_returns_configured_services(self, mock_get_status):
|
||||
"""Test that configured services are returned correctly."""
|
||||
mock_get_status.return_value = {
|
||||
"Dropbox": {"configured": True},
|
||||
"NextCloud": {"configured": False},
|
||||
"S3 Storage": {"configured": True},
|
||||
"Dropbox": {"configured": True, "enabled": True},
|
||||
"NextCloud": {"configured": False, "enabled": True},
|
||||
"S3 Storage": {"configured": True, "enabled": True},
|
||||
}
|
||||
|
||||
result = get_configured_services_from_validator()
|
||||
@@ -169,7 +301,7 @@ class TestGetConfiguredServicesFromValidator:
|
||||
def test_handles_missing_providers(self, mock_get_status):
|
||||
"""Test handling when some providers are not in status."""
|
||||
mock_get_status.return_value = {
|
||||
"Dropbox": {"configured": True},
|
||||
"Dropbox": {"configured": True, "enabled": True},
|
||||
}
|
||||
|
||||
result = get_configured_services_from_validator()
|
||||
@@ -177,6 +309,30 @@ class TestGetConfiguredServicesFromValidator:
|
||||
assert result["dropbox"] is True
|
||||
# Other services not in result
|
||||
|
||||
@patch("app.tasks.send_to_all.get_provider_status")
|
||||
def test_configured_but_disabled_service_not_active(self, mock_get_status):
|
||||
"""Test that a configured but disabled service is not returned as active."""
|
||||
mock_get_status.return_value = {
|
||||
"Dropbox": {"configured": True, "enabled": False},
|
||||
"S3 Storage": {"configured": True, "enabled": True},
|
||||
}
|
||||
|
||||
result = get_configured_services_from_validator()
|
||||
|
||||
assert result["dropbox"] is False
|
||||
assert result["s3"] is True
|
||||
|
||||
@patch("app.tasks.send_to_all.get_provider_status")
|
||||
def test_missing_enabled_field_defaults_to_true_for_backward_compatibility(self, mock_get_status):
|
||||
"""Test that missing 'enabled' key defaults to True (backward compatible)."""
|
||||
mock_get_status.return_value = {
|
||||
"Dropbox": {"configured": True},
|
||||
}
|
||||
|
||||
result = get_configured_services_from_validator()
|
||||
|
||||
assert result["dropbox"] is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSendToAllDestinations:
|
||||
@@ -204,12 +360,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_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
def test_queues_single_configured_service(
|
||||
self,
|
||||
mock_upload,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -239,6 +397,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
||||
@@ -250,6 +409,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all.log_task_progress")
|
||||
@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_s3")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_paperless")
|
||||
@@ -274,6 +434,7 @@ class TestSendToAllDestinations:
|
||||
mock_paperless,
|
||||
mock_nextcloud,
|
||||
mock_should_s3,
|
||||
mock_icloud,
|
||||
mock_should_dropbox,
|
||||
mock_settings,
|
||||
mock_log,
|
||||
@@ -295,6 +456,7 @@ class TestSendToAllDestinations:
|
||||
mock_sftp.return_value = False
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.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")
|
||||
|
||||
@@ -316,10 +478,12 @@ 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_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
def test_skips_unconfigured_services(
|
||||
self,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -349,6 +513,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
||||
|
||||
@@ -369,12 +534,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_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
def test_with_file_id_parameter(
|
||||
self,
|
||||
mock_upload,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -404,6 +571,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 42])
|
||||
@@ -425,6 +593,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_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
@@ -433,6 +602,7 @@ class TestSendToAllDestinations:
|
||||
mock_upload,
|
||||
mock_validator,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -463,6 +633,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), True, 1])
|
||||
@@ -482,12 +653,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_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||
def test_validator_exception_fallback(
|
||||
self,
|
||||
mock_validator,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -518,6 +691,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
# Should not raise, should fall back to individual checks
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), True, 1])
|
||||
@@ -536,12 +710,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_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
def test_handles_upload_task_queue_error(
|
||||
self,
|
||||
mock_upload,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -571,6 +747,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.side_effect = Exception("Queue error")
|
||||
|
||||
# Should not raise, should log error
|
||||
@@ -580,6 +757,7 @@ class TestSendToAllDestinations:
|
||||
# Error should be recorded in results
|
||||
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_s3")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@@ -608,6 +786,7 @@ class TestSendToAllDestinations:
|
||||
mock_email,
|
||||
mock_onedrive,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
tmp_path,
|
||||
):
|
||||
"""Test file_id lookup fallback when not provided."""
|
||||
@@ -629,6 +808,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
# Mock database session
|
||||
mock_db = MagicMock()
|
||||
@@ -656,10 +836,12 @@ 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_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
def test_should_upload_check_exception_handling(
|
||||
self,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -689,6 +871,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
# Should not raise, should treat as not configured
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
||||
|
||||
@@ -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,505 @@
|
||||
"""Tests for social login functionality in app/auth.py."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request, status
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSocialProviders:
|
||||
"""Tests for SOCIAL_PROVIDERS dictionary population."""
|
||||
|
||||
def test_social_providers_is_dict(self):
|
||||
"""Test that SOCIAL_PROVIDERS is a dict."""
|
||||
from app.auth import SOCIAL_PROVIDERS
|
||||
|
||||
assert isinstance(SOCIAL_PROVIDERS, dict)
|
||||
|
||||
def test_social_providers_empty_by_default(self):
|
||||
"""Test that no social providers are enabled by default (settings have enabled=False)."""
|
||||
# In test environment, social login settings are not set, so the dict should be empty
|
||||
from app.auth import SOCIAL_PROVIDERS
|
||||
|
||||
# Since tests run with default settings (all social providers disabled),
|
||||
# SOCIAL_PROVIDERS should be empty
|
||||
assert isinstance(SOCIAL_PROVIDERS, dict)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSocialLogin:
|
||||
"""Tests for social_login() function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_login_unknown_provider(self):
|
||||
"""Test social_login redirects when provider is unknown."""
|
||||
from app.auth import social_login
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
|
||||
with patch("app.auth.SOCIAL_PROVIDERS", {}):
|
||||
result = await social_login(mock_request, "unknown_provider")
|
||||
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert result.status_code == status.HTTP_302_FOUND
|
||||
assert "/login?error=Unknown+social+provider" in result.headers["location"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_login_provider_not_in_oauth(self):
|
||||
"""Test social_login redirects when provider is registered but OAuth client is missing."""
|
||||
from app.auth import social_login
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
|
||||
with (
|
||||
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
|
||||
patch("app.auth.oauth") as mock_oauth,
|
||||
):
|
||||
mock_oauth.google = None
|
||||
|
||||
result = await social_login(mock_request, "google")
|
||||
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert "/login?error=Provider+not+configured" in result.headers["location"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_login_initiates_redirect(self):
|
||||
"""Test social_login initiates OAuth redirect for a valid provider."""
|
||||
from app.auth import social_login
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.url_for = MagicMock(return_value="http://localhost/social-callback/google")
|
||||
|
||||
mock_google = MagicMock()
|
||||
mock_google.authorize_redirect = AsyncMock(return_value="google_redirect")
|
||||
|
||||
with (
|
||||
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
|
||||
patch("app.auth.oauth") as mock_oauth,
|
||||
):
|
||||
mock_oauth.google = mock_google
|
||||
|
||||
result = await social_login(mock_request, "google")
|
||||
|
||||
assert result == "google_redirect"
|
||||
mock_google.authorize_redirect.assert_called_once_with(
|
||||
mock_request, "http://localhost/social-callback/google"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNormalizeSocialUserinfo:
|
||||
"""Tests for _normalize_social_userinfo()."""
|
||||
|
||||
def test_normalize_google_userinfo(self):
|
||||
"""Test normalizing Google OIDC userinfo."""
|
||||
from app.auth import _normalize_social_userinfo
|
||||
|
||||
raw = {
|
||||
"sub": "123456789",
|
||||
"email": "user@gmail.com",
|
||||
"name": "Test User",
|
||||
"picture": "https://lh3.googleusercontent.com/photo.jpg",
|
||||
}
|
||||
result = _normalize_social_userinfo("google", {}, raw)
|
||||
|
||||
assert result["sub"] == "123456789"
|
||||
assert result["email"] == "user@gmail.com"
|
||||
assert result["name"] == "Test User"
|
||||
assert result["preferred_username"] == "user@gmail.com"
|
||||
assert result["picture"] == "https://lh3.googleusercontent.com/photo.jpg"
|
||||
|
||||
def test_normalize_microsoft_userinfo(self):
|
||||
"""Test normalizing Microsoft OIDC userinfo."""
|
||||
from app.auth import _normalize_social_userinfo
|
||||
|
||||
raw = {
|
||||
"sub": "ms-sub-123",
|
||||
"email": "user@outlook.com",
|
||||
"name": "MS User",
|
||||
}
|
||||
result = _normalize_social_userinfo("microsoft", {}, raw)
|
||||
|
||||
assert result["sub"] == "ms-sub-123"
|
||||
assert result["email"] == "user@outlook.com"
|
||||
assert result["name"] == "MS User"
|
||||
assert result["preferred_username"] == "user@outlook.com"
|
||||
|
||||
def test_normalize_apple_userinfo(self):
|
||||
"""Test normalizing Apple OIDC userinfo."""
|
||||
from app.auth import _normalize_social_userinfo
|
||||
|
||||
raw = {
|
||||
"sub": "apple-sub-456",
|
||||
"email": "user@privaterelay.appleid.com",
|
||||
}
|
||||
result = _normalize_social_userinfo("apple", {}, raw)
|
||||
|
||||
assert result["sub"] == "apple-sub-456"
|
||||
assert result["email"] == "user@privaterelay.appleid.com"
|
||||
|
||||
def test_normalize_dropbox_userinfo(self):
|
||||
"""Test normalizing Dropbox non-standard userinfo."""
|
||||
from app.auth import _normalize_social_userinfo
|
||||
|
||||
raw = {
|
||||
"account_id": "dbid:AABcDEfGhIjKlMnOpQr",
|
||||
"email": "user@example.com",
|
||||
"name": {"display_name": "Dropbox User"},
|
||||
"profile_photo_url": "https://dropbox.com/photo.jpg",
|
||||
}
|
||||
result = _normalize_social_userinfo("dropbox", {}, raw)
|
||||
|
||||
assert result["sub"] == "dbid:AABcDEfGhIjKlMnOpQr"
|
||||
assert result["email"] == "user@example.com"
|
||||
assert result["name"] == "Dropbox User"
|
||||
assert result["picture"] == "https://dropbox.com/photo.jpg"
|
||||
|
||||
def test_normalize_dropbox_missing_fields(self):
|
||||
"""Test normalizing Dropbox userinfo with missing fields."""
|
||||
from app.auth import _normalize_social_userinfo
|
||||
|
||||
raw = {"email": "user@example.com"}
|
||||
result = _normalize_social_userinfo("dropbox", {}, raw)
|
||||
|
||||
assert result["sub"] == "user@example.com" # Falls back to email
|
||||
assert result["email"] == "user@example.com"
|
||||
assert result["name"] == ""
|
||||
|
||||
def test_normalize_with_none_userinfo(self):
|
||||
"""Test normalizing when userinfo is None."""
|
||||
from app.auth import _normalize_social_userinfo
|
||||
|
||||
result = _normalize_social_userinfo("google", {}, None)
|
||||
|
||||
assert result["sub"] == ""
|
||||
assert result["email"] == ""
|
||||
assert result["name"] == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSocialCallback:
|
||||
"""Tests for social_callback() function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_callback_unknown_provider(self):
|
||||
"""Test social_callback redirects when provider is unknown."""
|
||||
from app.auth import social_callback
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_db = MagicMock()
|
||||
|
||||
with patch("app.auth.SOCIAL_PROVIDERS", {}):
|
||||
result = await social_callback(mock_request, "unknown_provider", db=mock_db)
|
||||
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert "/login?error=Unknown+social+provider" in result.headers["location"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_callback_provider_not_configured(self):
|
||||
"""Test social_callback redirects when OAuth client is missing."""
|
||||
from app.auth import social_callback
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_db = MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
|
||||
patch("app.auth.oauth") as mock_oauth,
|
||||
):
|
||||
mock_oauth.google = None
|
||||
|
||||
result = await social_callback(mock_request, "google", db=mock_db)
|
||||
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert "/login?error=Provider+not+configured" in result.headers["location"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_callback_success_google(self):
|
||||
"""Test successful Google social callback flow."""
|
||||
from app.auth import social_callback
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.session = {}
|
||||
mock_db = MagicMock()
|
||||
|
||||
mock_google = MagicMock()
|
||||
mock_google.authorize_access_token = AsyncMock(
|
||||
return_value={
|
||||
"userinfo": {
|
||||
"sub": "google-123",
|
||||
"email": "testuser@gmail.com",
|
||||
"name": "Test User",
|
||||
"picture": "https://example.com/photo.jpg",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mock_profile = MagicMock()
|
||||
mock_profile.onboarding_completed = True
|
||||
|
||||
with (
|
||||
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
|
||||
patch("app.auth.oauth") as mock_oauth,
|
||||
patch("app.auth._ensure_user_profile"),
|
||||
patch("app.auth._UserProfile") as mock_user_profile_cls,
|
||||
):
|
||||
mock_oauth.google = mock_google
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_profile
|
||||
|
||||
result = await social_callback(mock_request, "google", db=mock_db)
|
||||
|
||||
assert isinstance(result, RedirectResponse)
|
||||
# Verify session was set
|
||||
assert mock_request.session["user"]["email"] == "testuser@gmail.com"
|
||||
assert mock_request.session["user"]["auth_provider"] == "google"
|
||||
assert mock_request.session["user"]["is_admin"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_callback_no_email(self):
|
||||
"""Test social callback when provider doesn't return email."""
|
||||
from app.auth import social_callback
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.session = {}
|
||||
mock_db = MagicMock()
|
||||
|
||||
mock_google = MagicMock()
|
||||
mock_google.authorize_access_token = AsyncMock(
|
||||
return_value={
|
||||
"userinfo": {
|
||||
"sub": "google-123",
|
||||
# No email!
|
||||
"name": "Test User",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
|
||||
patch("app.auth.oauth") as mock_oauth,
|
||||
):
|
||||
mock_oauth.google = mock_google
|
||||
|
||||
result = await social_callback(mock_request, "google", db=mock_db)
|
||||
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert "/login?error=Could+not+retrieve+email+from+provider" in result.headers["location"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_callback_exception_handling(self):
|
||||
"""Test social callback handles exceptions gracefully."""
|
||||
from app.auth import social_callback
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.session = {}
|
||||
mock_db = MagicMock()
|
||||
|
||||
mock_google = MagicMock()
|
||||
mock_google.authorize_access_token = AsyncMock(side_effect=Exception("Token exchange failed"))
|
||||
|
||||
with (
|
||||
patch("app.auth.SOCIAL_PROVIDERS", {"google": {"name": "Google", "icon": "fab fa-google", "color": "red"}}),
|
||||
patch("app.auth.oauth") as mock_oauth,
|
||||
):
|
||||
mock_oauth.google = mock_google
|
||||
|
||||
result = await social_callback(mock_request, "google", db=mock_db)
|
||||
|
||||
assert isinstance(result, RedirectResponse)
|
||||
assert "/login?error=Social+login+failed" in result.headers["location"]
|
||||
# Ensure internal exception details are not exposed to the user
|
||||
assert "Exception" not in result.headers["location"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLoginPageSocialProviders:
|
||||
"""Tests for login page rendering with social providers."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_page_includes_social_providers(self):
|
||||
"""Test login page passes social_providers to template."""
|
||||
mock_providers = {
|
||||
"google": {"name": "Google", "icon": "fab fa-google", "color": "red"},
|
||||
"microsoft": {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("app.auth.AUTH_ENABLED", True),
|
||||
patch("app.auth.OAUTH_CONFIGURED", False),
|
||||
patch("app.auth.SOCIAL_PROVIDERS", mock_providers),
|
||||
patch("app.auth.templates") as mock_templates,
|
||||
patch("app.auth.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.version = "1.0.0"
|
||||
mock_settings.multi_user_enabled = False
|
||||
mock_settings.allow_local_signup = False
|
||||
|
||||
from app.auth import login
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.query_params.get.return_value = None
|
||||
|
||||
await login(mock_request)
|
||||
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
context = call_args[0][1]
|
||||
assert context["social_providers"] == mock_providers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_page_empty_social_providers(self):
|
||||
"""Test login page with no social providers configured."""
|
||||
with (
|
||||
patch("app.auth.AUTH_ENABLED", True),
|
||||
patch("app.auth.OAUTH_CONFIGURED", False),
|
||||
patch("app.auth.SOCIAL_PROVIDERS", {}),
|
||||
patch("app.auth.templates") as mock_templates,
|
||||
patch("app.auth.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.version = "1.0.0"
|
||||
mock_settings.multi_user_enabled = False
|
||||
mock_settings.allow_local_signup = False
|
||||
|
||||
from app.auth import login
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.query_params.get.return_value = None
|
||||
|
||||
await login(mock_request)
|
||||
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
context = call_args[0][1]
|
||||
assert context["social_providers"] == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestConfigValidatorSocialLogin:
|
||||
"""Tests for config validator social login checks."""
|
||||
|
||||
def test_social_login_counts_as_valid_auth(self):
|
||||
"""Test that enabled social login prevents 'neither auth configured' warning."""
|
||||
from app.utils.config_validator.validators import validate_auth_config
|
||||
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.auth_enabled = True
|
||||
mock_settings.session_secret = "a" * 32
|
||||
mock_settings.admin_username = None
|
||||
mock_settings.admin_password = None
|
||||
mock_settings.authentik_client_id = None
|
||||
mock_settings.authentik_client_secret = None
|
||||
mock_settings.authentik_config_url = None
|
||||
mock_settings.oauth_provider_name = None
|
||||
mock_settings.social_auth_google_enabled = True
|
||||
mock_settings.social_auth_google_client_id = "test-id"
|
||||
mock_settings.social_auth_google_client_secret = "test-secret"
|
||||
mock_settings.social_auth_microsoft_enabled = False
|
||||
mock_settings.social_auth_apple_enabled = False
|
||||
mock_settings.social_auth_dropbox_enabled = False
|
||||
|
||||
issues = validate_auth_config()
|
||||
|
||||
# Should NOT contain the "neither...configured" message
|
||||
assert not any("Neither" in issue for issue in issues)
|
||||
|
||||
def test_social_login_missing_credentials_reported(self):
|
||||
"""Test that enabled social login without credentials is reported."""
|
||||
from app.utils.config_validator.validators import validate_auth_config
|
||||
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.auth_enabled = True
|
||||
mock_settings.session_secret = "a" * 32
|
||||
mock_settings.admin_username = "admin"
|
||||
mock_settings.admin_password = "pass"
|
||||
mock_settings.authentik_client_id = None
|
||||
mock_settings.authentik_client_secret = None
|
||||
mock_settings.authentik_config_url = None
|
||||
mock_settings.oauth_provider_name = None
|
||||
mock_settings.social_auth_google_enabled = True
|
||||
mock_settings.social_auth_google_client_id = None # Missing!
|
||||
mock_settings.social_auth_google_client_secret = None # Missing!
|
||||
mock_settings.social_auth_microsoft_enabled = False
|
||||
mock_settings.social_auth_apple_enabled = False
|
||||
mock_settings.social_auth_dropbox_enabled = False
|
||||
|
||||
issues = validate_auth_config()
|
||||
|
||||
assert any("SOCIAL_AUTH_GOOGLE_CLIENT_ID" in issue for issue in issues)
|
||||
assert any("SOCIAL_AUTH_GOOGLE_CLIENT_SECRET" in issue for issue in issues)
|
||||
|
||||
def test_microsoft_missing_credentials(self):
|
||||
"""Test Microsoft login validation when credentials are missing."""
|
||||
from app.utils.config_validator.validators import validate_auth_config
|
||||
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.auth_enabled = True
|
||||
mock_settings.session_secret = "a" * 32
|
||||
mock_settings.admin_username = "admin"
|
||||
mock_settings.admin_password = "pass"
|
||||
mock_settings.authentik_client_id = None
|
||||
mock_settings.authentik_client_secret = None
|
||||
mock_settings.authentik_config_url = None
|
||||
mock_settings.oauth_provider_name = None
|
||||
mock_settings.social_auth_google_enabled = False
|
||||
mock_settings.social_auth_microsoft_enabled = True
|
||||
mock_settings.social_auth_microsoft_client_id = None
|
||||
mock_settings.social_auth_microsoft_client_secret = None
|
||||
mock_settings.social_auth_apple_enabled = False
|
||||
mock_settings.social_auth_dropbox_enabled = False
|
||||
|
||||
issues = validate_auth_config()
|
||||
|
||||
assert any("SOCIAL_AUTH_MICROSOFT_CLIENT_ID" in issue for issue in issues)
|
||||
assert any("SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET" in issue for issue in issues)
|
||||
|
||||
def test_apple_missing_credentials(self):
|
||||
"""Test Apple login validation when credentials are missing."""
|
||||
from app.utils.config_validator.validators import validate_auth_config
|
||||
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.auth_enabled = True
|
||||
mock_settings.session_secret = "a" * 32
|
||||
mock_settings.admin_username = "admin"
|
||||
mock_settings.admin_password = "pass"
|
||||
mock_settings.authentik_client_id = None
|
||||
mock_settings.authentik_client_secret = None
|
||||
mock_settings.authentik_config_url = None
|
||||
mock_settings.oauth_provider_name = None
|
||||
mock_settings.social_auth_google_enabled = False
|
||||
mock_settings.social_auth_microsoft_enabled = False
|
||||
mock_settings.social_auth_apple_enabled = True
|
||||
mock_settings.social_auth_apple_client_id = None
|
||||
mock_settings.social_auth_apple_team_id = None
|
||||
mock_settings.social_auth_dropbox_enabled = False
|
||||
|
||||
issues = validate_auth_config()
|
||||
|
||||
assert any("SOCIAL_AUTH_APPLE_CLIENT_ID" in issue for issue in issues)
|
||||
assert any("SOCIAL_AUTH_APPLE_TEAM_ID" in issue for issue in issues)
|
||||
|
||||
def test_dropbox_missing_credentials(self):
|
||||
"""Test Dropbox login validation when credentials are missing."""
|
||||
from app.utils.config_validator.validators import validate_auth_config
|
||||
|
||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
||||
mock_settings.auth_enabled = True
|
||||
mock_settings.session_secret = "a" * 32
|
||||
mock_settings.admin_username = "admin"
|
||||
mock_settings.admin_password = "pass"
|
||||
mock_settings.authentik_client_id = None
|
||||
mock_settings.authentik_client_secret = None
|
||||
mock_settings.authentik_config_url = None
|
||||
mock_settings.oauth_provider_name = None
|
||||
mock_settings.social_auth_google_enabled = False
|
||||
mock_settings.social_auth_microsoft_enabled = False
|
||||
mock_settings.social_auth_apple_enabled = False
|
||||
mock_settings.social_auth_dropbox_enabled = True
|
||||
mock_settings.social_auth_dropbox_client_id = None
|
||||
mock_settings.social_auth_dropbox_client_secret = None
|
||||
|
||||
issues = validate_auth_config()
|
||||
|
||||
assert any("SOCIAL_AUTH_DROPBOX_CLIENT_ID" in issue for issue in issues)
|
||||
assert any("SOCIAL_AUTH_DROPBOX_CLIENT_SECRET" in issue for issue in issues)
|
||||
@@ -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,205 @@
|
||||
"""Unit tests for the iCloud Drive upload task and helper functions.
|
||||
|
||||
Tests cover the global upload task (``upload_to_icloud``) as well as the
|
||||
per-user integration handler (``_upload_icloud`` in
|
||||
``upload_to_user_integration``). All external calls to ``pyicloud`` are
|
||||
mocked so tests are fast, hermetic, and free of network access.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TASK_ID = "test-icloud-task-id"
|
||||
|
||||
|
||||
def _write_file(path, content: bytes = b"PDF content") -> None:
|
||||
"""Write *content* to *path*, creating parent dirs as needed."""
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(content)
|
||||
|
||||
|
||||
def _mock_pyicloud_module(mock_api):
|
||||
"""Return a mock ``pyicloud`` module whose ``PyiCloudService`` returns *mock_api*."""
|
||||
mock_mod = MagicMock()
|
||||
mock_mod.PyiCloudService.return_value = mock_api
|
||||
return mock_mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_icloud_api
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGetIcloudApi:
|
||||
"""Tests for the _get_icloud_api helper."""
|
||||
|
||||
def test_returns_authenticated_api(self):
|
||||
mock_api = MagicMock()
|
||||
mock_api.requires_2sa = False
|
||||
mock_api.requires_2fa = False
|
||||
|
||||
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||
from app.tasks.upload_to_icloud import _get_icloud_api
|
||||
|
||||
result = _get_icloud_api("user@example.com", "secret")
|
||||
|
||||
assert result is mock_api
|
||||
|
||||
def test_passes_cookie_directory(self):
|
||||
mock_api = MagicMock()
|
||||
mock_api.requires_2sa = False
|
||||
mock_api.requires_2fa = False
|
||||
mock_mod = _mock_pyicloud_module(mock_api)
|
||||
|
||||
with patch.dict("sys.modules", {"pyicloud": mock_mod}):
|
||||
from app.tasks.upload_to_icloud import _get_icloud_api
|
||||
|
||||
_get_icloud_api("user@example.com", "secret", "/tmp/cookies")
|
||||
|
||||
mock_mod.PyiCloudService.assert_called_once_with("user@example.com", "secret", cookie_directory="/tmp/cookies")
|
||||
|
||||
def test_raises_on_2fa_required(self):
|
||||
mock_api = MagicMock()
|
||||
mock_api.requires_2sa = False
|
||||
mock_api.requires_2fa = True
|
||||
|
||||
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||
from app.tasks.upload_to_icloud import _get_icloud_api
|
||||
|
||||
with pytest.raises(ValueError, match="two-factor authentication"):
|
||||
_get_icloud_api("user@example.com", "secret")
|
||||
|
||||
def test_raises_on_2sa_required(self):
|
||||
mock_api = MagicMock()
|
||||
mock_api.requires_2sa = True
|
||||
mock_api.requires_2fa = False
|
||||
|
||||
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||
from app.tasks.upload_to_icloud import _get_icloud_api
|
||||
|
||||
with pytest.raises(ValueError, match="two-factor authentication"):
|
||||
_get_icloud_api("user@example.com", "secret")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _navigate_to_folder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNavigateToFolder:
|
||||
"""Tests for the _navigate_to_folder helper."""
|
||||
|
||||
def test_empty_path_returns_root(self):
|
||||
from app.tasks.upload_to_icloud import _navigate_to_folder
|
||||
|
||||
root = MagicMock()
|
||||
result = _navigate_to_folder(root, "")
|
||||
assert result is root
|
||||
|
||||
def test_navigates_existing_folders(self):
|
||||
from app.tasks.upload_to_icloud import _navigate_to_folder
|
||||
|
||||
# Build a mock folder tree: root -> Documents -> Uploads
|
||||
uploads_node = MagicMock()
|
||||
uploads_node.name = "Uploads"
|
||||
|
||||
docs_node = MagicMock()
|
||||
docs_node.name = "Documents"
|
||||
docs_node.dir.return_value = [uploads_node]
|
||||
|
||||
root = MagicMock()
|
||||
root.dir.return_value = [docs_node]
|
||||
|
||||
result = _navigate_to_folder(root, "Documents/Uploads")
|
||||
assert result is uploads_node
|
||||
|
||||
def test_creates_missing_folder(self):
|
||||
from app.tasks.upload_to_icloud import _navigate_to_folder
|
||||
|
||||
new_folder = MagicMock()
|
||||
root = MagicMock()
|
||||
root.dir.return_value = [] # No children
|
||||
root.mkdir.return_value = new_folder
|
||||
|
||||
result = _navigate_to_folder(root, "NewFolder")
|
||||
root.mkdir.assert_called_once_with("NewFolder")
|
||||
assert result is new_folder
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _upload_icloud (user integration handler)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUploadIcloudHandler:
|
||||
"""Tests for _upload_icloud handler in upload_to_user_integration."""
|
||||
|
||||
def _call(self, file_path: str, cfg: dict, creds: dict) -> dict:
|
||||
from app.tasks.upload_to_user_integration import _upload_icloud
|
||||
|
||||
return _upload_icloud(file_path, cfg, creds, TASK_ID)
|
||||
|
||||
def test_raises_when_credentials_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="username or password"):
|
||||
self._call(fp, {}, {})
|
||||
|
||||
def test_raises_when_password_missing(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
with pytest.raises(ValueError, match="username or password"):
|
||||
self._call(fp, {}, {"username": "user@example.com"})
|
||||
|
||||
def test_successful_upload(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.requires_2sa = False
|
||||
mock_api.requires_2fa = False
|
||||
|
||||
# drive.dir() returns nothing -> mkdir will be called
|
||||
mock_folder = MagicMock()
|
||||
mock_api.drive.dir.return_value = []
|
||||
mock_api.drive.mkdir.return_value = mock_folder
|
||||
|
||||
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||
result = self._call(
|
||||
fp,
|
||||
{"folder": "Documents"},
|
||||
{"username": "user@example.com", "password": "secret"},
|
||||
)
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
assert result["icloud_folder"] == "Documents"
|
||||
mock_folder.upload.assert_called_once()
|
||||
|
||||
def test_upload_to_root_when_no_folder(self, tmp_path):
|
||||
fp = str(tmp_path / "doc.pdf")
|
||||
_write_file(fp)
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_api.requires_2sa = False
|
||||
mock_api.requires_2fa = False
|
||||
|
||||
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
|
||||
result = self._call(
|
||||
fp,
|
||||
{},
|
||||
{"username": "user@example.com", "password": "secret"},
|
||||
)
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
assert result["icloud_folder"] == "/"
|
||||
mock_api.drive.upload.assert_called_once()
|
||||
+215
-112
@@ -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
|
||||
@@ -50,14 +50,14 @@ class TestURLUploadValidation:
|
||||
|
||||
def test_is_private_ip_localhost(self):
|
||||
"""Test that localhost is detected as private"""
|
||||
from app.api.url_upload import is_private_ip
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
assert is_private_ip("127.0.0.1") is True
|
||||
assert is_private_ip("localhost") is True
|
||||
|
||||
def test_is_private_ip_private_ranges(self):
|
||||
"""Test that private IP ranges are detected"""
|
||||
from app.api.url_upload import is_private_ip
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
# Private IP ranges
|
||||
assert is_private_ip("10.0.0.1") is True
|
||||
@@ -67,7 +67,7 @@ class TestURLUploadValidation:
|
||||
|
||||
def test_is_private_ip_public_allowed(self):
|
||||
"""Test that public IPs are allowed"""
|
||||
from app.api.url_upload import is_private_ip
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
# Public IPs should not be blocked
|
||||
assert is_private_ip("8.8.8.8") is False
|
||||
@@ -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()
|
||||
@@ -548,29 +618,36 @@ class TestURLUploadEndpoint:
|
||||
|
||||
def test_is_private_ip_ipv6_loopback(self):
|
||||
"""Test that IPv6 loopback is detected as private"""
|
||||
from app.api.url_upload import is_private_ip
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
# IPv6 loopback (::1)
|
||||
assert is_private_ip("::1") is True
|
||||
|
||||
def test_is_private_ip_link_local(self):
|
||||
"""Test that link-local addresses are detected as private"""
|
||||
from app.api.url_upload import is_private_ip
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
# 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()
|
||||
@@ -609,7 +686,7 @@ class TestURLUploadCoverageGaps:
|
||||
@patch("socket.getaddrinfo")
|
||||
def test_is_private_ip_hostname_resolves_to_public_ip(self, mock_getaddrinfo):
|
||||
"""Test that a hostname resolving to a public IP returns False (lines 65->61, 67)"""
|
||||
from app.api.url_upload import is_private_ip
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
# Mock DNS resolution to return a single public IP (8.8.8.8 is Google DNS)
|
||||
mock_getaddrinfo.return_value = [
|
||||
@@ -624,7 +701,7 @@ class TestURLUploadCoverageGaps:
|
||||
@patch("socket.getaddrinfo")
|
||||
def test_is_private_ip_hostname_resolves_multiple_ips_all_public(self, mock_getaddrinfo):
|
||||
"""Test hostname with multiple public IPs returns False (covers 65->61 loop branch)"""
|
||||
from app.api.url_upload import is_private_ip
|
||||
from app.utils.network import is_private_ip
|
||||
|
||||
# Return two public IPs - neither is private, so loop runs twice (65->61) then returns False (67)
|
||||
mock_getaddrinfo.return_value = [
|
||||
@@ -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"})
|
||||
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
"""Tests for app/utils/user_notification.py.
|
||||
|
||||
Covers all previously-uncovered branches:
|
||||
- create_in_app_notification: exception/rollback path
|
||||
- _send_email_notification: full SMTP success path, TLS disabled, no credentials
|
||||
- _send_webhook_notification: success path with/without secret header
|
||||
- dispatch_user_notification: preference loop (email, webhook), no target_id,
|
||||
inactive target, invalid/empty JSON config, JSON decode error, outer exception
|
||||
- dispatch_user_notification: push notification path (success and exception)
|
||||
- notify_user_document_processed / notify_user_document_failed: happy-path smoke tests
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, 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 InAppNotification, UserNotificationPreference, UserNotificationTarget
|
||||
|
||||
_OWNER = "dispatch-test-user@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixture helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mem_engine():
|
||||
"""In-memory SQLite engine for user_notification 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 Session(mem_engine): # noqa: N802
|
||||
"""Session factory bound to mem_engine."""
|
||||
return sessionmaker(bind=mem_engine)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_in_app_notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCreateInAppNotification:
|
||||
"""Tests for create_in_app_notification()."""
|
||||
|
||||
def test_returns_none_on_db_exception(self, Session):
|
||||
"""create_in_app_notification should return None and rollback on error."""
|
||||
from app.utils.user_notification import create_in_app_notification
|
||||
|
||||
# Provide a session whose commit raises to exercise the except branch
|
||||
bad_session = MagicMock()
|
||||
bad_session.add = MagicMock()
|
||||
bad_session.commit = MagicMock(side_effect=RuntimeError("DB is down"))
|
||||
bad_session.rollback = MagicMock()
|
||||
bad_session.close = MagicMock()
|
||||
|
||||
BadSession = MagicMock(return_value=bad_session) # noqa: N806
|
||||
|
||||
with patch("app.utils.user_notification.SessionLocal", BadSession):
|
||||
result = create_in_app_notification(
|
||||
owner_id=_OWNER,
|
||||
event_type="document.processed",
|
||||
title="Oops",
|
||||
message="Something went wrong",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
bad_session.rollback.assert_called_once()
|
||||
bad_session.close.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _send_email_notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSendEmailNotification:
|
||||
"""Tests for _send_email_notification()."""
|
||||
|
||||
def test_success_with_tls_and_credentials(self):
|
||||
"""Email is sent with STARTTLS and login when fully configured."""
|
||||
from app.utils.user_notification import _send_email_notification
|
||||
|
||||
config = {
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": "587",
|
||||
"smtp_username": "user@example.com",
|
||||
"smtp_password": "secret",
|
||||
"smtp_use_tls": True,
|
||||
"recipient_email": "dest@example.com",
|
||||
}
|
||||
|
||||
mock_server = MagicMock()
|
||||
mock_smtp_cls = MagicMock(return_value=mock_server)
|
||||
mock_server.__enter__ = MagicMock(return_value=mock_server)
|
||||
mock_server.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("app.utils.user_notification.smtplib.SMTP", mock_smtp_cls):
|
||||
result = _send_email_notification(config, "Subject", "Body text")
|
||||
|
||||
assert result is True
|
||||
mock_server.starttls.assert_called_once()
|
||||
mock_server.login.assert_called_once_with("user@example.com", "secret")
|
||||
mock_server.send_message.assert_called_once()
|
||||
|
||||
def test_success_without_tls_and_without_credentials(self):
|
||||
"""Email sent without STARTTLS and login when tls=False and no creds."""
|
||||
from app.utils.user_notification import _send_email_notification
|
||||
|
||||
config = {
|
||||
"smtp_host": "relay.internal",
|
||||
"smtp_port": 25,
|
||||
"smtp_use_tls": False,
|
||||
"recipient_email": "dest@example.com",
|
||||
}
|
||||
|
||||
mock_server = MagicMock()
|
||||
mock_smtp_cls = MagicMock(return_value=mock_server)
|
||||
mock_server.__enter__ = MagicMock(return_value=mock_server)
|
||||
mock_server.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("app.utils.user_notification.smtplib.SMTP", mock_smtp_cls):
|
||||
result = _send_email_notification(config, "Subject", "No TLS body")
|
||||
|
||||
assert result is True
|
||||
mock_server.starttls.assert_not_called()
|
||||
mock_server.login.assert_not_called()
|
||||
mock_server.send_message.assert_called_once()
|
||||
|
||||
def test_returns_false_on_smtp_exception(self):
|
||||
"""_send_email_notification returns False when SMTP.connect raises."""
|
||||
from app.utils.user_notification import _send_email_notification
|
||||
|
||||
config = {
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": 587,
|
||||
"recipient_email": "dest@example.com",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.utils.user_notification.smtplib.SMTP",
|
||||
side_effect=ConnectionRefusedError("refused"),
|
||||
):
|
||||
result = _send_email_notification(config, "Subject", "Body")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_sender_email_defaults_to_smtp_username(self):
|
||||
"""When sender_email is absent the smtp_username is used as From."""
|
||||
from app.utils.user_notification import _send_email_notification
|
||||
|
||||
captured_msgs = []
|
||||
|
||||
config = {
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": 587,
|
||||
"smtp_username": "sender@example.com",
|
||||
"smtp_use_tls": False,
|
||||
"recipient_email": "dest@example.com",
|
||||
}
|
||||
|
||||
mock_server = MagicMock()
|
||||
|
||||
def capture_send(msg):
|
||||
captured_msgs.append(msg)
|
||||
|
||||
mock_server.send_message = capture_send
|
||||
mock_server.__enter__ = MagicMock(return_value=mock_server)
|
||||
mock_server.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("app.utils.user_notification.smtplib.SMTP", return_value=mock_server):
|
||||
result = _send_email_notification(config, "Hi", "Body")
|
||||
|
||||
assert result is True
|
||||
assert captured_msgs[0]["From"] == "sender@example.com"
|
||||
|
||||
def test_sender_email_defaults_to_noreply_when_no_username(self):
|
||||
"""When no sender_email and no smtp_username, From falls back to noreply."""
|
||||
from app.utils.user_notification import _send_email_notification
|
||||
|
||||
captured_msgs = []
|
||||
|
||||
config = {
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_port": 25,
|
||||
"smtp_use_tls": False,
|
||||
"recipient_email": "dest@example.com",
|
||||
}
|
||||
|
||||
mock_server = MagicMock()
|
||||
|
||||
def capture_send(msg):
|
||||
captured_msgs.append(msg)
|
||||
|
||||
mock_server.send_message = capture_send
|
||||
mock_server.__enter__ = MagicMock(return_value=mock_server)
|
||||
mock_server.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch("app.utils.user_notification.smtplib.SMTP", return_value=mock_server):
|
||||
result = _send_email_notification(config, "Hi", "Body")
|
||||
|
||||
assert result is True
|
||||
assert captured_msgs[0]["From"] == "noreply@docuelevate.local"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _send_webhook_notification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSendWebhookNotification:
|
||||
"""Tests for _send_webhook_notification()."""
|
||||
|
||||
def test_success_with_secret_header(self):
|
||||
"""Webhook sent and X-DocuElevate-Secret header set when secret provided."""
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post:
|
||||
result = _send_webhook_notification(
|
||||
{"url": "https://hook.example.com/test", "secret": "mysecret"},
|
||||
"document.processed",
|
||||
"Title",
|
||||
"Body",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs["headers"]["X-DocuElevate-Secret"] == "mysecret"
|
||||
assert kwargs["json"]["event"] == "document.processed"
|
||||
|
||||
def test_success_without_secret(self):
|
||||
"""Webhook sent without X-DocuElevate-Secret header when no secret."""
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post:
|
||||
result = _send_webhook_notification(
|
||||
{"url": "https://hook.example.com/test"},
|
||||
"document.failed",
|
||||
"Failed",
|
||||
"Error details",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
_, kwargs = mock_post.call_args
|
||||
assert "X-DocuElevate-Secret" not in kwargs["headers"]
|
||||
|
||||
def test_returns_false_on_http_error(self):
|
||||
"""_send_webhook_notification returns False when httpx raises."""
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
|
||||
with patch(
|
||||
"app.utils.user_notification.httpx.post",
|
||||
side_effect=Exception("connection error"),
|
||||
):
|
||||
result = _send_webhook_notification(
|
||||
{"url": "https://hook.example.com/test"},
|
||||
"document.processed",
|
||||
"T",
|
||||
"M",
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_returns_false_on_raise_for_status(self):
|
||||
"""Returns False when response.raise_for_status() throws."""
|
||||
import httpx as _httpx
|
||||
|
||||
from app.utils.user_notification import _send_webhook_notification
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status = MagicMock(
|
||||
side_effect=_httpx.HTTPStatusError(
|
||||
"400",
|
||||
request=MagicMock(),
|
||||
response=MagicMock(),
|
||||
)
|
||||
)
|
||||
|
||||
with patch("app.utils.user_notification.httpx.post", return_value=mock_response):
|
||||
result = _send_webhook_notification(
|
||||
{"url": "https://hook.example.com/test"},
|
||||
"document.processed",
|
||||
"T",
|
||||
"M",
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dispatch_user_notification – preference loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDispatchUserNotification:
|
||||
"""Tests for dispatch_user_notification() preference dispatch logic."""
|
||||
|
||||
def _make_target(self, session, channel_type: str, config_dict: dict | None = None, is_active: bool = True):
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type=channel_type,
|
||||
name=f"{channel_type}-target",
|
||||
config=json.dumps(config_dict) if config_dict is not None else None,
|
||||
is_active=is_active,
|
||||
)
|
||||
session.add(target)
|
||||
session.commit()
|
||||
session.refresh(target)
|
||||
return target
|
||||
|
||||
def _make_pref(self, session, channel_type: str, target_id: int | None, is_enabled: bool = True):
|
||||
pref = UserNotificationPreference(
|
||||
owner_id=_OWNER,
|
||||
event_type="document.processed",
|
||||
channel_type=channel_type,
|
||||
target_id=target_id,
|
||||
is_enabled=is_enabled,
|
||||
)
|
||||
session.add(pref)
|
||||
session.commit()
|
||||
return pref
|
||||
|
||||
def test_dispatches_email_when_pref_enabled(self, Session):
|
||||
"""Email notification is sent for an active email preference."""
|
||||
s = Session()
|
||||
target = self._make_target(
|
||||
s,
|
||||
"email",
|
||||
{
|
||||
"smtp_host": "smtp.example.com",
|
||||
"recipient_email": "u@example.com",
|
||||
"smtp_use_tls": False,
|
||||
},
|
||||
)
|
||||
self._make_pref(s, "email", target.id)
|
||||
s.close()
|
||||
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch("app.utils.user_notification._send_email_notification", return_value=True) as mock_email,
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
|
||||
|
||||
mock_email.assert_called_once()
|
||||
|
||||
def test_dispatches_webhook_when_pref_enabled(self, Session):
|
||||
"""Webhook notification is sent for an active webhook preference."""
|
||||
s = Session()
|
||||
target = self._make_target(s, "webhook", {"url": "https://hook.example.com"})
|
||||
self._make_pref(s, "webhook", target.id)
|
||||
s.close()
|
||||
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch("app.utils.user_notification._send_webhook_notification", return_value=True) as mock_hook,
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
|
||||
|
||||
mock_hook.assert_called_once()
|
||||
|
||||
def test_skips_pref_with_no_target_id(self, Session):
|
||||
"""Preferences without a target_id are skipped (in-app only)."""
|
||||
s = Session()
|
||||
self._make_pref(s, "email", target_id=None)
|
||||
s.close()
|
||||
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch("app.utils.user_notification._send_email_notification") as mock_email,
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
|
||||
|
||||
mock_email.assert_not_called()
|
||||
|
||||
def test_skips_inactive_target(self, Session):
|
||||
"""Preferences pointing at an inactive target are skipped."""
|
||||
s = Session()
|
||||
target = self._make_target(s, "email", {"smtp_host": "x", "recipient_email": "y"}, is_active=False)
|
||||
self._make_pref(s, "email", target.id)
|
||||
s.close()
|
||||
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch("app.utils.user_notification._send_email_notification") as mock_email,
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
|
||||
|
||||
mock_email.assert_not_called()
|
||||
|
||||
def test_handles_invalid_json_config(self, Session):
|
||||
"""Invalid JSON in target.config falls back to empty dict (no crash)."""
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="email",
|
||||
name="bad-config-target",
|
||||
config="NOT_VALID_JSON",
|
||||
is_active=True,
|
||||
)
|
||||
s = Session()
|
||||
s.add(target)
|
||||
s.commit()
|
||||
s.refresh(target)
|
||||
self._make_pref(s, "email", target.id)
|
||||
s.close()
|
||||
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch("app.utils.user_notification._send_email_notification", return_value=False) as mock_email,
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
# Should not raise even though config is bad JSON
|
||||
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
|
||||
|
||||
# Called with empty config dict, which is missing smtp_host → returns False
|
||||
mock_email.assert_called_once_with({}, "Title", "Body")
|
||||
|
||||
def test_handles_null_config(self, Session):
|
||||
"""NULL target.config is treated as empty dict (no crash)."""
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="webhook",
|
||||
name="null-config-target",
|
||||
config=None,
|
||||
is_active=True,
|
||||
)
|
||||
s = Session()
|
||||
s.add(target)
|
||||
s.commit()
|
||||
s.refresh(target)
|
||||
self._make_pref(s, "webhook", target.id)
|
||||
s.close()
|
||||
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch("app.utils.user_notification._send_webhook_notification", return_value=False) as mock_hook,
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
|
||||
|
||||
mock_hook.assert_called_once_with({}, "document.processed", "Title", "Body")
|
||||
|
||||
def test_outer_exception_does_not_propagate(self):
|
||||
"""An exception in the preference query must be caught and logged."""
|
||||
bad_session = MagicMock()
|
||||
bad_session.query = MagicMock(side_effect=RuntimeError("DB gone"))
|
||||
bad_session.add = MagicMock()
|
||||
bad_session.commit = MagicMock()
|
||||
bad_session.refresh = MagicMock(return_value=MagicMock())
|
||||
bad_session.close = MagicMock()
|
||||
|
||||
BadSession = MagicMock(return_value=bad_session) # noqa: N806
|
||||
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", BadSession),
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
# Must not raise
|
||||
dispatch_user_notification(_OWNER, "document.processed", "Title", "Body")
|
||||
|
||||
def test_push_notification_sent(self, Session):
|
||||
"""Push notification is sent via send_push_to_owner."""
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch("app.utils.push_notification.send_push_to_owner") as mock_push,
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
dispatch_user_notification(_OWNER, "document.processed", "Push Title", "Push Body", file_id=99)
|
||||
|
||||
mock_push.assert_called_once_with(
|
||||
owner_id=_OWNER,
|
||||
title="Push Title",
|
||||
body="Push Body",
|
||||
data={"event_type": "document.processed", "file_id": 99},
|
||||
)
|
||||
|
||||
def test_push_exception_does_not_propagate(self, Session):
|
||||
"""An exception in send_push_to_owner must be caught and logged."""
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch(
|
||||
"app.utils.push_notification.send_push_to_owner",
|
||||
side_effect=RuntimeError("push service down"),
|
||||
),
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
# Must not raise
|
||||
dispatch_user_notification(_OWNER, "document.processed", "T", "M")
|
||||
|
||||
def test_unknown_channel_type_is_skipped(self, Session):
|
||||
"""Preferences with an unrecognised channel_type are silently skipped.
|
||||
|
||||
The live query filters to ("email", "webhook"), so this branch is only
|
||||
reachable via a mocked session that bypasses the filter. The test
|
||||
exercises the dead else-branch in dispatch_user_notification so that
|
||||
branch coverage reaches 100%.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="sms",
|
||||
name="sms-target",
|
||||
config=_json.dumps({"phone": "+1555000000"}),
|
||||
is_active=True,
|
||||
)
|
||||
s = Session()
|
||||
s.add(target)
|
||||
s.commit()
|
||||
s.refresh(target)
|
||||
target_id = target.id
|
||||
|
||||
unknown_pref = MagicMock()
|
||||
unknown_pref.target_id = target_id
|
||||
unknown_pref.channel_type = "sms"
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.all.return_value = [unknown_pref]
|
||||
|
||||
# Build a real session but intercept only the query for preferences
|
||||
real_session = Session()
|
||||
|
||||
def fake_query(model):
|
||||
from app.models import UserNotificationPreference as _UNP
|
||||
|
||||
if model is _UNP:
|
||||
return mock_query
|
||||
return real_session.query(model)
|
||||
|
||||
real_session.query = fake_query # type: ignore[method-assign]
|
||||
real_session_cls = MagicMock(return_value=real_session)
|
||||
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", real_session_cls),
|
||||
patch("app.utils.user_notification._send_email_notification") as mock_email,
|
||||
patch("app.utils.user_notification._send_webhook_notification") as mock_hook,
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import dispatch_user_notification
|
||||
|
||||
dispatch_user_notification(_OWNER, "document.processed", "T", "M")
|
||||
|
||||
mock_email.assert_not_called()
|
||||
mock_hook.assert_not_called()
|
||||
real_session.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notify_user_document_processed / notify_user_document_failed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotifyUserDocumentHelpers:
|
||||
"""Smoke tests for the convenience wrappers."""
|
||||
|
||||
def test_notify_processed_creates_in_app_record(self, Session):
|
||||
"""notify_user_document_processed creates an InAppNotification."""
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import notify_user_document_processed
|
||||
|
||||
notify_user_document_processed(owner_id=_OWNER, filename="report.pdf", file_id=5)
|
||||
|
||||
s = Session()
|
||||
notifs = s.query(InAppNotification).filter_by(owner_id=_OWNER).all()
|
||||
s.close()
|
||||
assert len(notifs) == 1
|
||||
assert "report.pdf" in notifs[0].title
|
||||
assert notifs[0].event_type == "document.processed"
|
||||
|
||||
def test_notify_failed_creates_in_app_record(self, Session):
|
||||
"""notify_user_document_failed creates an InAppNotification."""
|
||||
with (
|
||||
patch("app.utils.user_notification.SessionLocal", Session),
|
||||
patch("app.utils.push_notification.send_push_to_owner"),
|
||||
):
|
||||
from app.utils.user_notification import notify_user_document_failed
|
||||
|
||||
notify_user_document_failed(owner_id=_OWNER, filename="broken.pdf", error="Timeout")
|
||||
|
||||
s = Session()
|
||||
notifs = s.query(InAppNotification).filter_by(owner_id=_OWNER).all()
|
||||
s.close()
|
||||
assert len(notifs) == 1
|
||||
assert "broken.pdf" in notifs[0].title
|
||||
assert "Timeout" in notifs[0].message
|
||||
assert notifs[0].event_type == "document.failed"
|
||||
@@ -0,0 +1,608 @@
|
||||
"""Tests to boost code coverage for all view modules below 100%.
|
||||
|
||||
Covers: api_tokens, notifications, shared_links, share, plans,
|
||||
imap_accounts, integrations, general, filemanager, files, help.
|
||||
"""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import settings as app_settings
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _fresh_db():
|
||||
"""Yield a fresh in-memory SQLite session."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = sessionmaker(autocommit=False, autoflush=False, bind=engine)()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client_fresh(_fresh_db) -> TestClient:
|
||||
"""TestClient backed by a fresh database."""
|
||||
|
||||
def _override():
|
||||
try:
|
||||
yield _fresh_db
|
||||
finally:
|
||||
pass
|
||||
|
||||
app.dependency_overrides[get_db] = _override
|
||||
with TestClient(app, base_url="http://localhost") as tc:
|
||||
yield tc
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. Simple template-render views (api_tokens, notifications,
|
||||
# shared_links, share, plans)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestApiTokensView:
|
||||
"""GET /api-tokens should render the management page."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_api_tokens_page_returns_200(self, client_fresh: TestClient):
|
||||
resp = client_fresh.get("/api-tokens")
|
||||
assert resp.status_code == 200
|
||||
assert "API Tokens" in resp.text
|
||||
|
||||
|
||||
class TestNotificationsView:
|
||||
"""GET /notifications should render the dashboard."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_notifications_page_returns_200(self, client_fresh: TestClient):
|
||||
resp = client_fresh.get("/notifications")
|
||||
assert resp.status_code == 200
|
||||
assert "Notifications" in resp.text
|
||||
|
||||
|
||||
class TestSharedLinksView:
|
||||
"""GET /shared-links should render the management page."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_shared_links_page_returns_200(self, client_fresh: TestClient):
|
||||
resp = client_fresh.get("/shared-links")
|
||||
assert resp.status_code == 200
|
||||
assert "Shared Links" in resp.text
|
||||
|
||||
|
||||
class TestShareView:
|
||||
"""GET /share/{token} should render the public share landing page."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_share_page_returns_200(self, client_fresh: TestClient):
|
||||
resp = client_fresh.get("/share/abc123")
|
||||
assert resp.status_code == 200
|
||||
# Token should be passed to the template
|
||||
assert "abc123" in resp.text
|
||||
|
||||
|
||||
class TestPlansViews:
|
||||
"""GET /admin/plans and /admin/stripe-wizard should render pages."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_plan_designer_returns_200(self, client_fresh: TestClient):
|
||||
resp = client_fresh.get("/admin/plans")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_stripe_wizard_returns_200(self, client_fresh: TestClient):
|
||||
resp = client_fresh.get("/admin/stripe-wizard")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. imap_accounts view (30.95 % → 100 %)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestImapAccountsView:
|
||||
"""Tests for /imap-accounts view."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_imap_accounts_page_no_owner(self, client_fresh: TestClient):
|
||||
"""When no owner_id is resolved, page renders with defaults."""
|
||||
resp = client_fresh.get("/imap-accounts")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_imap_accounts_page_with_owner(self, _fresh_db, client_fresh: TestClient):
|
||||
"""When session has a user, the page queries IMAP accounts."""
|
||||
with patch("app.views.imap_accounts.get_current_owner_id", return_value="testuser"):
|
||||
with patch(
|
||||
"app.views.imap_accounts.get_user_tier_id",
|
||||
return_value="starter",
|
||||
):
|
||||
with patch(
|
||||
"app.views.imap_accounts.get_tier",
|
||||
return_value={"id": "starter", "name": "Starter", "max_mailboxes": 3},
|
||||
):
|
||||
resp = client_fresh.get("/imap-accounts")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_mailboxes_free_tier(self):
|
||||
"""Free tier should return 0 mailboxes."""
|
||||
from app.views.imap_accounts import _get_max_mailboxes
|
||||
|
||||
assert _get_max_mailboxes({"id": "free", "max_mailboxes": 0}) == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_mailboxes_unlimited(self):
|
||||
"""When max_mailboxes is 0 on a non-free tier, it means unlimited."""
|
||||
from app.views.imap_accounts import _get_max_mailboxes
|
||||
|
||||
assert _get_max_mailboxes({"id": "power", "max_mailboxes": 0}) is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_mailboxes_limited(self):
|
||||
"""When max_mailboxes > 0, return that value."""
|
||||
from app.views.imap_accounts import _get_max_mailboxes
|
||||
|
||||
assert _get_max_mailboxes({"id": "starter", "max_mailboxes": 5}) == 5
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. integrations view (82.09 % → 100 %)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestIntegrationsView:
|
||||
"""Tests for /integrations view."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_integrations_dashboard_no_owner(self, client_fresh: TestClient):
|
||||
"""When no owner, the dashboard renders with zero-count defaults."""
|
||||
resp = client_fresh.get("/integrations")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_integrations_dashboard_with_owner(self, _fresh_db, client_fresh: TestClient):
|
||||
"""When an owner_id is resolved, DB queries run and tier is fetched."""
|
||||
with patch("app.views.integrations.get_current_owner_id", return_value="testuser"):
|
||||
with patch("app.views.integrations.get_user_tier_id", return_value="power"):
|
||||
with patch(
|
||||
"app.views.integrations.get_tier",
|
||||
return_value={
|
||||
"id": "power",
|
||||
"name": "Power",
|
||||
"max_storage_destinations": 0,
|
||||
"max_mailboxes": 0,
|
||||
},
|
||||
):
|
||||
resp = client_fresh.get("/integrations")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_integrations_dashboard_generic_exception(self, client_fresh: TestClient):
|
||||
"""A non-HTTP exception in the dashboard returns 500."""
|
||||
with patch(
|
||||
"app.views.integrations.get_current_owner_id",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
resp = client_fresh.get("/integrations")
|
||||
assert resp.status_code == 500
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_integrations_dashboard_http_exception_passthrough(self, client_fresh: TestClient):
|
||||
"""An HTTPException inside the dashboard is re-raised, not wrapped in 500."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
with patch(
|
||||
"app.views.integrations.get_current_owner_id",
|
||||
side_effect=HTTPException(status_code=403, detail="Forbidden"),
|
||||
):
|
||||
resp = client_fresh.get("/integrations")
|
||||
assert resp.status_code == 403
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_destinations_free_default(self):
|
||||
from app.views.integrations import _get_max_destinations
|
||||
|
||||
assert _get_max_destinations({"id": "free", "max_storage_destinations": 0}) == 1
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_destinations_free_with_value(self):
|
||||
from app.views.integrations import _get_max_destinations
|
||||
|
||||
assert _get_max_destinations({"id": "free", "max_storage_destinations": 3}) == 3
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_destinations_unlimited(self):
|
||||
from app.views.integrations import _get_max_destinations
|
||||
|
||||
assert _get_max_destinations({"id": "power", "max_storage_destinations": 0}) is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_destinations_limited(self):
|
||||
from app.views.integrations import _get_max_destinations
|
||||
|
||||
assert _get_max_destinations({"id": "starter", "max_storage_destinations": 5}) == 5
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_sources_free(self):
|
||||
from app.views.integrations import _get_max_sources
|
||||
|
||||
assert _get_max_sources({"id": "free", "max_mailboxes": 0}) == 0
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_sources_unlimited(self):
|
||||
from app.views.integrations import _get_max_sources
|
||||
|
||||
assert _get_max_sources({"id": "power", "max_mailboxes": 0}) is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_max_sources_limited(self):
|
||||
from app.views.integrations import _get_max_sources
|
||||
|
||||
assert _get_max_sources({"id": "starter", "max_mailboxes": 2}) == 2
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4. general view (88.68 % → 100 %)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestGeneralViewMultiUser:
|
||||
"""Cover the multi_user_enabled subscription branch (lines 96-105)."""
|
||||
|
||||
@staticmethod
|
||||
def _signed_session(user_data: dict) -> str:
|
||||
"""Create a signed Starlette session cookie containing user_data."""
|
||||
import json
|
||||
from base64 import b64encode
|
||||
|
||||
from itsdangerous import TimestampSigner
|
||||
|
||||
secret = os.environ.get(
|
||||
"SESSION_SECRET",
|
||||
"test_secret_key_for_testing_must_be_at_least_32_characters_long",
|
||||
)
|
||||
signer = TimestampSigner(secret)
|
||||
data = {"user": user_data}
|
||||
return signer.sign(b64encode(json.dumps(data).encode("utf-8"))).decode("utf-8")
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_home_page_multi_user_with_subscription(self, _fresh_db, client_fresh: TestClient):
|
||||
"""When multi_user_enabled is True and user has owner_id, subscription info is fetched.
|
||||
|
||||
Lines 96-103: Exercises the subscription lookup path.
|
||||
"""
|
||||
cookie_val = self._signed_session({"username": "testuser", "email": "test@example.com", "is_admin": False})
|
||||
tier_mock = {
|
||||
"id": "starter",
|
||||
"name": "Starter",
|
||||
"lifetime_file_limit": 1000,
|
||||
"daily_upload_limit": 50,
|
||||
"monthly_upload_limit": 500,
|
||||
}
|
||||
usage_mock = {"lifetime": 10, "today": 2, "month": 8}
|
||||
with (
|
||||
patch.object(app_settings, "multi_user_enabled", True),
|
||||
patch("app.utils.setup_wizard.is_setup_required", return_value=False),
|
||||
patch("app.views.general.get_provider_status", return_value={}),
|
||||
patch("app.views.general.validate_storage_configs", return_value={}),
|
||||
patch("app.utils.subscription.get_user_tier_id", return_value="starter"),
|
||||
patch("app.utils.subscription.get_tier", return_value=tier_mock),
|
||||
patch("app.utils.subscription.get_user_usage", return_value=usage_mock),
|
||||
):
|
||||
client_fresh.cookies.set("session", cookie_val)
|
||||
resp = client_fresh.get("/?setup=complete")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_home_page_multi_user_subscription_error(self, _fresh_db, client_fresh: TestClient):
|
||||
"""When subscription lookup fails, error is logged but page still renders.
|
||||
|
||||
Lines 104-105: Exercises the exception handling branch.
|
||||
"""
|
||||
cookie_val = self._signed_session({"username": "testuser", "email": "test@example.com", "is_admin": False})
|
||||
with (
|
||||
patch.object(app_settings, "multi_user_enabled", True),
|
||||
patch("app.utils.setup_wizard.is_setup_required", return_value=False),
|
||||
patch("app.views.general.get_provider_status", return_value={}),
|
||||
patch("app.views.general.validate_storage_configs", return_value={}),
|
||||
patch("app.utils.subscription.get_user_tier_id", side_effect=RuntimeError("boom")),
|
||||
):
|
||||
client_fresh.cookies.set("session", cookie_val)
|
||||
resp = client_fresh.get("/?setup=complete")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 5. filemanager view (96.63 % → 100 %)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestFilemanagerCoverageGaps:
|
||||
"""Cover the remaining gaps in filemanager.py."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_size_petabytes(self):
|
||||
"""Line 43: _format_size should return PB for very large sizes."""
|
||||
from app.views.filemanager import _format_size
|
||||
|
||||
# 1 PB = 1024^5 bytes
|
||||
one_pb = 1024**5
|
||||
result = _format_size(one_pb)
|
||||
assert "PB" in result
|
||||
assert "1.0 PB" == result
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_format_size_multiple_petabytes(self):
|
||||
"""Large values above 1 PB."""
|
||||
from app.views.filemanager import _format_size
|
||||
|
||||
result = _format_size(5 * 1024**5)
|
||||
assert "PB" in result
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_scan_dir_with_broken_symlink(self, tmp_path):
|
||||
"""Lines 104-106: files that cannot be stat'd are skipped with a warning.
|
||||
|
||||
Using a broken symlink to trigger OSError on stat().
|
||||
"""
|
||||
from app.views.filemanager import _scan_dir
|
||||
|
||||
# Create a broken symlink — stat() will raise FileNotFoundError (subclass of OSError)
|
||||
broken_link = tmp_path / "broken_link.txt"
|
||||
broken_link.symlink_to("/nonexistent/target/file")
|
||||
|
||||
# Also create a valid file so we can verify it's included
|
||||
valid_file = tmp_path / "valid.txt"
|
||||
valid_file.write_text("hello")
|
||||
|
||||
db_paths: set[str] = set()
|
||||
entries = _scan_dir(tmp_path, tmp_path, db_paths)
|
||||
|
||||
# The broken symlink should be skipped, the valid file should be included
|
||||
entry_names = [e["name"] for e in entries]
|
||||
assert "broken_link.txt" not in entry_names
|
||||
assert "valid.txt" in entry_names
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_scan_dir_oserror(self, tmp_path):
|
||||
"""OSError during stat in _scan_dir is caught and file is skipped.
|
||||
|
||||
We create a second broken symlink for this test.
|
||||
"""
|
||||
from app.views.filemanager import _scan_dir
|
||||
|
||||
broken_link = tmp_path / "also_broken.txt"
|
||||
broken_link.symlink_to("/another/nonexistent/path")
|
||||
|
||||
valid_file = tmp_path / "good.txt"
|
||||
valid_file.write_text("ok")
|
||||
db_paths: set[str] = set()
|
||||
|
||||
entries = _scan_dir(tmp_path, tmp_path, db_paths)
|
||||
entry_names = [e["name"] for e in entries]
|
||||
assert "also_broken.txt" not in entry_names
|
||||
assert "good.txt" in entry_names
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_walk_all_files_with_broken_symlink(self, tmp_path):
|
||||
"""Lines 146-147: files that fail stat during walk are skipped.
|
||||
|
||||
Using a broken symlink to trigger OSError.
|
||||
"""
|
||||
from app.views.filemanager import _walk_all_files
|
||||
|
||||
broken_link = tmp_path / "broken.pdf"
|
||||
broken_link.symlink_to("/nonexistent/target/file")
|
||||
|
||||
valid_file = tmp_path / "valid.pdf"
|
||||
valid_file.write_text("content")
|
||||
|
||||
db_paths: set[str] = set()
|
||||
entries = _walk_all_files(tmp_path, db_paths)
|
||||
|
||||
entry_names = [e["name"] for e in entries]
|
||||
assert "broken.pdf" not in entry_names
|
||||
assert "valid.pdf" in entry_names
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 6. files view (99.21 % → 100 %)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestFilesViewCoverageGaps:
|
||||
"""Cover the remaining branches in files.py."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_compute_processing_flow_with_pipeline_steps(self):
|
||||
"""Lines 504-515: pipeline_steps filtering in _compute_processing_flow."""
|
||||
from app.views.files import _compute_processing_flow
|
||||
|
||||
# Create mock pipeline steps
|
||||
ps1 = SimpleNamespace(enabled=True, step_type="ocr")
|
||||
ps2 = SimpleNamespace(enabled=False, step_type="extract_metadata")
|
||||
ps3 = SimpleNamespace(enabled=True, step_type="send_to_destinations")
|
||||
|
||||
# Create mock logs with all required attributes including task_id
|
||||
log1 = SimpleNamespace(
|
||||
step_name="create_file_record",
|
||||
status="completed",
|
||||
message="ok",
|
||||
timestamp=None,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
task_id="task-001",
|
||||
)
|
||||
log2 = SimpleNamespace(
|
||||
step_name="check_text",
|
||||
status="completed",
|
||||
message="ok",
|
||||
timestamp=None,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
task_id="task-002",
|
||||
)
|
||||
|
||||
result = _compute_processing_flow([log1, log2], pipeline_steps=[ps1, ps2, ps3])
|
||||
|
||||
# _compute_processing_flow returns a list of stage dicts
|
||||
stage_keys = [s["key"] for s in result]
|
||||
assert "create_file_record" in stage_keys # always shown
|
||||
assert "check_text" in stage_keys # OCR step type + ran
|
||||
# extract_metadata is disabled, so its stages should NOT be included
|
||||
assert "extract_metadata_with_gpt" not in stage_keys
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_compute_processing_flow_with_pipeline_steps_none(self):
|
||||
"""When pipeline_steps is None, all stages are shown."""
|
||||
from app.views.files import _compute_processing_flow
|
||||
|
||||
result = _compute_processing_flow([], pipeline_steps=None)
|
||||
stage_keys = [s["key"] for s in result]
|
||||
assert "create_file_record" in stage_keys
|
||||
assert "extract_metadata_with_gpt" in stage_keys
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_compute_processing_flow_with_empty_pipeline_steps(self):
|
||||
"""When pipeline_steps is empty list, only always-show + ran stages remain."""
|
||||
from app.views.files import _compute_processing_flow
|
||||
|
||||
result = _compute_processing_flow([], pipeline_steps=[])
|
||||
stage_keys = [s["key"] for s in result]
|
||||
assert "create_file_record" in stage_keys
|
||||
# Other stages should be filtered out
|
||||
assert "convert_to_pdf" not in stage_keys
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_compute_processing_flow_dedup_enabled(self):
|
||||
"""When dedup is enabled and shown, check_for_duplicates stage appears."""
|
||||
from app.views.files import _compute_processing_flow
|
||||
|
||||
with (
|
||||
patch.object(app_settings, "enable_deduplication", True),
|
||||
patch.object(app_settings, "show_deduplication_step", True),
|
||||
):
|
||||
result = _compute_processing_flow([], pipeline_steps=None)
|
||||
stage_keys = [s["key"] for s in result]
|
||||
assert "check_for_duplicates" in stage_keys
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_file_detail_safe_exists_value_error(self, _fresh_db, client_fresh: TestClient):
|
||||
"""Test that _safe_exists handles ValueError from commonpath gracefully.
|
||||
|
||||
Lines 240-241: When os.path.commonpath raises ValueError (e.g., paths
|
||||
on different drives on Windows), _safe_exists returns False.
|
||||
"""
|
||||
from app.models import FileRecord
|
||||
|
||||
# Create a file record with all required fields
|
||||
rec = FileRecord(
|
||||
original_filename="test.pdf",
|
||||
local_filename="/tmp/test_local.pdf",
|
||||
original_file_path="/tmp/test_original.pdf",
|
||||
processed_file_path="/tmp/test_processed.pdf",
|
||||
file_size=100,
|
||||
mime_type="application/pdf",
|
||||
filehash="abc123def456",
|
||||
)
|
||||
_fresh_db.add(rec)
|
||||
_fresh_db.commit()
|
||||
_fresh_db.refresh(rec)
|
||||
|
||||
# Patch commonpath to raise ValueError
|
||||
with patch("os.path.commonpath", side_effect=ValueError("different drives")):
|
||||
resp = client_fresh.get(f"/files/{rec.id}")
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 7. help view (96 % → 100 %)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestHelpViewCoverageGaps:
|
||||
"""Cover the missing branch in help.py (34->37)."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_help_page_no_session_attr(self, client_fresh: TestClient):
|
||||
"""When no session user is set, defaults are used for Zammad widgets."""
|
||||
resp = client_fresh.get("/help")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_page_request_without_session(self):
|
||||
"""Direct function call where request has no session attribute.
|
||||
|
||||
Branch 34->37: when hasattr(request, 'session') is False.
|
||||
"""
|
||||
from app.views.help import help_center
|
||||
|
||||
# Create a mock request without session attribute
|
||||
mock_request = MagicMock(spec=[]) # spec=[] means no attributes
|
||||
# help_center checks hasattr(request, "session")
|
||||
# With spec=[], hasattr will return False
|
||||
|
||||
with patch("app.views.help.templates") as mock_templates:
|
||||
mock_templates.TemplateResponse.return_value = "ok"
|
||||
await help_center(mock_request)
|
||||
|
||||
# Template should be called with empty user context
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
ctx = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("context", {})
|
||||
assert ctx["user_name"] == ""
|
||||
assert ctx["user_email"] == ""
|
||||
assert ctx["user_id"] == ""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_help_page_with_logged_in_user(self, client_fresh: TestClient):
|
||||
"""When session has user data, Zammad widget fields are populated.
|
||||
|
||||
Covers lines 41-43 (user_name, user_email, user_id extraction).
|
||||
"""
|
||||
import json
|
||||
from base64 import b64encode
|
||||
|
||||
from itsdangerous import TimestampSigner
|
||||
|
||||
secret = os.environ.get(
|
||||
"SESSION_SECRET",
|
||||
"test_secret_key_for_testing_must_be_at_least_32_characters_long",
|
||||
)
|
||||
signer = TimestampSigner(secret)
|
||||
session_data = {
|
||||
"user": {
|
||||
"name": "Jane Doe",
|
||||
"email": "jane@example.com",
|
||||
"preferred_username": "janedoe",
|
||||
}
|
||||
}
|
||||
cookie_val = signer.sign(b64encode(json.dumps(session_data).encode("utf-8"))).decode("utf-8")
|
||||
client_fresh.cookies.set("session", cookie_val)
|
||||
|
||||
resp = client_fresh.get("/help")
|
||||
assert resp.status_code == 200
|
||||
@@ -82,6 +82,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
assert result == []
|
||||
@@ -107,6 +109,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
@@ -133,6 +137,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
@@ -159,6 +165,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
@@ -185,6 +193,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
@@ -211,6 +221,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
@@ -237,6 +249,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
@@ -263,6 +277,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
@@ -289,6 +305,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = "user"
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
@@ -315,6 +333,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = "ftp.example.com"
|
||||
cfg.ftp_username = "user"
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
@@ -341,6 +361,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = "sftpuser"
|
||||
cfg.ftp_host = "ftp.example.com"
|
||||
cfg.ftp_username = "ftpuser"
|
||||
cfg.icloud_username = "user@example.com"
|
||||
cfg.icloud_password = "app-pass"
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
assert len(result) == len(_DESTINATION_META)
|
||||
@@ -366,6 +388,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
assert len(result) == 1
|
||||
@@ -393,6 +417,8 @@ class TestGetConfiguredDestinations:
|
||||
cfg.sftp_username = None
|
||||
cfg.ftp_host = None
|
||||
cfg.ftp_username = None
|
||||
cfg.icloud_username = None
|
||||
cfg.icloud_password = None
|
||||
|
||||
result = _get_configured_destinations(cfg)
|
||||
ids = [d["id"] for d in result]
|
||||
|
||||
Reference in New Issue
Block a user