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:
copilot-swe-agent[bot]
2026-03-16 22:33:59 +00:00
parent 23498e0a98
commit 651b48658c
318 changed files with 317516 additions and 2858 deletions
+3
View File
@@ -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,
+140
View File
@@ -269,6 +269,53 @@ class TestSavedSearchesCRUD:
response2 = client.post("/api/saved-searches", json=payload)
assert response2.status_code == 409
def test_create_saved_search_db_error(self, client: TestClient, monkeypatch):
"""POST /api/saved-searches returns 500 on DB exception."""
# Mock db.add or db.commit to raise an exception
# We can monkeypatch the route's dependency or the models
# It's easier to mock the SavedSearch model's __init__ or db's add
# Since we use db: DbSession, it's an instance of sqlalchemy.orm.Session
from sqlalchemy.orm import Session
original_commit = Session.commit
def mock_commit(*args, **kwargs):
raise Exception("Simulated DB error")
monkeypatch.setattr(Session, "commit", mock_commit)
payload = {
"name": "DB Error Search",
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 500
assert "Failed to save search" in response.json()["detail"]
def test_create_saved_search_limit_reached(self, client: TestClient, monkeypatch):
"""POST /api/saved-searches returns 409 if max limit is reached."""
monkeypatch.setattr("app.api.saved_searches.MAX_SAVED_SEARCHES_PER_USER", 1)
# Create first one
payload1 = {"name": "Search 1", "filters": {"status": "completed"}}
response1 = client.post("/api/saved-searches", json=payload1)
assert response1.status_code == 201
# Creating second one should fail due to limit
payload2 = {"name": "Search 2", "filters": {"status": "pending"}}
response2 = client.post("/api/saved-searches", json=payload2)
assert response2.status_code == 409
assert "Maximum of 1 saved searches reached" in response2.json()["detail"]
def test_create_saved_search_invalid_name_type(self, client: TestClient):
"""POST /api/saved-searches with non-string name returns 422."""
payload = {
"name": 12345,
"filters": {"status": "completed"},
}
response = client.post("/api/saved-searches", json=payload)
assert response.status_code == 422
def test_update_saved_search(self, client: TestClient):
"""PUT /api/saved-searches/{id} updates the saved search."""
# Create
@@ -296,6 +343,83 @@ class TestSavedSearchesCRUD:
)
assert response.status_code == 404
def test_update_saved_search_duplicate_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with duplicate name returns 409."""
# Create first search
client.post(
"/api/saved-searches",
json={"name": "First Search", "filters": {"status": "pending"}},
)
# Create second search
create_resp2 = client.post(
"/api/saved-searches",
json={"name": "Second Search", "filters": {"status": "completed"}},
)
search_id2 = create_resp2.json()["id"]
# Try to rename second search to "First Search"
update_resp = client.put(
f"/api/saved-searches/{search_id2}",
json={"name": "First Search", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 409
def test_update_saved_search_name_too_long(self, client: TestClient):
"""PUT /api/saved-searches/{id} with name > 100 chars returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "x" * 101, "filters": {"status": "completed"}},
)
assert update_resp.status_code == 422
def test_update_saved_search_empty_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with empty name returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "", "filters": {"status": "completed"}},
)
assert update_resp.status_code == 422
def test_update_saved_search_empty_filters(self, client: TestClient):
"""PUT /api/saved-searches/{id} with empty filters returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Valid Name", "filters": {}},
)
assert update_resp.status_code == 422
def test_update_saved_search_invalid_filters(self, client: TestClient):
"""PUT /api/saved-searches/{id} with only invalid filters returns 422."""
create_resp = client.post(
"/api/saved-searches",
json={"name": "Valid Name", "filters": {"status": "pending"}},
)
search_id = create_resp.json()["id"]
update_resp = client.put(
f"/api/saved-searches/{search_id}",
json={"name": "Valid Name", "filters": {"invalid_key": "value"}},
)
assert update_resp.status_code == 422
def test_delete_saved_search(self, client: TestClient):
"""DELETE /api/saved-searches/{id} removes the saved search."""
# Create
@@ -318,6 +442,22 @@ class TestSavedSearchesCRUD:
response = client.delete("/api/saved-searches/999")
assert response.status_code == 404
def test_delete_saved_search_db_error(self, client: TestClient):
"""DELETE /api/saved-searches/{id} handles database errors (500)."""
from unittest.mock import patch
# Create
create_resp = client.post(
"/api/saved-searches",
json={"name": "To Delete DB Error", "filters": {"status": "failed"}},
)
search_id = create_resp.json()["id"]
with patch("sqlalchemy.orm.Session.delete", side_effect=Exception("DB Delete Error")):
response = client.delete(f"/api/saved-searches/{search_id}")
assert response.status_code == 500
assert response.json()["detail"] == "Failed to delete saved search"
def test_create_name_too_long(self, client: TestClient):
"""POST /api/saved-searches with name > 100 chars returns 422."""
payload = {
+7 -2
View File
@@ -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
+11 -7
View File
@@ -7,7 +7,6 @@ Covers Dropbox OAuth endpoints, settings management, and token testing.
from unittest.mock import Mock, patch
import pytest
import requests
@pytest.mark.unit
@@ -137,7 +136,7 @@ class TestTestDropboxToken:
assert data["status"] == "error"
assert "not fully configured" in data["message"]
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings")
def test_valid_token(self, mock_settings, mock_post, client):
"""Test successful token validation."""
@@ -162,7 +161,7 @@ class TestTestDropboxToken:
assert data["account"] == "user@example.com"
assert data["account_name"] == "Test User"
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings")
def test_expired_token_refreshed(self, mock_settings, mock_post, client):
"""Test that expired token triggers refresh and retry."""
@@ -194,7 +193,7 @@ class TestTestDropboxToken:
data = response.json()
assert data["status"] == "success"
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings")
def test_refresh_token_expired(self, mock_settings, mock_post, client):
"""Test handling when refresh token itself is expired."""
@@ -220,7 +219,7 @@ class TestTestDropboxToken:
assert data["status"] == "error"
assert data["needs_reauth"] is True
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings")
def test_token_validation_failure(self, mock_settings, mock_post, client):
"""Test handling non-401, non-200 response."""
@@ -240,16 +239,21 @@ class TestTestDropboxToken:
data = response.json()
assert data["status"] == "error"
@patch("app.api.dropbox.requests.post")
@patch("app.api.dropbox.httpx.AsyncClient.post")
@patch("app.api.dropbox.settings")
def test_connection_error(self, mock_settings, mock_post, client):
"""Test handling of connection exceptions."""
import httpx
mock_settings.dropbox_refresh_token = "token"
mock_settings.dropbox_app_key = "app-key"
mock_settings.dropbox_app_secret = "app-secret"
mock_settings.http_request_timeout = 30
mock_post.side_effect = requests.exceptions.ConnectionError("Connection refused")
mock_post.side_effect = httpx.RequestError(
"Connection refused",
request=httpx.Request("POST", "https://api.dropboxapi.com/2/users/get_current_account"),
)
response = client.get("/api/dropbox/test-token")
+23 -19
View File
@@ -84,8 +84,8 @@ class TestUpdateDropboxSettings:
class TestTestDropboxToken:
"""Tests for GET /dropbox/test-token endpoint."""
@patch("app.api.dropbox.requests.post")
def test_test_token_success(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_success(self, mock_client_cls):
"""Test successful token validation."""
from app.config import settings
@@ -95,7 +95,7 @@ class TestTestDropboxToken:
"email": "test@example.com",
"name": {"display_name": "Test User"},
}
mock_post.return_value = mock_response
mock_client_cls.return_value.__aenter__.return_value.post.return_value = mock_response
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
@@ -104,8 +104,8 @@ class TestTestDropboxToken:
# Should include account email and name
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_not_configured(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_not_configured(self, mock_client_cls):
"""Test when credentials are not configured."""
from app.config import settings
@@ -113,8 +113,8 @@ class TestTestDropboxToken:
# Should return error indicating not configured
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_partial_config(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_partial_config(self, mock_client_cls):
"""Test with partial configuration (missing some credentials)."""
from app.config import settings
@@ -123,8 +123,8 @@ class TestTestDropboxToken:
# Should return error
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_expired_requires_refresh(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_expired_requires_refresh(self, mock_client_cls):
"""Test when access token is expired and needs refresh."""
from app.config import settings
@@ -145,7 +145,9 @@ class TestTestDropboxToken:
"name": {"display_name": "Test User"},
}
mock_post.side_effect = [mock_response_401, mock_refresh_response, mock_success_response]
mock_client = MagicMock()
mock_client.post.side_effect = [mock_response_401, mock_refresh_response, mock_success_response]
mock_client_cls.return_value.__aenter__.return_value = mock_client
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
@@ -153,8 +155,8 @@ class TestTestDropboxToken:
# Should refresh and succeed
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_refresh_failed(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_refresh_failed(self, mock_client_cls):
"""Test when refresh token is invalid."""
from app.config import settings
@@ -167,7 +169,9 @@ class TestTestDropboxToken:
mock_refresh_response.status_code = 400
mock_refresh_response.text = "Invalid refresh token"
mock_post.side_effect = [mock_response_401, mock_refresh_response]
mock_client = MagicMock()
mock_client.post.side_effect = [mock_response_401, mock_refresh_response]
mock_client_cls.return_value.__aenter__.return_value = mock_client
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
@@ -175,8 +179,8 @@ class TestTestDropboxToken:
# Should return error with needs_reauth: True
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_perpetual_token_info(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_perpetual_token_info(self, mock_client_cls):
"""Test that perpetual token info is returned."""
from app.config import settings
@@ -186,7 +190,7 @@ class TestTestDropboxToken:
"email": "test@example.com",
"name": {"display_name": "Test User"},
}
mock_post.return_value = mock_response
mock_client_cls.return_value.__aenter__.return_value.post.return_value = mock_response
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
@@ -194,12 +198,12 @@ class TestTestDropboxToken:
# token_info should indicate never expires
pass
@patch("app.api.dropbox.requests.post")
def test_test_token_exception_handling(self, mock_post):
@patch("app.api.dropbox.httpx.AsyncClient")
def test_test_token_exception_handling(self, mock_client_cls):
"""Test handling of exceptions."""
from app.config import settings
mock_post.side_effect = Exception("Network error")
mock_client_cls.return_value.__aenter__.return_value.post.side_effect = Exception("Network error")
with patch.object(settings, "dropbox_refresh_token", "token"):
with patch.object(settings, "dropbox_app_key", "key"):
+8 -8
View File
@@ -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",
+1 -1
View File
@@ -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."""
+518
View File
@@ -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"]
+21 -21
View File
@@ -5,7 +5,7 @@ Focuses on uncovered lines: 98-99, 121-143, 160-161, 170-171,
324-326, 400-402, 436-438.
"""
from unittest.mock import MagicMock, PropertyMock, patch
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
import pytest
from fastapi.testclient import TestClient
@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
class TestTestTokenRefreshFailed:
"""Cover lines 98-99: token refresh returns non-200."""
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_test_token_refresh_returns_non_200(self, mock_post, client: TestClient):
"""Test token refresh returning a failure status hits the error branch."""
from app.config import settings
@@ -42,8 +42,8 @@ class TestTestTokenRefreshFailed:
class TestTestTokenRotation:
"""Cover lines 121-143, 160-161: token rotation with .env and DB persist."""
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_env_file_exists(self, mock_post, mock_get, client: TestClient, tmp_path):
"""When a new refresh token is received and .env file exists, it should be updated."""
from app.config import settings
@@ -75,8 +75,8 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.join", return_value=str(env_file)),
patch("app.api.onedrive.os.path.exists", return_value=True),
patch("app.utils.env_utils.os.path.join", return_value=str(env_file)),
patch("app.utils.env_utils.os.path.exists", return_value=True),
patch("app.database.SessionLocal") as mock_session_local,
patch("app.api.onedrive.save_setting_to_db"),
patch("app.api.onedrive.notify_settings_updated"),
@@ -90,8 +90,8 @@ class TestTestTokenRotation:
data = response.json()
assert data["status"] == "success"
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_env_not_existing(self, mock_post, mock_get, client: TestClient):
"""Token rotation when .env doesn't exist still succeeds."""
from app.config import settings
@@ -117,7 +117,7 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.exists", return_value=False),
patch("app.utils.env_utils.os.path.exists", return_value=False),
patch("app.database.SessionLocal") as mock_session_local,
patch("app.api.onedrive.save_setting_to_db"),
patch("app.api.onedrive.notify_settings_updated"),
@@ -130,8 +130,8 @@ class TestTestTokenRotation:
assert response.status_code == 200
assert response.json()["status"] == "success"
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_env_write_failure(self, mock_post, mock_get, client: TestClient):
"""Token rotation when .env write fails (lines 142-143) still continues."""
from app.config import settings
@@ -157,7 +157,7 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.exists", return_value=True),
patch("app.utils.env_utils.os.path.exists", return_value=True),
patch("builtins.open", side_effect=PermissionError("Permission denied")),
patch("app.database.SessionLocal") as mock_session_local,
patch("app.api.onedrive.save_setting_to_db"),
@@ -171,8 +171,8 @@ class TestTestTokenRotation:
assert response.status_code == 200
assert response.json()["status"] == "success"
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_db_persist_failure(self, mock_post, mock_get, client: TestClient):
"""Token rotation when DB persist fails (lines 160-161) still continues."""
from app.config import settings
@@ -198,7 +198,7 @@ class TestTestTokenRotation:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.exists", return_value=False),
patch("app.utils.env_utils.os.path.exists", return_value=False),
patch("app.database.SessionLocal", side_effect=Exception("DB error")),
):
response = client.get("/api/onedrive/test-token")
@@ -211,8 +211,8 @@ class TestTestTokenRotation:
class TestTestTokenUserInfoFailed:
"""Cover lines 170-171: user info request fails."""
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_user_info_returns_non_200(self, mock_post, mock_get, client: TestClient):
"""Test when user info request fails after successful token refresh."""
from app.config import settings
@@ -247,8 +247,8 @@ class TestTestTokenUserInfoFailed:
class TestTokenRotationEnvAppendLine:
"""Cover the branch at line 134 where token line is not found in .env and must be appended."""
@patch("app.api.onedrive.requests.get")
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_token_rotation_appends_to_env(self, mock_post, mock_get, client: TestClient, tmp_path):
"""When .env exists but doesn't have ONEDRIVE_REFRESH_TOKEN, it should append."""
from app.config import settings
@@ -277,8 +277,8 @@ class TestTokenRotationEnvAppendLine:
patch.object(settings, "onedrive_refresh_token", "old_token"),
patch.object(settings, "onedrive_client_id", "cid"),
patch.object(settings, "onedrive_client_secret", "sec"),
patch("app.api.onedrive.os.path.join", return_value=str(env_file)),
patch("app.api.onedrive.os.path.exists", return_value=True),
patch("app.utils.env_utils.os.path.join", return_value=str(env_file)),
patch("app.utils.env_utils.os.path.exists", return_value=True),
patch("app.database.SessionLocal") as mock_sl,
patch("app.api.onedrive.save_setting_to_db"),
patch("app.api.onedrive.notify_settings_updated"),
+12 -12
View File
@@ -1,7 +1,7 @@
"""Comprehensive unit tests for app/api/onedrive.py module."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -48,8 +48,8 @@ class TestExchangeOneDriveToken:
class TestTestOneDriveToken:
"""Tests for GET /onedrive/test-token endpoint."""
@patch("app.api.onedrive.requests.post")
@patch("app.api.onedrive.requests.get")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
def test_test_token_success(self, mock_get, mock_post):
"""Test successful token validation."""
from app.config import settings
@@ -79,7 +79,7 @@ class TestTestOneDriveToken:
# Should return success
pass
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_test_token_not_configured(self, mock_post):
"""Test when credentials are not configured."""
from app.config import settings
@@ -88,7 +88,7 @@ class TestTestOneDriveToken:
# Should return error
pass
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_test_token_refresh_failed(self, mock_post):
"""Test when token refresh fails."""
from app.config import settings
@@ -104,8 +104,8 @@ class TestTestOneDriveToken:
# Should return error with needs_reauth
pass
@patch("app.api.onedrive.requests.post")
@patch("app.api.onedrive.requests.get")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
def test_test_token_user_info_failed(self, mock_get, mock_post):
"""Test when user info request fails."""
from app.config import settings
@@ -128,8 +128,8 @@ class TestTestOneDriveToken:
# Should return error
pass
@patch("app.api.onedrive.requests.post")
@patch("app.api.onedrive.requests.get")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
@patch("builtins.open", create=True)
@patch("os.path.exists")
def test_test_token_updates_refresh_token(self, mock_exists, mock_open, mock_get, mock_post):
@@ -167,8 +167,8 @@ class TestTestOneDriveToken:
# Should update refresh token in memory and file
pass
@patch("app.api.onedrive.requests.post")
@patch("app.api.onedrive.requests.get")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
def test_test_token_expiration_info(self, mock_get, mock_post):
"""Test that expiration info is included."""
from app.config import settings
@@ -195,7 +195,7 @@ class TestTestOneDriveToken:
# token_info should include expiration details
pass
@patch("app.api.onedrive.requests.post")
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
def test_test_token_exception_handling(self, mock_post):
"""Test handling of exceptions."""
from app.config import settings
+597
View File
@@ -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)
+191
View File
@@ -0,0 +1,191 @@
"""Tests for the saved searches API (app/api/saved_searches.py)."""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import SavedSearch
# ---------------------------------------------------------------------------
# Test data constants
# ---------------------------------------------------------------------------
_OWNER = "test_user@example.com"
_OTHER_OWNER = "other_user@example.com"
# ---------------------------------------------------------------------------
# Shared fixture helpers
# ---------------------------------------------------------------------------
@pytest.fixture()
def int_engine():
"""In-memory SQLite engine for integration tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def int_session(int_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=int_engine)
session = Session()
yield session
session.close()
def _make_client(int_engine, owner_id: str = _OWNER):
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from unittest.mock import patch
from app.main import app
def override_db():
Session = sessionmaker(bind=int_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
with patch("app.api.saved_searches._get_user_id", return_value=owner_id):
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
yield client
app.dependency_overrides.clear()
@pytest.fixture()
def int_client(int_engine):
"""TestClient authenticated as _OWNER."""
yield from _make_client(int_engine, _OWNER)
# ---------------------------------------------------------------------------
# CRUD tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSavedSearchesAPI:
"""Tests for Saved Searches endpoints."""
def test_list_saved_searches_empty(self, int_client):
"""No saved searches returns empty list."""
resp = int_client.get("/api/saved-searches")
assert resp.status_code == 200
assert resp.json() == []
def test_create_saved_search(self, int_client):
"""Create a saved search and verify the response."""
payload = {"name": "My Invoices", "filters": {"tags": "invoice", "document_type": "Invoice"}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "My Invoices"
assert data["filters"] == {"tags": "invoice", "document_type": "Invoice"}
assert "id" in data
def test_create_saved_search_invalid_filters(self, int_client):
"""Creating with invalid filters returns 422."""
# Missing filters parameter (or empty after sanitization)
payload = {"name": "My Invoices", "filters": {}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 422
# Invalid filters format
payload2 = {"name": "My Invoices", "filters": "not_a_dict"}
resp2 = int_client.post("/api/saved-searches", json=payload2)
assert resp2.status_code == 422
def test_create_saved_search_duplicate(self, int_client):
"""Creating a duplicate named search returns 409."""
payload = {"name": "Duplicate", "filters": {"q": "test"}}
int_client.post("/api/saved-searches", json=payload)
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 409
def test_create_saved_search_limit(self, int_client, int_session):
"""Exceeding MAX_SAVED_SEARCHES_PER_USER returns 409."""
# Create 50 searches using the API to ensure they are visible
for i in range(50):
resp = int_client.post("/api/saved-searches", json={"name": f"Search LIMIT {i}", "filters": {"q": "test"}})
assert resp.status_code == 201
payload = {"name": "One too many", "filters": {"q": "test"}}
resp = int_client.post("/api/saved-searches", json=payload)
assert resp.status_code == 409
def test_update_saved_search(self, int_client):
"""Update an existing saved search."""
payload = {"name": "Original Name", "filters": {"q": "test"}}
created = int_client.post("/api/saved-searches", json=payload).json()
search_id = created["id"]
update_payload = {"name": "Updated Name", "filters": {"tags": "new"}}
resp = int_client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Updated Name"
assert data["filters"] == {"tags": "new"}
def test_update_saved_search_not_found(self, int_client):
"""Updating a non-existent search returns 404."""
update_payload = {"name": "Updated Name"}
resp = int_client.put("/api/saved-searches/999", json=update_payload)
assert resp.status_code == 404
def test_update_saved_search_duplicate_name(self, int_client):
"""Updating name to an existing search name returns 409."""
payload1 = {"name": "Search 1", "filters": {"q": "a"}}
payload2 = {"name": "Search 2", "filters": {"q": "b"}}
int_client.post("/api/saved-searches", json=payload1)
created2 = int_client.post("/api/saved-searches", json=payload2).json()
search2_id = created2["id"]
update_payload = {"name": "Search 1"}
resp = int_client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
assert resp.status_code == 409
def test_delete_saved_search(self, int_client, int_session):
"""Delete an existing search."""
payload = {"name": "To be deleted", "filters": {"q": "test"}}
created = int_client.post("/api/saved-searches", json=payload).json()
search_id = created["id"]
resp = int_client.delete(f"/api/saved-searches/{search_id}")
assert resp.status_code == 204
assert int_session.query(SavedSearch).filter(SavedSearch.id == search_id).first() is None
def test_delete_saved_search_not_found(self, int_client):
"""Deleting a non-existent search returns 404."""
resp = int_client.delete("/api/saved-searches/999")
assert resp.status_code == 404
def test_other_users_searches_isolated(self, int_engine, int_session):
"""Users only see and can only modify their own saved searches."""
int_session.add(SavedSearch(user_id=_OTHER_OWNER, name="Other Search", filters='{"q": "test"}'))
int_session.commit()
client = next(_make_client(int_engine, _OWNER))
resp = client.get("/api/saved-searches")
assert resp.status_code == 200
assert len(resp.json()) == 0
other_search = int_session.query(SavedSearch).first()
resp = client.put(f"/api/saved-searches/{other_search.id}", json={"name": "Hacked"})
assert resp.status_code == 404
resp = client.delete(f"/api/saved-searches/{other_search.id}")
assert resp.status_code == 404
+241
View File
@@ -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
View File
@@ -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:
+657
View File
@@ -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
+35
View File
@@ -2,6 +2,7 @@
import asyncio
import hashlib
import logging
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -38,6 +39,40 @@ class TestGetCurrentUser:
result = get_current_user(mock_request)
assert result is None
def test_logs_debug_when_session_user_found(self, caplog):
"""Test that get_current_user emits a DEBUG log when session user is found."""
mock_request = MagicMock(spec=Request)
mock_request.session = {"user": {"id": "u1", "preferred_username": "alice"}}
mock_request.state = MagicMock(spec=[]) # no api_token_user attribute
with caplog.at_level(logging.DEBUG, logger="app.auth"):
get_current_user(mock_request)
assert any("[AUTH] get_current_user: resolved from session" in m for m in caplog.messages)
def test_logs_debug_when_no_user(self, caplog):
"""Test that get_current_user emits a DEBUG log when no user is present."""
mock_request = MagicMock(spec=Request)
mock_request.session = {}
mock_request.state = MagicMock(spec=[])
with caplog.at_level(logging.DEBUG, logger="app.auth"):
get_current_user(mock_request)
assert any("[AUTH] get_current_user: no user in session or API token" in m for m in caplog.messages)
def test_logs_debug_when_api_token_user(self, caplog):
"""Test that get_current_user emits a DEBUG log when resolved from API token."""
mock_request = MagicMock(spec=Request)
mock_request.state.api_token_user = {"id": "tok_user"}
mock_request.session = {}
with caplog.at_level(logging.DEBUG, logger="app.auth"):
result = get_current_user(mock_request)
assert result == {"id": "tok_user"}
assert any("[AUTH] get_current_user: resolved from API token" in m for m in caplog.messages)
@pytest.mark.unit
class TestGetGravatarUrl:
+139
View File
@@ -224,3 +224,142 @@ class TestTaskFailureHandler:
# Simply verify that importing the handler doesn't cause errors
# The actual signal connection is tested implicitly by the other tests
assert callable(task_failure_handler)
@pytest.mark.unit
class TestDispatchUserFailureNotification:
"""Tests for _dispatch_user_failure_notification helper."""
@patch("app.celery_app._dispatch_user_failure_notification")
@patch("app.celery_app.settings")
@patch("app.utils.notification.notify_celery_failure")
def test_task_failure_handler_calls_user_failure_dispatch(self, mock_notify_sys, mock_settings, mock_dispatch):
"""task_failure_handler also calls _dispatch_user_failure_notification."""
mock_settings.notify_on_task_failure = True
from app.celery_app import task_failure_handler
mock_sender = MagicMock()
mock_sender.name = "app.tasks.process_document.process_document"
exc = ValueError("OCR timeout")
task_failure_handler(
sender=mock_sender,
task_id="tid",
exception=exc,
args=["/tmp/f.pdf"],
kwargs={"file_id": 42},
)
mock_dispatch.assert_called_once_with(mock_sender, exc, ["/tmp/f.pdf"], {"file_id": 42})
def test_dispatch_ignores_non_document_tasks(self):
"""Non app.tasks.* tasks should be silently ignored."""
from app.celery_app import _dispatch_user_failure_notification
sender = MagicMock()
sender.name = "celery.backend_cleanup"
# Should complete without error or DB access
_dispatch_user_failure_notification(sender, ValueError("x"), [], {})
def test_dispatch_ignores_when_no_file_id(self):
"""If file_id is not in args or kwargs, nothing happens."""
from app.celery_app import _dispatch_user_failure_notification
sender = MagicMock()
sender.name = "app.tasks.process_document.process_document"
# No file_id anywhere
_dispatch_user_failure_notification(sender, ValueError("x"), ["/tmp/f.pdf"], {})
@patch("app.database.SessionLocal")
def test_dispatch_extracts_file_id_from_kwargs(self, mock_session):
"""file_id should be extracted from kwargs when present."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_record = MagicMock()
mock_record.owner_id = "alice@example.com"
mock_record.original_filename = "invoice.pdf"
mock_record.local_filename = "/tmp/invoice.pdf"
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.finalize_document_storage.finalize_document_storage"
exc = RuntimeError("Upload failed")
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
_dispatch_user_failure_notification(sender, exc, ["/tmp/f.pdf"], {"file_id": 10})
mock_notify.assert_called_once_with(
owner_id="alice@example.com",
filename="invoice.pdf",
error="RuntimeError: Upload failed",
file_id=10,
)
@patch("app.database.SessionLocal")
def test_dispatch_extracts_file_id_from_positional_args(self, mock_session):
"""file_id should be extracted from positional args for known tasks."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_record = MagicMock()
mock_record.owner_id = "bob@test.com"
mock_record.original_filename = "scan.pdf"
mock_record.local_filename = "/tmp/scan.pdf"
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.process_with_ocr.process_with_ocr"
exc = ValueError("OCR error")
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
# process_with_ocr: file_id is args[1]
_dispatch_user_failure_notification(sender, exc, ["filename.pdf", 77], {})
mock_notify.assert_called_once_with(
owner_id="bob@test.com",
filename="scan.pdf",
error="ValueError: OCR error",
file_id=77,
)
@patch("app.database.SessionLocal")
def test_dispatch_skips_when_no_owner(self, mock_session):
"""When file record has no owner_id, no notification is sent."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_record = MagicMock()
mock_record.owner_id = None
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.process_document.process_document"
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
_dispatch_user_failure_notification(sender, ValueError("x"), [], {"file_id": 5})
mock_notify.assert_not_called()
@patch("app.database.SessionLocal")
def test_dispatch_skips_when_record_not_found(self, mock_session):
"""When file record doesn't exist, no notification is sent."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.process_document.process_document"
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
_dispatch_user_failure_notification(sender, ValueError("x"), [], {"file_id": 999})
mock_notify.assert_not_called()
+502
View File
@@ -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"
+17 -1
View File
@@ -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
+15 -8
View File
@@ -5,7 +5,7 @@ in files listed in the 90%+ coverage push issue.
Each test class maps to a single source module.
"""
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from dropbox.exceptions import ApiError
@@ -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"
+149 -2
View File
@@ -704,6 +704,18 @@ def _set_minimal_provider_settings(mock_settings):
class TestSettingsSyncAdditional:
"""Additional tests for settings_sync covering reload failure branch."""
def test_notify_settings_updated_redis_failure_logs_warning(self):
"""Test that a Redis failure is logged, not raised (lines 58-59)."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.settings_sync.redis") as mock_redis_module:
mock_redis_module.from_url.side_effect = Exception("Redis connection failed")
with patch("app.utils.settings_sync.logger") as mock_logger:
notify_settings_updated()
mock_logger.warning.assert_any_call(
"Could not publish settings update to Redis: Redis connection failed"
)
def test_reload_failure_is_logged_not_raised(self):
"""Test that a reload failure is logged, not raised (lines 71-72)."""
from app.utils.settings_sync import notify_settings_updated
@@ -711,8 +723,66 @@ class TestSettingsSyncAdditional:
with patch("app.utils.settings_sync.redis") as mock_redis_module:
mock_redis_module.from_url.return_value = MagicMock() # Redis OK
with patch("app.utils.config_loader.reload_settings_from_db", side_effect=Exception("reload failed")):
# Should not raise despite reload failure
notify_settings_updated()
with patch("app.utils.settings_sync.logger") as mock_logger:
# Should not raise despite reload failure
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not reload in-process settings: reload failed")
def test_notify_settings_updated_ocr_failure_logs_warning(self):
"""Test that OCR check failure is logged, not raised (lines 82-83)."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.settings_sync.redis") as mock_redis_module:
mock_redis_module.from_url.return_value = MagicMock() # Redis OK
with patch("app.utils.config_loader.reload_settings_from_db"):
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async", side_effect=Exception("OCR failed")
):
with patch("app.utils.settings_sync.logger") as mock_logger:
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR failed")
def test_signal_handler_ocr_check_failure_logs_warning(self):
"""Test that signal handler logs warning if OCR language check fails on worker (lines 115-116)."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = b"1234567890.0"
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db"):
with patch("app.utils.settings_sync._last_seen_version", ""):
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async",
side_effect=Exception("Worker OCR fail"),
):
with patch("app.utils.settings_sync.logger") as mock_logger:
handler_fn(sender=None)
mock_logger.warning.assert_any_call(
"Could not schedule OCR language check on worker: Worker OCR fail"
)
def test_signal_handler_reloads_on_version_change(self):
"""Test the task_prerun signal handler reloads settings when version changes (lines 95-98)."""
@@ -820,6 +890,83 @@ class TestSettingsSyncAdditional:
# Should not raise
handler_fn(sender=None)
def test_signal_handler_no_version_returned(self):
"""Test that handler does nothing if Redis returns None for version."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = None # Return None for version
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
handler_fn(sender=None)
mock_reload.assert_not_called()
def test_signal_handler_ocr_language_manager_exception(self):
"""Test that OCR language check exception inside handler is caught and logged."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = b"9999999.0" # New version
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
with patch("app.utils.settings_sync._last_seen_version", "111.0"):
with patch("app.utils.settings_sync.logger") as mock_logger:
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async",
side_effect=Exception("OCR failed"),
):
handler_fn(sender=None)
mock_reload.assert_called_once()
mock_logger.warning.assert_called_with(
"Could not schedule OCR language check on worker: OCR failed"
)
# ===========================================================================
# app/api/logs.py additional branches
@@ -665,6 +665,7 @@ def _all_should_upload_false():
"email",
"onedrive",
"s3",
"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)
+164
View File
@@ -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
+1 -1
View File
@@ -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."""
+37 -16
View File
@@ -5,7 +5,7 @@ This test module serves as a regression prevention mechanism to ensure
that endpoints remain accessible after code refactoring or reorganization.
"""
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
@@ -17,17 +17,24 @@ TEST_URL = "https://example.com/test.pdf"
class TestEndpointRegistration:
"""Verify that critical API endpoints are registered in the FastAPI app"""
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_endpoint_exists(self, mock_process_document, mock_requests_get, client):
def test_process_url_endpoint_exists(self, mock_process_document, mock_stream, client):
"""Verify that /api/process-url endpoint is registered and accessible"""
# Mock successful download to prevent actual HTTP requests
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -46,17 +53,24 @@ class TestEndpointRegistration:
"Verify that url_upload_router is included in app/api/__init__.py"
)
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_endpoint_accepts_post(self, mock_process_document, mock_requests_get, client):
def test_process_url_endpoint_accepts_post(self, mock_process_document, mock_stream, client):
"""Verify that /api/process-url accepts POST requests"""
# Mock successful download to prevent actual HTTP requests
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -72,17 +86,24 @@ class TestEndpointRegistration:
"Verify the endpoint is decorated with @router.post()"
)
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_api_router_included_in_app(self, mock_process_document, mock_requests_get, client):
def test_api_router_included_in_app(self, mock_process_document, mock_stream, client):
"""Verify that the main API router is included in the FastAPI app"""
# Mock successful download for /api/process-url test
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
+12 -6
View File
@@ -417,14 +417,20 @@ class TestOneDriveIntegration:
def test_onedrive_token_refresh_and_user_info(self, original_env: dict) -> None:
"""Validate token refresh and user info retrieval."""
import requests
import asyncio
import httpx
token = self._get_access_token(original_env)
resp = requests.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
async def _test():
async with httpx.AsyncClient(timeout=30) as client:
return await client.get(
"https://graph.microsoft.com/v1.0/me",
headers={"Authorization": f"Bearer {token}"},
)
resp = asyncio.run(_test())
assert resp.status_code == 200, f"OneDrive user info failed: {resp.text}"
def test_onedrive_upload_download_delete(self, original_env: dict) -> None:
+1 -1
View File
@@ -345,7 +345,7 @@ class TestUploadErrorHandling:
def test_upload_disk_write_failure(self, client: TestClient):
"""Test handling of disk write failures."""
with patch("builtins.open", side_effect=IOError("Disk full")):
with patch("aiofiles.open", side_effect=IOError("Disk full")):
pdf_content = b"%PDF-1.4\n%EOF"
response = client.post(
+134
View File
@@ -632,3 +632,137 @@ class TestFinalizeDocumentStorageUserRouting:
mock_send_all.delay.assert_called_once_with("/workdir/processed/file.pdf", True, 400)
mock_send_user.delay.assert_not_called()
assert result["status"] == "Completed"
@pytest.mark.unit
class TestFinalizeDocumentStorageUserNotification:
"""Tests for per-user notification dispatch in finalize_document_storage."""
@patch("app.tasks.finalize_document_storage.notify_user_document_processed")
@patch("app.tasks.finalize_document_storage.notify_file_processed")
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=2)
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
@patch("app.tasks.finalize_document_storage.log_task_progress")
@patch("app.tasks.finalize_document_storage.SessionLocal")
def test_dispatches_per_user_notification_when_owner_is_set(
self,
mock_session_local,
mock_log_progress,
mock_get_services,
mock_get_dest_count,
mock_send_all,
mock_send_user,
mock_notify_system,
mock_notify_user,
):
"""notify_user_document_processed is called when owner_id is available."""
mock_get_services.return_value = {"dropbox": True}
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_file_record = _make_file_record(100, owner_id="alice@example.com")
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=1024):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="doc.pdf"):
finalize_document_storage.request.id = "test-task-id"
finalize_document_storage.__wrapped__(
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/doc.pdf",
metadata={"filename": "doc.pdf"},
file_id=100,
)
mock_notify_user.assert_called_once_with(
owner_id="alice@example.com",
filename="doc.pdf",
file_id=100,
)
@patch("app.tasks.finalize_document_storage.notify_user_document_processed")
@patch("app.tasks.finalize_document_storage.notify_file_processed")
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
@patch("app.tasks.finalize_document_storage.log_task_progress")
@patch("app.tasks.finalize_document_storage.SessionLocal")
def test_skips_per_user_notification_when_no_owner(
self,
mock_session_local,
mock_log_progress,
mock_get_services,
mock_get_dest_count,
mock_send_all,
mock_send_user,
mock_notify_system,
mock_notify_user,
):
"""notify_user_document_processed is NOT called when owner_id is None."""
mock_get_services.return_value = {"dropbox": True}
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_file_record = _make_file_record(200, owner_id=None)
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=2048):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="file.pdf"):
finalize_document_storage.request.id = "test-task-id"
finalize_document_storage.__wrapped__(
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/file.pdf",
metadata={"filename": "file.pdf"},
file_id=200,
)
mock_notify_user.assert_not_called()
@patch("app.tasks.finalize_document_storage.notify_user_document_processed")
@patch("app.tasks.finalize_document_storage.notify_file_processed")
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
@patch("app.tasks.finalize_document_storage.log_task_progress")
@patch("app.tasks.finalize_document_storage.SessionLocal")
def test_per_user_notification_failure_does_not_break_task(
self,
mock_session_local,
mock_log_progress,
mock_get_services,
mock_get_dest_count,
mock_send_all,
mock_send_user,
mock_notify_system,
mock_notify_user,
):
"""Even if notify_user_document_processed raises, finalize returns success."""
mock_get_services.return_value = {"dropbox": True}
mock_notify_user.side_effect = RuntimeError("SMTP down")
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_file_record = _make_file_record(300, owner_id="bob@example.com")
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=512):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="a.pdf"):
finalize_document_storage.request.id = "test-task-id"
result = finalize_document_storage.__wrapped__(
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/a.pdf",
metadata={"filename": "a.pdf"},
file_id=300,
)
assert result["status"] == "Completed"
mock_notify_user.assert_called_once()
+419
View File
@@ -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
+542
View File
@@ -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
+158
View File
@@ -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"] == []
+76
View File
@@ -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:
+83
View File
@@ -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
# ---------------------------------------------------------------------------
+285
View File
@@ -0,0 +1,285 @@
"""Tests for application logging configuration.
Validates that the LOG_LEVEL and DEBUG settings correctly control the
Python root-logger level and that the standard precedence rules are respected:
1. Explicit LOG_LEVEL always wins.
2. DEBUG=True without LOG_LEVEL effective DEBUG.
3. Neither set default INFO.
"""
import logging
import os
from unittest.mock import patch
import pytest
from app.config import Settings
@pytest.mark.unit
class TestLogLevelSetting:
"""Tests for the log_level config field."""
_BASE_KWARGS = {
"database_url": "sqlite:///test.db",
"redis_url": "redis://localhost:6379",
"openai_api_key": "test",
"azure_ai_key": "test",
"azure_region": "test",
"azure_endpoint": "https://test.example.com",
"gotenberg_url": "http://localhost:3000",
"workdir": "/tmp",
"auth_enabled": False,
"session_secret": None,
}
def test_log_level_default_is_info(self):
"""Test that log_level defaults to INFO."""
config = Settings(**self._BASE_KWARGS)
assert config.log_level.upper() == "INFO"
def test_log_level_accepts_debug(self):
"""Test that log_level accepts DEBUG."""
config = Settings(**self._BASE_KWARGS, log_level="DEBUG")
assert config.log_level.upper() == "DEBUG"
def test_log_level_accepts_warning(self):
"""Test that log_level accepts WARNING."""
config = Settings(**self._BASE_KWARGS, log_level="WARNING")
assert config.log_level.upper() == "WARNING"
def test_log_level_accepts_error(self):
"""Test that log_level accepts ERROR."""
config = Settings(**self._BASE_KWARGS, log_level="ERROR")
assert config.log_level.upper() == "ERROR"
def test_log_level_case_insensitive(self):
"""Test that log_level is case-insensitive in usage."""
config = Settings(**self._BASE_KWARGS, log_level="debug")
assert config.log_level.upper() == "DEBUG"
def test_debug_flag_defaults_to_false(self):
"""Test that debug defaults to False."""
config = Settings(**self._BASE_KWARGS)
assert config.debug is False
@pytest.mark.unit
class TestEffectiveLogLevel:
"""Tests for the effective log-level resolution logic in main.py."""
def test_debug_true_without_log_level_gives_debug(self):
"""When DEBUG=True and LOG_LEVEL is not set, effective level is DEBUG."""
with patch.dict(os.environ, {"DEBUG": "true"}, clear=False):
# Remove LOG_LEVEL from env if present
env = os.environ.copy()
env.pop("LOG_LEVEL", None)
with patch.dict(os.environ, env, clear=True):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
debug=True,
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "DEBUG"
def test_explicit_log_level_overrides_debug(self):
"""When LOG_LEVEL is explicitly set, it takes precedence over DEBUG=True."""
with patch.dict(os.environ, {"LOG_LEVEL": "WARNING", "DEBUG": "true"}, clear=False):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
debug=True,
log_level="WARNING",
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "WARNING"
def test_default_no_flags_gives_info(self):
"""When neither DEBUG nor LOG_LEVEL is set, effective level is INFO."""
env = os.environ.copy()
env.pop("LOG_LEVEL", None)
env.pop("DEBUG", None)
with patch.dict(os.environ, env, clear=True):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "INFO"
def test_effective_level_maps_to_logging_constant(self):
"""The effective level string maps to a valid logging constant."""
for level_name in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
assert getattr(logging, level_name) is not None
@pytest.mark.unit
class TestLoggingConfiguredAtStartup:
"""Tests that the main module configures the root logger on import."""
def test_root_logger_has_handler(self):
"""Root logger should have at least one handler after app import."""
root = logging.getLogger()
assert len(root.handlers) > 0, "Root logger has no handlers after app startup"
def test_root_logger_level_is_not_warning_default(self):
"""Root logger should not be at the unconfigured WARNING default.
Our basicConfig(force=True) should have set it to at least INFO.
"""
root = logging.getLogger()
# The test env doesn't set DEBUG=True, so the level should be INFO (20)
assert root.level <= logging.INFO
@pytest.mark.unit
class TestJsonFormatter:
"""Tests for the _JsonFormatter used when LOG_FORMAT=json."""
def _make_formatter(self):
"""Lazily import the JSON formatter from main module."""
from app.main import _JsonFormatter
return _JsonFormatter()
def test_output_is_valid_json(self):
"""JSON formatter output should be parseable JSON."""
import json
fmt = self._make_formatter()
record = logging.LogRecord(
name="test.logger",
level=logging.INFO,
pathname="test.py",
lineno=42,
msg="Hello %s",
args=("world",),
exc_info=None,
)
result = fmt.format(record)
parsed = json.loads(result)
assert parsed["level"] == "INFO"
assert parsed["logger"] == "test.logger"
assert parsed["message"] == "Hello world"
assert parsed["lineno"] == 42
def test_includes_timestamp_iso8601(self):
"""JSON output should contain an ISO 8601 timestamp."""
import json
fmt = self._make_formatter()
record = logging.LogRecord(
name="x",
level=logging.DEBUG,
pathname="x.py",
lineno=1,
msg="test",
args=(),
exc_info=None,
)
parsed = json.loads(fmt.format(record))
assert "timestamp" in parsed
# ISO 8601 timestamps contain "T" and "+00:00" (UTC)
assert "T" in parsed["timestamp"]
def test_includes_exc_info_when_present(self):
"""JSON output should include exc_info when an exception is logged."""
import json
fmt = self._make_formatter()
try:
raise ValueError("boom") # noqa: TRY301
except ValueError:
import sys
record = logging.LogRecord(
name="x",
level=logging.ERROR,
pathname="x.py",
lineno=1,
msg="error",
args=(),
exc_info=sys.exc_info(),
)
parsed = json.loads(fmt.format(record))
assert "exc_info" in parsed
assert "ValueError" in parsed["exc_info"]
@pytest.mark.unit
class TestLogFormatSetting:
"""Tests for the log_format and log_syslog_* config fields."""
_BASE_KWARGS = {
"database_url": "sqlite:///test.db",
"redis_url": "redis://localhost:6379",
"openai_api_key": "test",
"azure_ai_key": "test",
"azure_region": "test",
"azure_endpoint": "https://test.example.com",
"gotenberg_url": "http://localhost:3000",
"workdir": "/tmp",
"auth_enabled": False,
"session_secret": None,
}
def test_log_format_default_is_text(self):
"""Test that log_format defaults to 'text'."""
config = Settings(**self._BASE_KWARGS)
assert config.log_format == "text"
def test_log_format_accepts_json(self):
"""Test that log_format accepts 'json'."""
config = Settings(**self._BASE_KWARGS, log_format="json")
assert config.log_format == "json"
def test_log_syslog_defaults(self):
"""Test syslog forwarding defaults."""
config = Settings(**self._BASE_KWARGS)
assert config.log_syslog_enabled is False
assert config.log_syslog_host == "localhost"
assert config.log_syslog_port == 514
assert config.log_syslog_protocol == "udp"
def test_log_syslog_can_be_enabled(self):
"""Test that syslog forwarding can be enabled."""
config = Settings(**self._BASE_KWARGS, log_syslog_enabled=True, log_syslog_host="syslog.example.com")
assert config.log_syslog_enabled is True
assert config.log_syslog_host == "syslog.example.com"
+117 -1
View File
@@ -18,7 +18,7 @@ from sqlalchemy.pool import StaticPool
from app.config import settings
from app.database import Base
from app.models import FileRecord
from app.models import ApiToken, FileRecord
# ---------------------------------------------------------------------------
# Fixtures
@@ -162,6 +162,122 @@ class TestGetCurrentOwnerId:
request.session = {}
assert get_current_owner_id(request) is None
@pytest.mark.unit
def test_resolves_from_api_token_user_state(self):
"""get_current_owner_id should resolve from request.state.api_token_user."""
from app.utils.user_scope import get_current_owner_id
request = MagicMock()
request.session = {}
request.state.api_token_user = {
"id": "tok-owner",
"preferred_username": "tok-owner",
"email": "tok-owner",
}
assert get_current_owner_id(request) == "tok-owner"
@pytest.mark.unit
def test_session_takes_precedence_over_api_token_user(self):
"""Session auth should take precedence over api_token_user in state."""
from app.utils.user_scope import get_current_owner_id
request = MagicMock()
request.session = {"user": {"sub": "session-sub", "email": "session@example.com"}}
request.state.api_token_user = {"id": "tok-owner"}
assert get_current_owner_id(request) == "session-sub"
@pytest.mark.unit
def test_resolves_bearer_token_directly(self, mu_engine, mu_session):
"""get_current_owner_id should resolve a Bearer token when no session exists."""
from types import SimpleNamespace
from app.api.api_tokens import generate_api_token, hash_token
from app.utils.user_scope import get_current_owner_id
# Create a token in the DB
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id="bearer-owner",
name="Test Bearer",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
)
mu_session.add(db_token)
mu_session.commit()
# Build a mock request with Bearer header but no session.
# SimpleNamespace starts with no attributes so getattr(..., None) works.
request = MagicMock()
request.session = {}
request.state = SimpleNamespace()
request.headers = {"authorization": f"Bearer {plaintext}"}
request.client.host = "127.0.0.1"
# Provide the test session and make close() a no-op so the shared
# session is not torn down prematurely.
noop_close = MagicMock()
with patch("app.database.SessionLocal", return_value=mu_session), patch.object(mu_session, "close", noop_close):
result = get_current_owner_id(request)
assert result == "bearer-owner"
# Verify the resolved user was cached in request.state
assert request.state.api_token_user["id"] == "bearer-owner"
@pytest.mark.unit
def test_returns_none_for_invalid_bearer_token(self, mu_engine, mu_session):
"""get_current_owner_id should return None for an invalid Bearer token."""
from types import SimpleNamespace
from app.utils.user_scope import get_current_owner_id
request = MagicMock()
request.session = {}
request.state = SimpleNamespace()
request.headers = {"authorization": "Bearer de_invalid_token_value"}
request.client.host = "127.0.0.1"
noop_close = MagicMock()
with patch("app.database.SessionLocal", return_value=mu_session), patch.object(mu_session, "close", noop_close):
result = get_current_owner_id(request)
assert result is None
class TestOwnerIdFromUser:
"""Tests for the _owner_id_from_user helper."""
@pytest.mark.unit
def test_prefers_sub(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"sub": "s", "preferred_username": "u", "email": "e"}) == "s"
@pytest.mark.unit
def test_falls_back_to_preferred_username(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"preferred_username": "u", "email": "e"}) == "u"
@pytest.mark.unit
def test_falls_back_to_email(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"email": "e"}) == "e"
@pytest.mark.unit
def test_falls_back_to_id(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"id": "i"}) == "i"
@pytest.mark.unit
def test_returns_none_for_empty_dict(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({}) is None
class TestApplyOwnerFilter:
"""Tests for apply_owner_filter()."""
+55
View File
@@ -830,3 +830,58 @@ class TestUserNotificationService:
result = _send_email_notification({"smtp_host": "smtp.example.com"}, "Title", "Body")
assert result is False
class TestBenchmark:
@pytest.mark.unit
def test_update_preferences_benchmark(self, notif_engine, notif_session):
import statistics
import time
from app.main import app
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="webhook",
name="My Webhook",
config=json.dumps({"url": "https://x.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
client = _make_client(notif_engine, _OWNER)
try:
items_count = 100
preferences = []
for i in range(items_count):
preferences.append(
{
"event_type": f"event.type.{i}",
"channel_type": "webhook",
"is_enabled": True,
"target_id": target.id,
}
)
payload = {"preferences": preferences}
# Warm up
client.put("/api/user-notifications/preferences", json=payload)
times = []
for _ in range(5):
# Alter the values a bit so it's a real update
for p in payload["preferences"]:
p["is_enabled"] = not p["is_enabled"]
start = time.time()
resp = client.put("/api/user-notifications/preferences", json=payload)
end = time.time()
assert resp.status_code == 200
times.append(end - start)
print(f"\nAverage time: {statistics.mean(times):.4f}s")
finally:
_cleanup(app)
+4 -3
View File
@@ -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 = [
+803
View File
@@ -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
View File
@@ -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])
+31 -1
View File
@@ -289,7 +289,8 @@ class TestNotifySettingsUpdated:
call_args = mock_redis_instance.set.call_args[0]
assert call_args[0] == SETTINGS_VERSION_KEY
def test_does_not_raise_on_redis_failure(self):
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_redis_failure(self, mock_logger):
"""notify_settings_updated must not propagate Redis errors."""
from app.utils.settings_sync import notify_settings_updated
@@ -297,6 +298,35 @@ class TestNotifySettingsUpdated:
mock_redis_module.from_url.side_effect = Exception("Redis down")
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not publish settings update to Redis: Redis down")
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_reload_failure(self, mock_logger):
"""notify_settings_updated must not propagate settings reload errors."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
mock_reload.side_effect = Exception("Reload error")
# We mock redis so that we skip over the redis block, and mock ensure_ocr_languages_async to prevent its side effects.
with patch("app.utils.settings_sync.redis"):
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async"):
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not reload in-process settings: Reload error")
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_ocr_language_check_failure(self, mock_logger):
"""notify_settings_updated must not propagate OCR language check errors."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async") as mock_ensure:
mock_ensure.side_effect = Exception("OCR error")
# We mock redis and reload_settings_from_db so we only test the OCR block failure.
with patch("app.utils.settings_sync.redis"):
with patch("app.utils.config_loader.reload_settings_from_db"):
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR error")
@pytest.mark.unit
+211
View File
@@ -0,0 +1,211 @@
from unittest.mock import MagicMock, patch
import pytest
import app.utils.settings_sync
from app.utils.settings_sync import (
SETTINGS_VERSION_KEY,
notify_settings_updated,
register_settings_reload_signal,
)
@pytest.fixture
def reset_last_seen_version():
"""Reset the global variable before and after tests."""
app.utils.settings_sync._last_seen_version = ""
yield
app.utils.settings_sync._last_seen_version = ""
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
@patch("app.utils.settings_sync.time.time", return_value=12345.0)
def test_notify_settings_updated_success(mock_time, mock_ensure_ocr, mock_reload, mock_redis):
# Setup mock redis instance
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verify redis calls
mock_redis.assert_called_once()
mock_redis_instance.set.assert_called_once_with(SETTINGS_VERSION_KEY, "12345.0")
# Verify other calls
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_redis_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup mock redis to fail
mock_redis.side_effect = Exception("Redis connection failed")
notify_settings_updated()
# Verification: should continue and call reload and ocr despite redis failure
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not publish settings update to Redis: Redis connection failed" in caplog.text
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_reload_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup reload to fail
mock_reload.side_effect = Exception("Reload failed")
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verification: redis should be called, reload fails, ocr should still be called
mock_redis_instance.set.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not reload in-process settings: Reload failed" in caplog.text
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_ocr_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup ocr check to fail
mock_ensure_ocr.side_effect = Exception("OCR check failed")
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verification: all should be called, ocr failure logged
mock_redis_instance.set.assert_called_once()
mock_reload.assert_called_once()
assert "Could not schedule OCR language check: OCR check failed" in caplog.text
@patch("app.utils.settings_sync.task_prerun.connect")
def test_register_settings_reload_signal(mock_connect):
register_settings_reload_signal()
# It should register a signal with task_prerun
mock_connect.assert_called_once_with(weak=False)
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_new_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
# Capture the registered callback
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
mock_connect.assert_called_once_with(weak=False)
# Get the callback function
callback = mock_decorator.call_args[0][0]
# Setup redis to return a new version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"new_version"
mock_redis.return_value = mock_redis_instance
# Initial state check
assert app.utils.settings_sync._last_seen_version == ""
# Call the callback
callback(sender="test")
# Verification
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert app.utils.settings_sync._last_seen_version == "new_version"
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_same_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
# Set initial state
app.utils.settings_sync._last_seen_version = "existing_version"
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to return the SAME version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"existing_version"
mock_redis.return_value = mock_redis_instance
# Call the callback
callback(sender="test")
# Verification
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
# Should NOT reload or check OCR
mock_reload.assert_not_called()
mock_ensure_ocr.assert_not_called()
assert app.utils.settings_sync._last_seen_version == "existing_version"
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
def test_reload_if_stale_redis_error(mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog):
import logging
caplog.set_level(logging.DEBUG)
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to fail
mock_redis.side_effect = Exception("Redis error")
# Call the callback
callback(sender="test")
# Verification
mock_reload.assert_not_called()
assert "Settings version check skipped: Redis error" in caplog.text
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_ocr_error(
mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog
):
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to return a new version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"new_version"
mock_redis.return_value = mock_redis_instance
# Setup OCR check to fail
mock_ensure_ocr.side_effect = Exception("OCR error")
# Call the callback
callback(sender="test")
# Verification
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not schedule OCR language check on worker: OCR error" in caplog.text
assert app.utils.settings_sync._last_seen_version == "new_version"
+505
View File
@@ -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)
+478
View File
@@ -0,0 +1,478 @@
"""Tests for document translation feature.
Covers:
- translate_to_default_language Celery task
- /api/files/{id}/translate on-the-fly translation endpoint
- /api/files/{id}/translation/default stored translation endpoint
- /files/{id}/text/default-language view endpoint
- _resolve_default_language helper
"""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.models import FileRecord, UserProfile
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def file_with_ocr(db_session):
"""Create a FileRecord with OCR text and detected language."""
record = FileRecord(
filehash="abc123translationtest",
local_filename="/tmp/test_translate.pdf",
file_size=1024,
mime_type="application/pdf",
original_filename="test_translate.pdf",
ocr_text="Dies ist ein Testdokument in deutscher Sprache.",
detected_language="de",
)
db_session.add(record)
db_session.commit()
db_session.refresh(record)
return record
@pytest.fixture
def file_with_translation(db_session):
"""Create a FileRecord with a persisted default-language translation."""
record = FileRecord(
filehash="def456translationtest",
local_filename="/tmp/test_translated.pdf",
file_size=2048,
mime_type="application/pdf",
original_filename="test_translated.pdf",
ocr_text="Ceci est un document de test en français.",
detected_language="fr",
default_language_text="This is a test document in French.",
default_language_code="en",
)
db_session.add(record)
db_session.commit()
db_session.refresh(record)
return record
@pytest.fixture
def file_without_ocr(db_session):
"""Create a FileRecord without OCR text."""
record = FileRecord(
filehash="ghi789translationtest",
local_filename="/tmp/test_no_ocr.pdf",
file_size=512,
mime_type="application/pdf",
original_filename="test_no_ocr.pdf",
)
db_session.add(record)
db_session.commit()
db_session.refresh(record)
return record
@pytest.fixture
def user_profile_with_language(db_session):
"""Create a UserProfile with a custom default_document_language."""
profile = UserProfile(
user_id="test-user-lang",
default_document_language="de",
)
db_session.add(profile)
db_session.commit()
db_session.refresh(profile)
return profile
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
class TestFileRecordTranslationFields:
"""Verify that the new translation columns exist on FileRecord."""
@pytest.mark.unit
def test_detected_language_column(self, file_with_ocr):
assert file_with_ocr.detected_language == "de"
@pytest.mark.unit
def test_default_language_text_column(self, file_with_translation):
assert file_with_translation.default_language_text == "This is a test document in French."
@pytest.mark.unit
def test_default_language_code_column(self, file_with_translation):
assert file_with_translation.default_language_code == "en"
@pytest.mark.unit
def test_translation_columns_nullable(self, file_with_ocr):
"""Translation columns should be NULL when no translation exists."""
assert file_with_ocr.default_language_text is None
assert file_with_ocr.default_language_code is None
class TestUserProfileDefaultLanguage:
"""Verify UserProfile.default_document_language column."""
@pytest.mark.unit
def test_default_document_language_set(self, user_profile_with_language):
assert user_profile_with_language.default_document_language == "de"
@pytest.mark.unit
def test_default_document_language_nullable(self, db_session):
profile = UserProfile(user_id="test-user-no-lang")
db_session.add(profile)
db_session.commit()
db_session.refresh(profile)
assert profile.default_document_language is None
# ---------------------------------------------------------------------------
# Celery task tests
# ---------------------------------------------------------------------------
class TestTranslateToDefaultLanguageTask:
"""Tests for the translate_to_default_language Celery task."""
@pytest.mark.unit
@patch("app.tasks.translate_to_default_language.get_ai_provider")
def test_translate_stores_result(self, mock_provider_fn, db_session, file_with_ocr):
"""Successful translation is persisted to the FileRecord."""
mock_provider = MagicMock()
mock_provider.chat_completion.return_value = "This is a test document in German."
mock_provider_fn.return_value = mock_provider
from app.tasks.translate_to_default_language import translate_to_default_language
# Patch SessionLocal to use our test session
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=db_session)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_session_cls.return_value = mock_ctx
task = translate_to_default_language
# Call the underlying function (not .delay) for synchronous testing
result = task.apply(
args=[file_with_ocr.id, file_with_ocr.ocr_text, "de"],
kwargs={"owner_id": None},
).get()
assert result["status"] == "success"
assert result["target_language"] == "en"
# Verify it was stored
db_session.refresh(file_with_ocr)
assert file_with_ocr.default_language_text == "This is a test document in German."
assert file_with_ocr.default_language_code == "en"
assert file_with_ocr.detected_language == "de"
@pytest.mark.unit
@patch("app.tasks.translate_to_default_language.get_ai_provider")
def test_skip_when_already_in_target_language(self, mock_provider_fn, db_session, file_with_ocr):
"""No translation when document language matches default target."""
file_with_ocr.detected_language = "en"
db_session.commit()
from app.tasks.translate_to_default_language import translate_to_default_language
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=db_session)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_session_cls.return_value = mock_ctx
result = translate_to_default_language.apply(
args=[file_with_ocr.id, file_with_ocr.ocr_text, "en"],
).get()
assert result["status"] == "skipped"
mock_provider_fn.assert_not_called()
@pytest.mark.unit
def test_resolve_default_language_global(self):
"""Falls back to the global setting when no user profile override."""
from app.tasks.translate_to_default_language import _resolve_default_language
with patch("app.tasks.translate_to_default_language.settings") as mock_settings:
mock_settings.default_document_language = "en"
assert _resolve_default_language(None) == "en"
@pytest.mark.unit
def test_resolve_default_language_user_override(self, db_session, user_profile_with_language):
"""Per-user override is used when available."""
from app.tasks.translate_to_default_language import _resolve_default_language
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=db_session)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_session_cls.return_value = mock_ctx
result = _resolve_default_language("test-user-lang")
assert result == "de"
# ---------------------------------------------------------------------------
# API endpoint tests
# ---------------------------------------------------------------------------
class TestDefaultTranslationEndpoint:
"""Tests for GET /api/files/{id}/translation/default."""
@pytest.mark.integration
def test_returns_default_translation(self, client: TestClient, file_with_translation):
response = client.get(f"/api/files/{file_with_translation.id}/translation/default")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in French."
assert data["default_language_code"] == "en"
assert data["detected_language"] == "fr"
assert data["file_id"] == file_with_translation.id
@pytest.mark.integration
def test_404_when_no_translation(self, client: TestClient, file_with_ocr):
response = client.get(f"/api/files/{file_with_ocr.id}/translation/default")
assert response.status_code == 404
@pytest.mark.integration
def test_404_for_nonexistent_file(self, client: TestClient):
response = client.get("/api/files/999999/translation/default")
assert response.status_code == 404
class TestOnTheFlyTranslateEndpoint:
"""Tests for GET /api/files/{id}/translate?lang=xx."""
@pytest.mark.integration
@patch("app.api.translation.get_ai_provider")
def test_translate_on_the_fly(self, mock_provider_fn, client: TestClient, file_with_ocr):
mock_provider = MagicMock()
mock_provider.chat_completion.return_value = "This is a test document in German language."
mock_provider_fn.return_value = mock_provider
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=en")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in German language."
assert data["target_language"] == "en"
assert data["cached"] is False
@pytest.mark.integration
def test_returns_cached_default_language(self, client: TestClient, file_with_translation):
"""If the requested language matches the stored default, return cached text."""
response = client.get(f"/api/files/{file_with_translation.id}/translate?lang=en")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in French."
assert data["cached"] is True
@pytest.mark.integration
def test_returns_original_when_same_language(self, client: TestClient, file_with_ocr):
"""Return the original text when target matches detected language."""
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=de")
assert response.status_code == 200
data = response.json()
assert data["text"] == file_with_ocr.ocr_text
assert data["cached"] is True
@pytest.mark.integration
def test_400_when_no_ocr_text(self, client: TestClient, file_without_ocr):
response = client.get(f"/api/files/{file_without_ocr.id}/translate?lang=en")
assert response.status_code == 400
@pytest.mark.integration
def test_missing_lang_param(self, client: TestClient, file_with_ocr):
response = client.get(f"/api/files/{file_with_ocr.id}/translate")
assert response.status_code == 422 # validation error
@pytest.mark.integration
def test_404_for_nonexistent_file(self, client: TestClient):
response = client.get("/api/files/999999/translate?lang=en")
assert response.status_code == 404
@pytest.mark.integration
@patch("app.api.translation.get_ai_provider")
def test_502_on_provider_error(self, mock_provider_fn, client: TestClient, file_with_ocr):
mock_provider = MagicMock()
mock_provider.chat_completion.side_effect = RuntimeError("AI error")
mock_provider_fn.return_value = mock_provider
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=fr")
assert response.status_code == 502
# ---------------------------------------------------------------------------
# View endpoint tests
# ---------------------------------------------------------------------------
class TestDefaultLanguageTextView:
"""Tests for GET /files/{id}/text/default-language."""
@pytest.mark.integration
def test_returns_default_language_text(self, client: TestClient, file_with_translation):
response = client.get(f"/files/{file_with_translation.id}/text/default-language")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in French."
assert data["language_code"] == "en"
assert data["detected_language"] == "fr"
@pytest.mark.integration
def test_404_when_no_default_text(self, client: TestClient, file_with_ocr):
response = client.get(f"/files/{file_with_ocr.id}/text/default-language")
assert response.status_code == 404
@pytest.mark.integration
def test_404_for_nonexistent_file(self, client: TestClient):
response = client.get("/files/999999/text/default-language")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------
class TestDefaultDocumentLanguageConfig:
"""Verify the DEFAULT_DOCUMENT_LANGUAGE setting."""
@pytest.mark.unit
def test_default_value_is_english(self):
from app.config import settings
assert settings.default_document_language == "en"
# ---------------------------------------------------------------------------
# Profile API integration tests
# ---------------------------------------------------------------------------
class TestProfileDefaultDocumentLanguage:
"""Tests for default_document_language in the profile API."""
@pytest.fixture
def prof_engine(self):
"""In-memory SQLite engine for profile tests."""
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from app.database import Base
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
@pytest.fixture
def prof_session(self, prof_engine):
"""DB session for profile tests."""
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=self.prof_engine if hasattr(self, "prof_engine") else prof_engine)
session = Session()
yield session
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_profile_includes_default_document_language(self, prof_engine):
"""GET handler returns default_document_language in response."""
from unittest.mock import MagicMock
from sqlalchemy.orm import sessionmaker
from app.api.profile import get_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser", "email": "lang@test.com"}}
result = await get_profile(req, session)
assert hasattr(result, "default_document_language")
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_update_default_document_language(self, prof_engine):
"""PATCH handler updates default_document_language."""
from unittest.mock import MagicMock
from sqlalchemy.orm import sessionmaker
from app.api.profile import ProfileUpdateRequest, update_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser2", "email": "lang2@test.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(default_document_language="de")
result = await update_profile(body, req, resp, session)
assert result.default_document_language == "de"
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_clear_default_document_language(self, prof_engine):
"""Setting default_document_language to empty string clears it."""
from unittest.mock import MagicMock
from sqlalchemy.orm import sessionmaker
from app.api.profile import ProfileUpdateRequest, update_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser3", "email": "lang3@test.com"}}
resp = MagicMock()
# Set
body = ProfileUpdateRequest(default_document_language="fr")
await update_profile(body, req, resp, session)
# Clear
body = ProfileUpdateRequest(default_document_language="")
result = await update_profile(body, req, resp, session)
assert result.default_document_language is None
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_reject_invalid_default_document_language(self, prof_engine):
"""Invalid language codes are rejected with 422."""
from unittest.mock import MagicMock
from fastapi import HTTPException
from sqlalchemy.orm import sessionmaker
from app.api.profile import ProfileUpdateRequest, update_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser4", "email": "lang4@test.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(default_document_language="xx_invalid")
with pytest.raises(HTTPException) as exc_info:
await update_profile(body, req, resp, session)
assert exc_info.value.status_code == 422
session.close()
+89
View File
@@ -121,6 +121,95 @@ class TestExtractMetadataFromFile:
assert result == {}
def test_extract_metadata_from_pdf(self, tmp_path):
"""Test extracting metadata from a PDF file using pypdf when JSON is missing."""
import pypdf
file_path = tmp_path / "test.pdf"
# Create a test PDF with metadata
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "Test Title",
"/Author": "Test Author",
"/Subject": "Test Document",
"/Keywords": "test, metadata, pypdf",
}
)
with open(file_path, "wb") as f:
writer.write(f)
result = extract_metadata_from_file(str(file_path))
# Keys are mapped to application-specific names
assert result.get("filename") == "Test Title"
assert result.get("absender") == "Test Author"
assert result.get("document_type") == "Test Document"
assert result.get("tags") == "test, metadata, pypdf"
def test_extracts_embedded_metadata_from_pdf(self, tmp_path):
"""Test that embedded PDF metadata is mapped to application-specific keys."""
import pypdf
file_path = tmp_path / "mapped.pdf"
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "Invoice 2024",
"/Author": "Acme Corp",
"/Subject": "invoice",
"/Keywords": "finance, billing",
}
)
with open(file_path, "wb") as f:
writer.write(f)
result = extract_metadata_from_file(str(file_path))
# Verify the PDF-to-app key mapping
assert result["filename"] == "Invoice 2024"
assert result["absender"] == "Acme Corp"
assert result["document_type"] == "invoice"
assert result["tags"] == "finance, billing"
def test_pdf_metadata_does_not_overwrite_json(self, tmp_path):
"""Test that JSON metadata takes precedence over embedded PDF metadata."""
import pypdf
file_path = tmp_path / "dual.pdf"
# Create a PDF with embedded metadata
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "PDF Title",
"/Author": "PDF Author",
"/Subject": "PDF Subject",
"/Keywords": "pdf, keywords",
}
)
with open(file_path, "wb") as f:
writer.write(f)
# Create a companion JSON file that sets some overlapping fields
json_metadata = {"filename": "JSON Filename", "absender": "JSON Author"}
json_path = tmp_path / "dual.json"
json_path.write_text(json.dumps(json_metadata))
result = extract_metadata_from_file(str(file_path))
# JSON values must not be overwritten by PDF metadata
assert result["filename"] == "JSON Filename"
assert result["absender"] == "JSON Author"
# Fields missing from JSON are filled from PDF metadata
assert result["document_type"] == "PDF Subject"
assert result["tags"] == "pdf, keywords"
@pytest.mark.unit
class TestAttachLogo:
+205
View File
@@ -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
View File
@@ -2,10 +2,10 @@
Tests for URL-based file upload functionality
"""
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
import requests
@pytest.mark.unit
@@ -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"})
+635
View File
@@ -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"
+608
View File
@@ -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
+26
View File
@@ -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]