fix(tests): resolve merge conflicts and fix code review issues in saved searches tests

- Resolve add/add conflict in tests/test_api_saved_searches.py by keeping the improved HEAD version
- Resolve content conflict in tests/test_api_advanced_filters.py by keeping HEAD (no CRUD tests)
- Remove no-op test_get_user_id_branches (was just 'pass')
- Remove unused 'from fastapi import Request' import (fixes Ruff F401)
- Fix duplicate 'session = {}' assignment in MockRequest (fixes Ruff F811)
This commit is contained in:
copilot-swe-agent[bot]
2026-03-23 16:15:47 +00:00
356 changed files with 139501 additions and 2253 deletions
+14
View File
@@ -62,9 +62,14 @@ from app.main import app as fastapi_app # noqa: E402
from app.models import ( # noqa: F401, E402
ApiToken,
AuditLog,
AutomationHook,
ClassificationRuleModel,
ComplianceTemplate,
DocumentAnnotation,
DocumentComment,
DocumentMetadata,
FileRecord,
FileShare,
Pipeline,
PipelineRoutingRule,
PipelineStep,
@@ -115,6 +120,7 @@ def client(db_session) -> TestClient:
# Import the canonical get_db function
from app.database import get_db
from app.middleware.upload_rate_limit import require_upload_rate_limit
# Override the get_db dependency to use our test database
def override_get_db():
@@ -126,6 +132,14 @@ def client(db_session) -> TestClient:
# Override the single canonical get_db dependency
fastapi_app.dependency_overrides[get_db] = override_get_db
# Disable per-user upload rate limiting in tests so that upload-heavy
# test suites are not rejected with 429 Too Many Requests.
async def _no_rate_limit() -> None:
"""No-op override: skip upload rate limiting during tests."""
return None
fastapi_app.dependency_overrides[require_upload_rate_limit] = _no_rate_limit
# Use base_url to satisfy TrustedHostMiddleware
with TestClient(fastapi_app, base_url="http://localhost") as test_client:
yield test_client
+2
View File
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
".tif",
".webp",
".svg",
".heic",
".heif",
}
_html_extensions = {".html", ".htm"}
_markdown_extensions = {".md", ".markdown"}
+290
View File
@@ -0,0 +1,290 @@
"""Tests for the classification rules API endpoints.
Covers CRUD operations, validation, and access control for
``/api/classification-rules``.
"""
import pytest
from app.models import ClassificationRuleModel
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_rule(db_session, owner_id="anonymous", **overrides):
"""Insert a ClassificationRuleModel and return it."""
defaults = {
"owner_id": owner_id,
"name": "test_rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": r"(?i)invoice",
"priority": 0,
"case_sensitive": False,
"enabled": True,
}
defaults.update(overrides)
rule = ClassificationRuleModel(**defaults)
db_session.add(rule)
db_session.commit()
db_session.refresh(rule)
return rule
# ---------------------------------------------------------------------------
# Categories & Rule Types endpoints
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCategoriesEndpoint:
"""Tests for GET /api/classification-rules/categories."""
def test_list_categories(self, client):
"""Should return a dict of built-in categories."""
r = client.get("/api/classification-rules/categories")
assert r.status_code == 200
data = r.json()
assert isinstance(data, dict)
assert "invoice" in data
assert "contract" in data
assert "receipt" in data
assert "unknown" in data
@pytest.mark.unit
class TestRuleTypesEndpoint:
"""Tests for GET /api/classification-rules/rule-types."""
def test_list_rule_types(self, client):
"""Should return a list of valid rule types."""
r = client.get("/api/classification-rules/rule-types")
assert r.status_code == 200
data = r.json()
assert isinstance(data, list)
assert len(data) == 3
type_values = {item["type"] for item in data}
assert "filename_pattern" in type_values
assert "content_keyword" in type_values
assert "metadata_match" in type_values
# ---------------------------------------------------------------------------
# CRUD Operations
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestClassificationRuleCRUD:
"""Full CRUD test-suite for classification rules."""
def test_list_rules_empty(self, client):
"""List returns an empty array when no rules exist."""
r = client.get("/api/classification-rules/")
assert r.status_code == 200
assert r.json() == []
def test_create_rule(self, client):
"""POST should create a new classification rule."""
r = client.post(
"/api/classification-rules/",
json={
"name": "My Invoice Rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": r"(?i)rechnung",
"priority": 10,
},
)
assert r.status_code == 201
data = r.json()
assert data["name"] == "My Invoice Rule"
assert data["category"] == "invoice"
assert data["rule_type"] == "filename_pattern"
assert data["priority"] == 10
assert data["enabled"] is True
assert data["id"] is not None
def test_create_rule_invalid_type_rejected(self, client):
"""Creating a rule with an invalid rule_type should be rejected."""
r = client.post(
"/api/classification-rules/",
json={
"name": "Bad Rule",
"category": "test",
"rule_type": "invalid_type",
"pattern": "test",
},
)
assert r.status_code == 400
def test_create_duplicate_name_rejected(self, client):
"""Creating two rules with the same name should be rejected."""
payload = {
"name": "Dupe Rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "test",
}
r1 = client.post("/api/classification-rules/", json=payload)
assert r1.status_code == 201
r2 = client.post("/api/classification-rules/", json=payload)
assert r2.status_code == 409
def test_get_rule(self, client):
"""GET should return a specific rule by ID."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Get Test Rule",
"category": "contract",
"rule_type": "content_keyword",
"pattern": "agreement|terms",
},
)
rule_id = create_resp.json()["id"]
r = client.get(f"/api/classification-rules/{rule_id}")
assert r.status_code == 200
assert r.json()["name"] == "Get Test Rule"
assert r.json()["category"] == "contract"
def test_get_nonexistent_rule(self, client):
"""GET for a nonexistent rule should return 404."""
r = client.get("/api/classification-rules/99999")
assert r.status_code == 404
def test_update_rule(self, client):
"""PUT should update an existing rule."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Update Test",
"category": "receipt",
"rule_type": "filename_pattern",
"pattern": "receipt",
},
)
rule_id = create_resp.json()["id"]
r = client.put(
f"/api/classification-rules/{rule_id}",
json={"category": "invoice", "priority": 50},
)
assert r.status_code == 200
assert r.json()["category"] == "invoice"
assert r.json()["priority"] == 50
# Name should be unchanged
assert r.json()["name"] == "Update Test"
def test_update_nonexistent_rule(self, client):
"""PUT for a nonexistent rule should return 404."""
r = client.put("/api/classification-rules/99999", json={"category": "test"})
assert r.status_code == 404
def test_update_invalid_rule_type_rejected(self, client):
"""PUT with an invalid rule_type should be rejected."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Invalid Update",
"category": "test",
"rule_type": "filename_pattern",
"pattern": "test",
},
)
rule_id = create_resp.json()["id"]
r = client.put(
f"/api/classification-rules/{rule_id}",
json={"rule_type": "bad_type"},
)
assert r.status_code == 400
def test_delete_rule(self, client):
"""DELETE should remove the rule."""
create_resp = client.post(
"/api/classification-rules/",
json={
"name": "Delete Test",
"category": "test",
"rule_type": "content_keyword",
"pattern": "test",
},
)
rule_id = create_resp.json()["id"]
r = client.delete(f"/api/classification-rules/{rule_id}")
assert r.status_code == 204
# Verify it's gone
r2 = client.get(f"/api/classification-rules/{rule_id}")
assert r2.status_code == 404
def test_delete_nonexistent_rule(self, client):
"""DELETE for a nonexistent rule should return 404."""
r = client.delete("/api/classification-rules/99999")
assert r.status_code == 404
def test_list_rules_after_create(self, client):
"""List should return created rules."""
client.post(
"/api/classification-rules/",
json={
"name": "List Rule 1",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "test1",
},
)
client.post(
"/api/classification-rules/",
json={
"name": "List Rule 2",
"category": "contract",
"rule_type": "content_keyword",
"pattern": "test2",
},
)
r = client.get("/api/classification-rules/")
assert r.status_code == 200
assert len(r.json()) == 2
def test_create_rule_with_all_fields(self, client):
"""Create a rule providing all optional fields."""
r = client.post(
"/api/classification-rules/",
json={
"name": "Full Rule",
"category": "tax_document",
"rule_type": "metadata_match",
"pattern": "department=finance",
"priority": 100,
"case_sensitive": True,
"enabled": False,
},
)
assert r.status_code == 201
data = r.json()
assert data["case_sensitive"] is True
assert data["enabled"] is False
assert data["priority"] == 100
def test_create_rule_defaults(self, client):
"""Create a rule with minimal fields to test defaults."""
r = client.post(
"/api/classification-rules/",
json={
"name": "Minimal Rule",
"category": "invoice",
"rule_type": "filename_pattern",
"pattern": "test",
},
)
assert r.status_code == 201
data = r.json()
assert data["priority"] == 0
assert data["case_sensitive"] is False
assert data["enabled"] is True
+261 -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")
@@ -412,3 +416,253 @@ class TestSaveDropboxSettings:
# .env write is best-effort; endpoint should still succeed via DB write
assert response.status_code == 200
assert response.json()["status"] == "success"
@pytest.mark.unit
class TestListDropboxFolders:
"""Tests for list_dropbox_folders endpoint."""
@patch("app.api.dropbox.requests.post")
def test_list_folders_success(self, mock_post, client):
"""Test successful folder listing at root."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"entries": [
{".tag": "folder", "name": "Documents", "path_display": "/Documents", "id": "id:1"},
{".tag": "folder", "name": "Photos", "path_display": "/Photos", "id": "id:2"},
{".tag": "file", "name": "readme.txt", "path_display": "/readme.txt", "id": "id:3"},
],
"has_more": False,
}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 2
assert data["folders"][0]["name"] == "Documents"
assert data["folders"][1]["name"] == "Photos"
assert data["path"] == "/"
assert data["has_more"] is False
@patch("app.api.dropbox.requests.post")
def test_list_folders_subfolder(self, mock_post, client):
"""Test listing folders in a subfolder."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"entries": [
{".tag": "folder", "name": "Invoices", "path_display": "/Documents/Invoices", "id": "id:4"},
],
"has_more": False,
}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": "/Documents"},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 1
assert data["folders"][0]["path"] == "/Documents/Invoices"
@patch("app.api.dropbox.requests.post")
def test_list_folders_empty(self, mock_post, client):
"""Test listing folders in an empty directory."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"entries": [], "has_more": False}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": "/EmptyFolder"},
)
assert response.status_code == 200
assert len(response.json()["folders"]) == 0
@patch("app.api.dropbox.requests.post")
def test_list_folders_unauthorized(self, mock_post, client):
"""Test listing folders with invalid token returns 401."""
mock_response = Mock()
mock_response.status_code = 401
mock_response.text = "Invalid access token"
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "bad-token", "path": ""},
)
assert response.status_code == 401
@patch("app.api.dropbox.requests.post")
def test_list_folders_api_error(self, mock_post, client):
"""Test listing folders when Dropbox API returns an error."""
mock_response = Mock()
mock_response.status_code = 500
mock_response.text = "Internal server error"
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 502
@patch("app.api.dropbox.requests.post")
def test_list_folders_root_path_normalization(self, mock_post, client):
"""Test that '/' is normalized to empty string for Dropbox API."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"entries": [], "has_more": False}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": "/"},
)
assert response.status_code == 200
# Check the actual API call used empty string for root
call_args = mock_post.call_args
assert call_args[1]["json"]["path"] == ""
@patch("app.api.dropbox.requests.post")
def test_list_folders_sorted_alphabetically(self, mock_post, client):
"""Test that folders are returned in alphabetical order."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"entries": [
{".tag": "folder", "name": "Zebra", "path_display": "/Zebra", "id": "id:1"},
{".tag": "folder", "name": "Alpha", "path_display": "/Alpha", "id": "id:2"},
{".tag": "folder", "name": "middle", "path_display": "/middle", "id": "id:3"},
],
"has_more": False,
}
mock_post.return_value = mock_response
response = client.post(
"/api/dropbox/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
names = [f["name"] for f in response.json()["folders"]]
assert names == ["Alpha", "middle", "Zebra"]
class TestBuildDropboxRedirectUri:
"""Tests for the _build_dropbox_redirect_uri helper."""
def test_uses_public_base_url_when_set(self):
"""When PUBLIC_BASE_URL is configured, redirect URI should use it."""
from unittest.mock import MagicMock
with patch("app.api.dropbox.settings") as mock_settings:
mock_settings.public_base_url = "https://myapp.example.com"
from app.api.dropbox import _build_dropbox_redirect_uri
mock_request = MagicMock()
result = _build_dropbox_redirect_uri(mock_request)
assert result == "https://myapp.example.com/dropbox-callback"
def test_uses_public_base_url_strips_trailing_slash(self):
"""PUBLIC_BASE_URL with trailing slash should be handled correctly."""
from unittest.mock import MagicMock
with patch("app.api.dropbox.settings") as mock_settings:
mock_settings.public_base_url = "https://myapp.example.com/"
from app.api.dropbox import _build_dropbox_redirect_uri
mock_request = MagicMock()
result = _build_dropbox_redirect_uri(mock_request)
assert result == "https://myapp.example.com/dropbox-callback"
def test_falls_back_to_request_when_public_base_url_not_set(self):
"""When PUBLIC_BASE_URL is not set, use request scheme and netloc."""
from unittest.mock import MagicMock
with patch("app.api.dropbox.settings") as mock_settings:
mock_settings.public_base_url = None
from app.api.dropbox import _build_dropbox_redirect_uri
mock_request = MagicMock()
mock_request.url.scheme = "https"
mock_request.url.netloc = "other.example.com"
result = _build_dropbox_redirect_uri(mock_request)
assert result == "https://other.example.com/dropbox-callback"
@pytest.mark.unit
class TestGlobalAuthorizeUrl:
"""Tests for GET /api/dropbox/global-authorize-url endpoint."""
@patch("app.api.dropbox.settings")
def test_returns_authorize_url(self, mock_settings, client):
"""Test that a valid authorize URL is returned when global creds are configured."""
mock_settings.dropbox_allow_global_credentials_for_integrations = True
mock_settings.dropbox_app_key = "test-app-key"
mock_settings.dropbox_app_secret = "test-app-secret"
mock_settings.public_base_url = "https://example.com"
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 200
data = response.json()
assert "authorize_url" in data
assert "https://www.dropbox.com/oauth2/authorize" in data["authorize_url"]
assert "client_id=test-app-key" in data["authorize_url"]
# redirect_uri should be URL-encoded
assert "redirect_uri=" in data["authorize_url"]
assert "https%3A%2F%2Fexample.com%2Fdropbox-callback" in data["authorize_url"]
@patch("app.api.dropbox.settings")
def test_returns_403_when_global_creds_disabled(self, mock_settings, client):
"""Test 403 when global credentials for integrations are disabled."""
mock_settings.dropbox_allow_global_credentials_for_integrations = False
mock_settings.dropbox_app_key = "test-app-key"
mock_settings.dropbox_app_secret = "test-app-secret"
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 403
@patch("app.api.dropbox.settings")
def test_returns_503_when_creds_not_configured(self, mock_settings, client):
"""Test 503 when global Dropbox credentials are not configured."""
mock_settings.dropbox_allow_global_credentials_for_integrations = True
mock_settings.dropbox_app_key = None
mock_settings.dropbox_app_secret = None
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 503
@patch("app.api.dropbox.settings")
def test_redirect_uri_uses_public_base_url(self, mock_settings, client):
"""Redirect URI in authorize URL must use PUBLIC_BASE_URL when configured."""
mock_settings.dropbox_allow_global_credentials_for_integrations = True
mock_settings.dropbox_app_key = "my-key"
mock_settings.dropbox_app_secret = "my-secret"
mock_settings.public_base_url = "https://prod.example.com"
response = client.get("/api/dropbox/global-authorize-url")
assert response.status_code == 200
authorize_url = response.json()["authorize_url"]
# The redirect_uri must be URL-encoded and contain the public base URL
assert "https%3A%2F%2Fprod.example.com%2Fdropbox-callback" in authorize_url
+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"):
File diff suppressed because it is too large Load Diff
+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",
+144 -2
View File
@@ -1,5 +1,7 @@
"""Tests for the per-user integrations API (app/api/integrations.py)."""
import unittest.mock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
@@ -887,9 +889,9 @@ class TestConnectionTestEndpoint:
def test_test_unsupported_type(self, int_client):
"""Unsupported integration types return a helpful non-error message."""
payload = {
"integration_type": "DROPBOX",
"integration_type": "FTP",
"config": {},
"credentials": {"token": "abc"},
"credentials": {"username": "user", "password": "pass"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
@@ -897,6 +899,83 @@ class TestConnectionTestEndpoint:
assert data["success"] is False
assert "not yet supported" in data["message"]
def test_test_dropbox_missing_refresh_token(self, int_client):
"""Dropbox test with missing refresh_token returns failure."""
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {"app_key": "key", "app_secret": "secret"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "refresh_token" in data["message"].lower()
def test_test_dropbox_missing_app_key(self, int_client):
"""Dropbox test with missing app_key/app_secret returns failure."""
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {"refresh_token": "rtoken"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "app_key" in data["message"].lower()
def test_test_dropbox_invalid_credentials(self, int_client):
"""Dropbox test with bad credentials returns an auth failure."""
from unittest.mock import MagicMock, patch
import dropbox.exceptions as dbx_exc
with patch("app.api.integrations.dbx_lib") as mock_dbx:
mock_instance = MagicMock()
mock_dbx.Dropbox.return_value = mock_instance
mock_instance.users_get_current_account.side_effect = dbx_exc.AuthError("req_id", MagicMock())
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {
"app_key": "bad_key",
"app_secret": "bad_secret",
"refresh_token": "bad_token",
},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "authentication failed" in data["message"].lower()
def test_test_dropbox_success(self, int_client):
"""Dropbox test with valid (mocked) credentials returns success."""
from unittest.mock import MagicMock, patch
with patch("app.api.integrations.dbx_lib") as mock_dbx:
mock_instance = MagicMock()
mock_dbx.Dropbox.return_value = mock_instance
mock_account = MagicMock()
mock_account.name.display_name = "Test User"
mock_instance.users_get_current_account.return_value = mock_account
payload = {
"integration_type": "DROPBOX",
"config": {},
"credentials": {
"app_key": "valid_key",
"app_secret": "valid_secret",
"refresh_token": "valid_token",
},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert "dropbox connection successful" in data["message"].lower()
def test_test_invalid_type_returns_400(self, int_client):
"""Invalid integration_type returns 400."""
payload = {
@@ -984,6 +1063,69 @@ class TestConnectionTestEndpoint:
assert data["success"] is False
assert "scheme" in data["message"].lower()
@unittest.mock.patch("httpx.request")
def test_test_webdav_success(self, mock_request, int_client):
"""WebDAV test succeeds with valid credentials and a valid status code."""
mock_response = unittest.mock.MagicMock()
mock_response.status_code = 207 # Typical WebDAV success for PROPFIND
mock_request.return_value = mock_response
payload = {
"integration_type": "WEBDAV",
"config": {"url": "https://example.com/webdav"},
"credentials": {"username": "user1", "password": "password123"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
mock_request.assert_called_once_with(
"PROPFIND",
"https://example.com/webdav",
auth=("user1", "password123"),
headers={"Depth": "0"},
timeout=10.0,
follow_redirects=False,
)
@unittest.mock.patch("httpx.request")
def test_test_webdav_failure_status(self, mock_request, int_client):
"""WebDAV test fails if the server returns a 4xx or 5xx status code."""
mock_response = unittest.mock.MagicMock()
mock_response.status_code = 401
mock_request.return_value = mock_response
payload = {
"integration_type": "WEBDAV",
"config": {"url": "https://example.com/webdav"},
"credentials": {"username": "user1", "password": "wrong"},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "401" in data["message"]
@unittest.mock.patch("httpx.request")
def test_test_webdav_exception(self, mock_request, int_client):
"""WebDAV test fails gracefully if an exception occurs during the request."""
mock_request.side_effect = Exception("Connection error")
payload = {
"integration_type": "WEBDAV",
"config": {"url": "https://example.com/webdav"},
"credentials": {},
}
resp = int_client.post("/api/integrations/test", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "failed" in data["message"].lower()
# ---------------------------------------------------------------------------
# Quota endpoint tests
+66 -2
View File
@@ -329,7 +329,7 @@ class TestDeactivateDevice:
"""Tests for DELETE /api/mobile/devices/{device_id}."""
def test_deactivate_own_device(self, mob_engine, mob_session):
"""Deactivating a device sets is_active to False."""
"""Deactivating an active device sets is_active to False (soft-delete, returns 200)."""
from app.main import app
device = MobileDevice(
@@ -346,7 +346,8 @@ class TestDeactivateDevice:
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 204
assert resp.status_code == 200
assert resp.json()["detail"] == "Device deactivated"
mob_session.expire_all()
updated = mob_session.get(MobileDevice, device_id)
@@ -355,6 +356,33 @@ class TestDeactivateDevice:
finally:
_cleanup(app)
def test_delete_inactive_device(self, mob_engine, mob_session):
"""Deleting an already-inactive device permanently removes it (hard-delete, returns 200)."""
from app.main import app
device = MobileDevice(
owner_id=_OWNER,
push_token=_EXPO_TOKEN,
platform="ios",
is_active=False,
)
mob_session.add(device)
mob_session.commit()
mob_session.refresh(device)
device_id = device.id
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 200
assert resp.json()["detail"] == "Device deleted"
mob_session.expire_all()
deleted = mob_session.get(MobileDevice, device_id)
assert deleted is None
finally:
_cleanup(app)
def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session):
"""Attempting to deactivate another user's device returns 404."""
from app.main import app
@@ -439,6 +467,42 @@ class TestWhoAmI:
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL
assert data["is_admin"] is False
assert data["preferred_language"] is None # not set yet
finally:
_cleanup(app)
def test_whoami_returns_preferred_language(self, mob_engine, mob_session):
"""preferred_language from UserProfile is included in the whoami response."""
from app.main import app
from app.models import UserProfile
profile = UserProfile(
user_id=_OWNER,
display_name="Bob Test",
preferred_language="de",
)
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["preferred_language"] == "de"
finally:
_cleanup(app)
def test_whoami_no_profile_preferred_language_is_null(self, mob_engine):
"""preferred_language is null 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["preferred_language"] is None
finally:
_cleanup(app)
+179
View File
@@ -695,3 +695,182 @@ class TestOneDriveIntegration:
# Verify env format is present (exact values may vary)
assert "env_format" in config_data
@pytest.mark.unit
class TestListOneDriveFolders:
"""Tests for list_onedrive_folders endpoint."""
@patch("app.api.onedrive.requests.get")
def test_list_folders_success(self, mock_get, client):
"""Test successful folder listing at root."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "Documents",
"id": "id:1",
"folder": {"childCount": 3},
"parentReference": {"path": "/drive/root:"},
},
{
"name": "Pictures",
"id": "id:2",
"folder": {"childCount": 10},
"parentReference": {"path": "/drive/root:"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 2
assert data["folders"][0]["name"] == "Documents"
assert data["folders"][0]["path"] == "/Documents"
assert data["folders"][1]["name"] == "Pictures"
assert data["path"] == "/"
@patch("app.api.onedrive.requests.get")
def test_list_folders_subfolder(self, mock_get, client):
"""Test listing folders in a subfolder."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "Invoices",
"id": "id:3",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:/Documents"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": "Documents"},
)
assert response.status_code == 200
data = response.json()
assert len(data["folders"]) == 1
assert data["folders"][0]["path"] == "/Documents/Invoices"
assert data["path"] == "/Documents"
@patch("app.api.onedrive.requests.get")
def test_list_folders_empty(self, mock_get, client):
"""Test listing folders in an empty directory."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"value": []}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": "EmptyFolder"},
)
assert response.status_code == 200
assert len(response.json()["folders"]) == 0
@patch("app.api.onedrive.requests.get")
def test_list_folders_unauthorized(self, mock_get, client):
"""Test listing folders with invalid token returns 401."""
mock_response = Mock()
mock_response.status_code = 401
mock_response.text = "Invalid access token"
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "bad-token", "path": ""},
)
assert response.status_code == 401
@patch("app.api.onedrive.requests.get")
def test_list_folders_api_error(self, mock_get, client):
"""Test listing folders when Graph API returns an error."""
mock_response = Mock()
mock_response.status_code = 500
mock_response.text = "Internal server error"
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 502
@patch("app.api.onedrive.requests.get")
def test_list_folders_sorted_alphabetically(self, mock_get, client):
"""Test that folders are returned in alphabetical order."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "Zebra",
"id": "id:1",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:"},
},
{
"name": "Alpha",
"id": "id:2",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:"},
},
{
"name": "middle",
"id": "id:3",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root:"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
names = [f["name"] for f in response.json()["folders"]]
assert names == ["Alpha", "middle", "Zebra"]
@patch("app.api.onedrive.requests.get")
def test_list_folders_root_drive_parent(self, mock_get, client):
"""Test folder path construction when parentReference.path is /drive/root."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"value": [
{
"name": "TopLevel",
"id": "id:1",
"folder": {"childCount": 0},
"parentReference": {"path": "/drive/root"},
},
],
}
mock_get.return_value = mock_response
response = client.post(
"/api/onedrive/list-folders",
data={"access_token": "test-token", "path": ""},
)
assert response.status_code == 200
data = response.json()
assert data["folders"][0]["path"] == "/TopLevel"
+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
-7
View File
@@ -308,11 +308,6 @@ class TestSavedSearchesCRUD:
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
assert response.status_code == 422
def test_get_user_id_branches(self, client: TestClient, mocker):
"""Test _get_user_id branches with different mock users."""
# This will be tested indirectly by mocking get_current_user
pass
def test_update_saved_search_same_name(self, client: TestClient):
"""PUT /api/saved-searches/{id} with the same name does not trigger duplicate check error."""
# Create a search
@@ -328,11 +323,9 @@ class TestSavedSearchesCRUD:
def test_get_user_id_branches_real(self, client: TestClient):
from app.api.saved_searches import _get_user_id
from fastapi import Request
# We need a mock request
class MockRequest:
session = {}
session = {}
state = type('obj', (object,), {'user': None})
+439
View File
@@ -0,0 +1,439 @@
"""Tests for the session management API endpoints (app/api/sessions.py).
Covers:
* _get_owner_id dependency helper (authenticated and unauthenticated paths)
* GET /api/sessions/ list sessions
* DELETE /api/sessions/{id} revoke a single session
* POST /api/sessions/revoke-all log off everywhere
"""
from __future__ import annotations
import base64
import json
import secrets
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import itsdangerous
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 UserSession
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_OWNER = "sessionuser@example.com"
_OTHER_OWNER = "other@example.com"
_SESSION_SECRET = "test_secret_key_for_testing_must_be_at_least_32_characters_long"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_session_cookie(session_data: dict) -> str:
"""Encode *session_data* as a signed Starlette session cookie value."""
signer = itsdangerous.TimestampSigner(_SESSION_SECRET)
data = base64.b64encode(json.dumps(session_data).encode("utf-8"))
return signer.sign(data).decode("utf-8")
def _make_user_session(
db,
user_id: str = _OWNER,
session_token: str | None = None,
expires_delta: timedelta = timedelta(days=30),
) -> UserSession:
"""Create and persist a UserSession in *db*."""
now = datetime.now(timezone.utc)
token = session_token or secrets.token_urlsafe(32)
session = UserSession(
session_token=token,
user_id=user_id,
ip_address="127.0.0.1",
user_agent="TestBrowser/1.0",
device_info="TestBrowser on Linux",
created_at=now,
last_active_at=now,
expires_at=now + expires_delta,
)
db.add(session)
db.commit()
db.refresh(session)
return session
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def sess_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 sess_db(sess_engine):
"""Database session scoped to one test."""
Session = sessionmaker(bind=sess_engine)
session = Session()
yield session
session.close()
def _make_client(sess_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.sessions import _get_owner_id
from app.main import app
Session = sessionmaker(bind=sess_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
return TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
def _make_unauthenticated_client(sess_engine) -> TestClient:
"""Return a TestClient with only the DB overridden (no auth injection)."""
from app.main import app
Session = sessionmaker(bind=sess_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_get_db
return TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
def _cleanup():
"""Remove all dependency overrides from the app."""
from app.main import app
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests _get_owner_id helper
# ---------------------------------------------------------------------------
class TestGetOwnerId:
"""Tests for the _get_owner_id dependency helper in app/api/sessions.py."""
@pytest.mark.unit
def test_unauthenticated_raises_401(self):
"""_get_owner_id should raise HTTP 401 when the user is not authenticated."""
from unittest.mock import MagicMock
from fastapi import HTTPException
from app.api.sessions import _get_owner_id
mock_request = MagicMock()
with patch("app.api.sessions.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_authenticated_returns_owner_id(self):
"""_get_owner_id should return the owner_id when the user is authenticated."""
from unittest.mock import MagicMock
from app.api.sessions import _get_owner_id
mock_request = MagicMock()
with patch("app.api.sessions.get_current_owner_id", return_value=_OWNER):
result = _get_owner_id(mock_request)
assert result == _OWNER
# ---------------------------------------------------------------------------
# Tests GET /api/sessions/
# ---------------------------------------------------------------------------
class TestListSessions:
"""Tests for GET /api/sessions/."""
@pytest.mark.unit
def test_list_sessions_empty(self, sess_engine):
"""Returns an empty session list when no sessions exist."""
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
data = resp.json()
assert data["sessions"] == []
assert "session_lifetime_days" in data
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_returns_active_sessions(self, sess_engine, sess_db):
"""Returns session details for all active sessions belonging to the user."""
_make_user_session(sess_db, user_id=_OWNER)
_make_user_session(sess_db, user_id=_OWNER)
# A session owned by a different user must not appear.
_make_user_session(sess_db, user_id=_OTHER_OWNER)
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
sessions = resp.json()["sessions"]
assert len(sessions) == 2
for s in sessions:
assert "id" in s
assert "device_info" in s
assert "ip_address" in s
assert "created_at" in s
assert "last_active_at" in s
assert "expires_at" in s
assert "is_current" in s
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_marks_current_session(self, sess_engine, sess_db):
"""The session whose token matches request.session['_session_token'] is
marked ``is_current=True``; all others are ``False``."""
current_token = secrets.token_urlsafe(32)
current_session = _make_user_session(sess_db, user_id=_OWNER, session_token=current_token)
other_session = _make_user_session(sess_db, user_id=_OWNER)
cookie = _make_session_cookie({"_session_token": current_token})
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/", cookies={"session": cookie})
assert resp.status_code == 200
sessions = resp.json()["sessions"]
session_map = {s["id"]: s for s in sessions}
assert session_map[current_session.id]["is_current"] is True
assert session_map[other_session.id]["is_current"] is False
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_no_current_token(self, sess_engine, sess_db):
"""When no _session_token is present, all sessions have is_current=False."""
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
for s in resp.json()["sessions"]:
assert s["is_current"] is False
finally:
_cleanup()
@pytest.mark.unit
def test_list_sessions_returns_lifetime_days(self, sess_engine):
"""Response always includes session_lifetime_days."""
client = _make_client(sess_engine)
try:
resp = client.get("/api/sessions/")
assert resp.status_code == 200
assert isinstance(resp.json()["session_lifetime_days"], int)
assert resp.json()["session_lifetime_days"] >= 1
finally:
_cleanup()
# ---------------------------------------------------------------------------
# Tests DELETE /api/sessions/{session_id}
# ---------------------------------------------------------------------------
class TestRevokeSingleSession:
"""Tests for DELETE /api/sessions/{session_id}."""
@pytest.mark.unit
def test_revoke_session_success(self, sess_engine, sess_db):
"""Revoking an owned session returns 204 No Content."""
session = _make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.delete(f"/api/sessions/{session.id}")
assert resp.status_code == 204
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_session_not_found(self, sess_engine):
"""Revoking a non-existent session returns 404."""
client = _make_client(sess_engine)
try:
resp = client.delete("/api/sessions/999999")
assert resp.status_code == 404
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_session_belonging_to_other_user_returns_404(self, sess_engine, sess_db):
"""A user cannot revoke another user's session (returns 404)."""
other_session = _make_user_session(sess_db, user_id=_OTHER_OWNER)
client = _make_client(sess_engine, owner_id=_OWNER)
try:
resp = client.delete(f"/api/sessions/{other_session.id}")
assert resp.status_code == 404
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_session_audit_failure_does_not_break_response(self, sess_engine, sess_db):
"""Even if the audit service raises an exception, the response is still 204."""
session = _make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
with patch("app.utils.audit_service.record_event", side_effect=Exception("audit down")):
resp = client.delete(f"/api/sessions/{session.id}")
assert resp.status_code == 204
finally:
_cleanup()
# ---------------------------------------------------------------------------
# Tests POST /api/sessions/revoke-all
# ---------------------------------------------------------------------------
class TestRevokeAllSessions:
"""Tests for POST /api/sessions/revoke-all."""
@pytest.mark.unit
def test_revoke_all_no_sessions(self, sess_engine):
"""Returns revoked_count=0 when there are no sessions to revoke."""
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
data = resp.json()
assert data["revoked_count"] == 0
assert "message" in data
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_revokes_all_sessions(self, sess_engine, sess_db):
"""All active sessions for the user are revoked."""
_make_user_session(sess_db, user_id=_OWNER)
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
data = resp.json()
assert data["revoked_count"] == 2
assert "2" in data["message"]
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_preserves_current_session(self, sess_engine, sess_db):
"""The session matching the current _session_token is NOT revoked."""
current_token = secrets.token_urlsafe(32)
current_session = _make_user_session(sess_db, user_id=_OWNER, session_token=current_token)
_make_user_session(sess_db, user_id=_OWNER)
_make_user_session(sess_db, user_id=_OWNER)
cookie = _make_session_cookie({"_session_token": current_token})
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all", cookies={"session": cookie})
assert resp.status_code == 200
# Only the two non-current sessions should be revoked.
assert resp.json()["revoked_count"] == 2
# The current session must still be active in the DB.
sess_db.refresh(current_session)
assert current_session.is_revoked is False
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_current_session_token_not_in_db(self, sess_engine, sess_db):
"""When the _session_token in the cookie doesn't match any DB row,
all sessions are revoked (no session is preserved)."""
_make_user_session(sess_db, user_id=_OWNER)
# Cookie references a token that does not exist in the DB.
cookie = _make_session_cookie({"_session_token": "ghost_token_xyz"})
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all", cookies={"session": cookie})
assert resp.status_code == 200
assert resp.json()["revoked_count"] == 1
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_audit_failure_does_not_break_response(self, sess_engine, sess_db):
"""Even if the audit service raises, revoke-all still returns 200."""
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
with patch("app.utils.audit_service.record_event", side_effect=Exception("audit down")):
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
assert resp.json()["revoked_count"] == 1
finally:
_cleanup()
@pytest.mark.unit
def test_revoke_all_message_format(self, sess_engine, sess_db):
"""Response message includes the count and mentions API tokens."""
_make_user_session(sess_db, user_id=_OWNER)
client = _make_client(sess_engine)
try:
resp = client.post("/api/sessions/revoke-all")
assert resp.status_code == 200
msg = resp.json()["message"]
assert "1" in msg
assert "API" in msg or "token" in msg.lower()
finally:
_cleanup()
+115 -2
View File
@@ -180,6 +180,27 @@ class TestSettingModels:
assert update.key == "test_key"
assert update.value is None
def test_setting_value_update_model(self):
"""Test SettingValueUpdate model (PUT body — no key required)."""
from app.api.settings import SettingValueUpdate
body = SettingValueUpdate(value="test_value")
assert body.value == "test_value"
def test_setting_value_update_model_with_none_value(self):
"""Test SettingValueUpdate model accepts None value."""
from app.api.settings import SettingValueUpdate
body = SettingValueUpdate(value=None)
assert body.value is None
def test_setting_value_update_model_defaults_to_none(self):
"""Test SettingValueUpdate model value defaults to None when omitted."""
from app.api.settings import SettingValueUpdate
body = SettingValueUpdate()
assert body.value is None
def test_setting_response_model(self):
"""Test SettingResponse model."""
from app.api.settings import SettingResponse
@@ -205,8 +226,100 @@ class TestSettingModels:
assert "test_key" in response.db_settings
@pytest.mark.unit
class TestListCredentials:
@pytest.mark.integration
class TestPutSettingEndpoint:
"""Tests for PUT /api/settings/{key} endpoint."""
def test_put_setting_requires_admin(self, client):
"""Test PUT /settings/{key} requires admin access."""
response = client.put("/api/settings/social_auth_dropbox_enabled", json={"value": "true"})
assert response.status_code in [302, 401, 403]
@patch("app.api.settings.notify_settings_updated")
@patch("app.api.settings.get_setting_metadata")
@patch("app.api.settings.validate_setting_value")
@patch("app.api.settings.save_setting_to_db")
def test_put_setting_saves_value(self, mock_save, mock_validate, mock_metadata, mock_notify, client):
"""Test PUT /settings/{key} saves the value when authenticated as admin."""
from app.api.settings import require_admin
from app.main import app as fastapi_app
mock_validate.return_value = (True, None)
mock_save.return_value = True
mock_metadata.return_value = {"restart_required": True}
def override_require_admin():
return {"id": "admin", "is_admin": True, "preferred_username": "admin"}
fastapi_app.dependency_overrides[require_admin] = override_require_admin
try:
response = client.put(
"/api/settings/social_auth_dropbox_enabled",
json={"value": "true"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["key"] == "social_auth_dropbox_enabled"
assert data["value"] == "true"
assert data["restart_required"] is True
finally:
fastapi_app.dependency_overrides.pop(require_admin, None)
@patch("app.api.settings.notify_settings_updated")
@patch("app.api.settings.get_setting_metadata")
@patch("app.api.settings.validate_setting_value")
@patch("app.api.settings.save_setting_to_db")
def test_put_setting_body_without_key_field_is_accepted(
self, mock_save, mock_validate, mock_metadata, mock_notify, client
):
"""Test PUT /settings/{key} body need not contain a key field."""
from app.api.settings import require_admin
from app.main import app as fastapi_app
mock_validate.return_value = (True, None)
mock_save.return_value = True
mock_metadata.return_value = {"restart_required": False}
def override_require_admin():
return {"id": "admin", "is_admin": True, "preferred_username": "admin"}
fastapi_app.dependency_overrides[require_admin] = override_require_admin
try:
# Body only contains "value" — no "key" field (mirrors admin_connections.html behaviour)
response = client.put(
"/api/settings/social_auth_dropbox_use_global_credentials",
json={"value": "false"},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
finally:
fastapi_app.dependency_overrides.pop(require_admin, None)
@patch("app.api.settings.validate_setting_value")
@patch("app.api.settings.get_setting_metadata")
def test_put_setting_returns_400_on_invalid_value(self, mock_metadata, mock_validate, client):
"""Test PUT /settings/{key} returns 400 for invalid values."""
from app.api.settings import require_admin
from app.main import app as fastapi_app
mock_validate.return_value = (False, "Invalid boolean value")
mock_metadata.return_value = {"restart_required": False}
def override_require_admin():
return {"id": "admin", "is_admin": True}
fastapi_app.dependency_overrides[require_admin] = override_require_admin
try:
response = client.put(
"/api/settings/social_auth_dropbox_enabled",
json={"value": "not_a_bool"},
)
assert response.status_code == 400
finally:
fastapi_app.dependency_overrides.pop(require_admin, None)
"""Tests for the list_credentials function (GET /api/settings/credentials)."""
@patch("app.api.settings.get_all_settings_from_db")
+352 -10
View File
@@ -101,6 +101,43 @@ def _cleanup(app):
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests Auth Helper
# ---------------------------------------------------------------------------
class TestGetOwnerId:
"""Tests for the _get_owner_id dependency helper."""
@pytest.mark.unit
def test_get_owner_id_unauthenticated(self):
"""_get_owner_id should raise a 401 if the user is not authenticated."""
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from app.api.api_tokens import _get_owner_id
mock_request = MagicMock()
with patch("app.api.api_tokens.get_current_owner_id", return_value=None):
with pytest.raises(HTTPException) as exc_info:
_get_owner_id(mock_request)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Not authenticated"
@pytest.mark.unit
def test_get_owner_id_authenticated(self):
"""_get_owner_id should return owner_id if user is authenticated."""
from unittest.mock import MagicMock, patch
from app.api.api_tokens import _get_owner_id
mock_request = MagicMock()
with patch("app.api.api_tokens.get_current_owner_id", return_value="owner123"):
owner_id = _get_owner_id(mock_request)
assert owner_id == "owner123"
# ---------------------------------------------------------------------------
# Tests Token CRUD
# ---------------------------------------------------------------------------
@@ -157,6 +194,46 @@ class TestTokenCreate:
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_token_database_error(self, tok_engine, tok_session):
"""Creating a token should rollback and raise 500 if database commit fails."""
from unittest.mock import patch
from sqlalchemy.orm import Session as SASession
from app.main import app
client = _make_client(tok_engine)
try:
# Wrap commit: flush first so changes are staged in the transaction,
# then raise to simulate a commit failure after data has been written.
def _fail_after_flush(self):
self.flush() # stage changes inside the open transaction
raise Exception("DB Failure")
# Spy on rollback so we can assert it is called.
rollback_called = False
real_rollback = SASession.rollback
def _spy_rollback(self):
nonlocal rollback_called
rollback_called = True
real_rollback(self)
with patch.object(SASession, "commit", _fail_after_flush):
with patch.object(SASession, "rollback", _spy_rollback):
resp = client.post("/api/api-tokens/", json={"name": "DB Error Create Test"})
assert resp.status_code == 500
# rollback() must have been called to undo the flushed changes.
assert rollback_called, "db.rollback() was not called after commit failure in create_token"
# After rollback the token must not exist in the database.
db_token = tok_session.query(ApiToken).filter(ApiToken.name == "DB Error Create Test").first()
assert db_token is None
finally:
_cleanup(app)
class TestTokenList:
"""Tests for GET /api/api-tokens/."""
@@ -237,8 +314,8 @@ class TestTokenRevoke:
_cleanup(app)
@pytest.mark.unit
def test_revoke_already_revoked_token(self, tok_engine):
"""Revoking an already-revoked token should return 400."""
def test_delete_already_revoked_token(self, tok_engine):
"""Deleting an already-revoked token should permanently remove it (hard-delete, 200)."""
from app.main import app
client = _make_client(tok_engine)
@@ -247,9 +324,15 @@ class TestTokenRevoke:
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
# Second DELETE should hard-delete the revoked token.
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 400
assert resp.json()["detail"] == "Token is already revoked"
assert resp.status_code == 200
assert resp.json()["detail"] == "Token deleted"
# Token must no longer appear in the list.
list_resp = client.get("/api/api-tokens/")
ids = [t["id"] for t in list_resp.json()]
assert token_id not in ids
finally:
_cleanup(app)
@@ -326,12 +409,10 @@ class TestTokenRevoke:
rollback_called = True
real_rollback(self)
with (
patch.object(SASession, "commit", _fail_after_flush),
patch.object(SASession, "rollback", _spy_rollback),
):
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 500
with patch.object(SASession, "commit", _fail_after_flush):
with patch.object(SASession, "rollback", _spy_rollback):
resp = client.delete(f"/api/api-tokens/{token_id}")
assert resp.status_code == 500
# rollback() must have been called to undo the flushed changes.
assert rollback_called, "db.rollback() was not called after commit failure"
@@ -534,6 +615,44 @@ class TestTokenUtils:
tokens = {generate_api_token() for _ in range(100)}
assert len(tokens) == 100
@pytest.mark.unit
def test_generate_api_token_length(self):
"""Generated tokens should have the exact expected length based on TOKEN_BYTES."""
import math
from app.api.api_tokens import TOKEN_BYTES, TOKEN_PREFIX, generate_api_token
# base64url encoding of N bytes without padding: ceil(N * 4 / 3) characters
expected_b64_len = math.ceil(TOKEN_BYTES * 4 / 3)
expected_total_len = len(TOKEN_PREFIX) + expected_b64_len
token = generate_api_token()
assert len(token) == expected_total_len
@pytest.mark.unit
def test_generate_api_token_charset(self):
"""Generated tokens should only contain URL-safe base64 characters and the prefix."""
import re
from app.api.api_tokens import TOKEN_PREFIX, generate_api_token
token = generate_api_token()
# Check it starts with prefix and the rest is base64url chars ([A-Za-z0-9_-])
pattern = f"^{re.escape(TOKEN_PREFIX)}[A-Za-z0-9_\\-]+$"
assert re.match(pattern, token) is not None
@pytest.mark.unit
def test_generate_api_token_uses_secrets(self):
"""Generated tokens should use secrets.token_urlsafe with the correct number of bytes."""
from unittest.mock import patch
from app.api.api_tokens import TOKEN_BYTES, TOKEN_PREFIX, generate_api_token
with patch("app.api.api_tokens.secrets.token_urlsafe", return_value="mocked_token") as mock_secrets:
token = generate_api_token()
mock_secrets.assert_called_once_with(TOKEN_BYTES)
assert token == f"{TOKEN_PREFIX}mocked_token"
@pytest.mark.unit
def test_hash_token_deterministic(self):
"""Hashing the same token should always produce the same result."""
@@ -554,3 +673,226 @@ 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
# ---------------------------------------------------------------------------
# Tests Token reactivation
# ---------------------------------------------------------------------------
class TestTokenReactivate:
"""Tests for POST /api/api-tokens/{id}/reactivate."""
@pytest.mark.unit
def test_reactivate_revoked_token(self, tok_engine):
"""Reactivating a revoked token should set is_active=True and clear revoked_at."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "Reactivate Me"})
token_id = create_resp.json()["id"]
client.delete(f"/api/api-tokens/{token_id}")
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 200
data = resp.json()
assert data["is_active"] is True
assert data["revoked_at"] is None
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_active_token_returns_400(self, tok_engine):
"""Reactivating an already-active token should return 400."""
from app.main import app
client = _make_client(tok_engine)
try:
create_resp = client.post("/api/api-tokens/", json={"name": "Already Active"})
token_id = create_resp.json()["id"]
resp = client.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 400
assert resp.json()["detail"] == "Token is already active"
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_nonexistent_token(self, tok_engine):
"""Reactivating a non-existent token should return 404."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/99999/reactivate")
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_reactivate_other_users_token(self, tok_engine):
"""A user cannot reactivate another user's token."""
from app.main import app
client_a = _make_client(tok_engine, _OWNER)
try:
create_resp = client_a.post("/api/api-tokens/", json={"name": "A Token"})
token_id = create_resp.json()["id"]
client_a.delete(f"/api/api-tokens/{token_id}")
finally:
_cleanup(app)
client_b = _make_client(tok_engine, _OTHER_OWNER)
try:
resp = client_b.post(f"/api/api-tokens/{token_id}/reactivate")
assert resp.status_code == 404
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests Token lifetime (expires_at)
# ---------------------------------------------------------------------------
class TestTokenExpiry:
"""Tests for token creation with optional lifetime and expiry enforcement."""
@pytest.mark.unit
def test_create_token_without_expiry(self, tok_engine):
"""Creating a token without expires_in_days should leave expires_at as None."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "No Expiry"})
assert resp.status_code == 201
data = resp.json()
assert data["expires_at"] is None
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_token_with_expiry(self, tok_engine, tok_session):
"""Creating a token with expires_in_days should set expires_at in the future."""
from datetime import datetime, timezone
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "With Expiry", "expires_in_days": 30})
assert resp.status_code == 201
data = resp.json()
assert data["expires_at"] is not None
# Parse the returned datetime; handle both tz-aware and tz-naive serialisations
expires_str = data["expires_at"].replace("Z", "+00:00")
expires_at = datetime.fromisoformat(expires_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
delta_days = (expires_at - now).days
assert 28 <= delta_days <= 30
finally:
_cleanup(app)
@pytest.mark.unit
def test_expired_token_not_resolved(self, tok_engine, tok_session):
"""A token past its expires_at should not authenticate."""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Expired Token",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
expires_at=datetime.now(timezone.utc) - timedelta(days=1), # expired yesterday
)
tok_session.add(db_token)
tok_session.commit()
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is None
@pytest.mark.unit
def test_non_expired_token_resolves(self, tok_engine, tok_session):
"""A token before its expires_at should authenticate normally."""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import _resolve_bearer_user
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id=_OWNER,
name="Valid Token",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
expires_at=datetime.now(timezone.utc) + timedelta(days=30), # expires in 30 days
)
tok_session.add(db_token)
tok_session.commit()
mock_request = MagicMock()
mock_request.headers = {"authorization": f"Bearer {plaintext}"}
mock_request.client.host = "127.0.0.1"
user = _resolve_bearer_user(mock_request, tok_session)
assert user is not None
assert user["preferred_username"] == _OWNER
@pytest.mark.unit
def test_create_token_expires_in_days_zero_rejected(self, tok_engine):
"""expires_in_days=0 should be rejected with 422 (ge=1)."""
from app.main import app
client = _make_client(tok_engine)
try:
resp = client.post("/api/api-tokens/", json={"name": "Bad Expiry", "expires_in_days": 0})
assert resp.status_code == 422
finally:
_cleanup(app)
@pytest.mark.unit
def test_expires_at_included_in_list_response(self, tok_engine):
"""List endpoint should include expires_at field."""
from app.main import app
client = _make_client(tok_engine)
try:
client.post("/api/api-tokens/", json={"name": "Listed", "expires_in_days": 7})
resp = client.get("/api/api-tokens/")
assert resp.status_code == 200
tokens = resp.json()
assert len(tokens) == 1
assert "expires_at" in tokens[0]
assert tokens[0]["expires_at"] is not None
finally:
_cleanup(app)
+96 -1
View File
@@ -5,12 +5,15 @@ Covers the audit service (recording, querying, SIEM forwarding),
the REST API endpoints, and the admin viewer page.
"""
import base64
import json
import socket
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, Mock, PropertyMock, patch
import pytest
from fastapi import HTTPException
from itsdangerous import TimestampSigner
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
@@ -645,6 +648,16 @@ class TestAuditLogAPI:
# View tests
# ---------------------------------------------------------------------------
_TEST_SESSION_SECRET = "test_secret_key_for_testing_must_be_at_least_32_characters_long"
def _make_admin_session_cookie() -> str:
"""Create a signed session cookie with admin user data for integration tests."""
session_data = {"user": {"id": "admin", "is_admin": True}}
signer = TimestampSigner(_TEST_SESSION_SECRET)
data = base64.b64encode(json.dumps(session_data).encode()).decode("utf-8")
return signer.sign(data).decode("utf-8")
@pytest.mark.integration
class TestAuditLogView:
@@ -655,3 +668,85 @@ class TestAuditLogView:
resp = client.get("/admin/audit-logs")
assert resp.status_code == 200
assert "Audit Logs" in resp.text
def test_audit_logs_page_accessible_with_admin_session(self, client):
"""GET /admin/audit-logs with admin session cookie returns 200."""
client.cookies.set("session", _make_admin_session_cookie())
resp = client.get("/admin/audit-logs", follow_redirects=False)
assert resp.status_code == 200
assert "Audit Logs" in resp.text
def test_audit_logs_page_redirects_non_admin(self, client):
"""GET /admin/audit-logs without admin session redirects to home."""
resp = client.get("/admin/audit-logs", follow_redirects=False)
assert resp.status_code == 302
@pytest.mark.unit
class TestAuditLogsPageUnit:
"""Unit tests for the audit_logs_page view function (lines 29-43)."""
@patch("app.views.audit_logs.templates")
@patch("app.views.audit_logs.settings")
@pytest.mark.asyncio
async def test_audit_logs_page_siem_disabled(self, mock_settings, mock_templates):
"""Renders the template with siem_transport=None when SIEM is disabled."""
from app.views.audit_logs import audit_logs_page
mock_settings.audit_siem_enabled = False
mock_settings.version = "2.0.0"
mock_request = Mock()
mock_request.session = {"user": {"id": "admin", "is_admin": True}}
mock_db = Mock()
await audit_logs_page(mock_request, mock_db)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
assert call_args[0][0] == "audit_logs.html"
context = call_args[0][1]
assert context["siem_enabled"] is False
assert context["siem_transport"] is None
assert context["app_version"] == "2.0.0"
@patch("app.views.audit_logs.templates")
@patch("app.views.audit_logs.settings")
@pytest.mark.asyncio
async def test_audit_logs_page_siem_enabled(self, mock_settings, mock_templates):
"""Renders the template with siem_transport set when SIEM is enabled."""
from app.views.audit_logs import audit_logs_page
mock_settings.audit_siem_enabled = True
mock_settings.audit_siem_transport = "syslog"
mock_settings.version = "2.0.0"
mock_request = Mock()
mock_request.session = {"user": {"id": "admin", "is_admin": True}}
mock_db = Mock()
await audit_logs_page(mock_request, mock_db)
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
assert context["siem_enabled"] is True
assert context["siem_transport"] == "syslog"
@patch("app.views.audit_logs.settings")
@pytest.mark.asyncio
async def test_audit_logs_page_raises_500_on_error(self, mock_settings):
"""Raises HTTP 500 when an unexpected error occurs while loading the page."""
from app.views.audit_logs import audit_logs_page
# Make accessing audit_siem_enabled raise an exception to trigger the except branch
type(mock_settings).audit_siem_enabled = PropertyMock(side_effect=RuntimeError("settings unavailable"))
mock_request = Mock()
mock_request.session = {"user": {"id": "admin", "is_admin": True}}
mock_db = Mock()
with pytest.raises(HTTPException) as exc_info:
await audit_logs_page(mock_request, mock_db)
assert exc_info.value.status_code == 500
assert "Failed to load audit logs page" in exc_info.value.detail
+38 -3
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:
@@ -395,8 +430,8 @@ class TestLoginFunction:
# Verify TemplateResponse was called with correct context
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
assert call_args[0][0] == "login.html"
context = call_args[0][1]
assert call_args[0][1] == "login.html"
context = call_args.kwargs["context"]
assert context["error"] == "Test error"
assert context["message"] == "Test message"
@@ -415,7 +450,7 @@ class TestLoginFunction:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs["context"]
assert context["error"] is None
assert context["message"] is None
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -281,7 +281,7 @@ class TestLoginEndpoint:
# Verify template was rendered with OAuth enabled
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs["context"]
assert context["show_oauth"] is True
assert context["oauth_provider_name"] == "Test SSO"
+432
View File
@@ -0,0 +1,432 @@
"""Tests for the Zapier / Make.com automation integration.
Covers:
- Automation hook utility functions (payload builder, DB queries, dispatch)
- Automation hook Celery task
- REST hooks API endpoints (subscribe, unsubscribe, list, sample, events)
- Incoming action endpoints (upload)
- Integration with existing webhook dispatch
"""
import json
import time
from unittest.mock import MagicMock
import pytest
from app.models import AutomationHook
from app.utils.automation_hooks import (
SAMPLE_PAYLOADS,
build_zapier_payload,
dispatch_automation_hooks,
get_active_hooks_for_event,
)
from app.utils.webhook import VALID_EVENTS
# ---------------------------------------------------------------------------
# Unit tests build_zapier_payload
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuildZapierPayload:
"""Tests for the Zapier-compatible payload builder."""
def test_contains_required_keys(self):
"""Payload must contain id, event, timestamp, plus data fields."""
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
assert "id" in payload
assert "event" in payload
assert "timestamp" in payload
assert "document_id" in payload
def test_id_starts_with_evt(self):
"""ID field must start with 'evt_' for Zapier deduplication."""
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
assert payload["id"].startswith("evt_")
def test_event_matches_input(self):
"""Event field must match the event argument."""
payload = build_zapier_payload("document.processed", {"document_id": 2})
assert payload["event"] == "document.processed"
def test_timestamp_is_recent(self):
"""Timestamp should be close to current time."""
before = time.time()
payload = build_zapier_payload("document.uploaded", {})
after = time.time()
assert before <= payload["timestamp"] <= after
def test_data_is_flat(self):
"""Data fields should be merged into top level (flat, no nested 'data' key)."""
payload = build_zapier_payload("document.uploaded", {"filename": "test.pdf", "size": 1024})
assert payload["filename"] == "test.pdf"
assert payload["size"] == 1024
assert "data" not in payload
def test_unique_ids(self):
"""Each call should produce a unique ID."""
ids = {build_zapier_payload("document.uploaded", {})["id"] for _ in range(50)}
assert len(ids) == 50
# ---------------------------------------------------------------------------
# Unit tests SAMPLE_PAYLOADS
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSamplePayloads:
"""Tests for the sample payloads used by Zapier field mapping."""
def test_all_events_have_samples(self):
"""Every valid event should have a sample payload."""
for event in VALID_EVENTS:
assert event in SAMPLE_PAYLOADS, f"Missing sample payload for {event}"
def test_samples_contain_id_and_event(self):
"""Each sample should contain id and event keys."""
for event, sample in SAMPLE_PAYLOADS.items():
assert "id" in sample, f"Sample for {event} missing 'id'"
assert sample["event"] == event
# ---------------------------------------------------------------------------
# Unit tests get_active_hooks_for_event
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetActiveHooksForEvent:
"""Tests for querying active automation hooks from the database."""
def test_returns_matching_hooks(self, mocker):
"""Only hooks subscribed to the event should be returned."""
hook = MagicMock(
id=1,
target_url="https://hooks.zapier.com/1234",
secret="abc",
events=json.dumps(["document.uploaded"]),
is_active=True,
)
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert len(result) == 1
assert result[0]["target_url"] == "https://hooks.zapier.com/1234"
def test_excludes_non_matching_hooks(self, mocker):
"""Hooks for different events should not be returned."""
hook = MagicMock(
id=1,
target_url="https://hooks.zapier.com/1234",
secret=None,
events=json.dumps(["document.processed"]),
is_active=True,
)
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert len(result) == 0
def test_empty_when_no_hooks(self, mocker):
"""Empty list returned when no hooks exist."""
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = []
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert result == []
# ---------------------------------------------------------------------------
# Unit tests dispatch_automation_hooks
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDispatchAutomationHooks:
"""Tests for the automation hook dispatch function."""
def test_ignores_unknown_events(self, mocker):
"""Unknown events should be silently ignored."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
dispatch_automation_hooks("bad.event", {})
mock_get.assert_not_called()
def test_skips_when_disabled(self, mocker):
"""No hooks should fire when automation_hooks_enabled is False."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=False))
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
dispatch_automation_hooks("document.uploaded", {"file_id": 1})
mock_get.assert_not_called()
def test_queues_celery_task_for_each_hook(self, mocker):
"""A Celery task is queued for each matching hook."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mocker.patch(
"app.utils.automation_hooks.get_active_hooks_for_event",
return_value=[
{"id": 1, "target_url": "https://hooks.zapier.com/a", "secret": "s", "events": ["document.uploaded"]},
{"id": 2, "target_url": "https://hooks.zapier.com/b", "secret": None, "events": ["document.uploaded"]},
],
)
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
dispatch_automation_hooks("document.uploaded", {"file_id": 42})
assert mock_task.call_count == 2
def test_no_tasks_when_no_hooks(self, mocker):
"""No tasks should be queued when there are no matching hooks."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event", return_value=[])
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
dispatch_automation_hooks("document.uploaded", {})
mock_task.assert_not_called()
# ---------------------------------------------------------------------------
# Unit tests deliver_automation_hook_task
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeliverAutomationHookTask:
"""Tests for the automation hook Celery task."""
def test_returns_success_dict(self, mocker):
"""Successful delivery returns status dict."""
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=True)
from app.tasks.automation_tasks import deliver_automation_hook_task
deliver_automation_hook_task.request.retries = 0
result = deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
assert result["status"] == "delivered"
assert result["url"] == "https://hooks.zapier.com/test"
def test_raises_on_failure(self, mocker):
"""Failed delivery raises RuntimeError for Celery retry."""
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=False)
from app.tasks.automation_tasks import deliver_automation_hook_task
deliver_automation_hook_task.request.retries = 0
with pytest.raises(RuntimeError, match="Automation hook delivery"):
deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
# ---------------------------------------------------------------------------
# Integration tests webhook dispatch integration
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestWebhookDispatchIntegration:
"""Test that dispatch_webhook_event also triggers automation hooks."""
def test_dispatch_triggers_automation_hooks(self, mocker):
"""dispatch_webhook_event should also call dispatch_automation_hooks."""
mocker.patch("app.utils.webhook.get_active_webhooks_for_event", return_value=[])
mock_auto = mocker.patch("app.utils.automation_hooks.dispatch_automation_hooks")
from app.utils.webhook import dispatch_webhook_event
dispatch_webhook_event("document.uploaded", {"file_id": 1})
mock_auto.assert_called_once_with("document.uploaded", {"file_id": 1})
# ---------------------------------------------------------------------------
# Integration tests API endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestAutomationAPI:
"""Tests for the /api/automation/ endpoints."""
def _with_auth(self, client):
"""Override auth dependency to simulate an authenticated user."""
from app.api.automation import _require_api_user
client.app.dependency_overrides[_require_api_user] = lambda: {
"id": "testuser",
"email": "test@example.com",
"preferred_username": "testuser",
"is_admin": False,
}
return client
# ── Subscribe / Unsubscribe ──────────────────────────────────────
def test_subscribe_hook(self, client):
"""POST /api/automation/hooks/subscribe creates a new hook."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/test",
"events": ["document.uploaded"],
"hook_type": "zapier",
"description": "My Zap",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["target_url"] == "https://hooks.zapier.com/test"
assert data["events"] == ["document.uploaded"]
assert data["is_active"] is True
assert data["hook_type"] == "zapier"
def test_subscribe_with_secret(self, client):
"""POST /api/automation/hooks/subscribe with secret masks it."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/secret",
"events": ["document.processed"],
"secret": "my-signing-secret",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["has_secret"] is True
assert "secret" not in data
def test_subscribe_invalid_event(self, client):
"""POST /api/automation/hooks/subscribe rejects invalid events."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/bad",
"events": ["bad.event"],
},
)
assert resp.status_code == 422
def test_unsubscribe_hook(self, client, db_session):
"""DELETE /api/automation/hooks/{id} removes the hook."""
self._with_auth(client)
hook = AutomationHook(
target_url="https://hooks.zapier.com/del",
events=json.dumps(["document.uploaded"]),
is_active=True,
hook_type="zapier",
)
db_session.add(hook)
db_session.commit()
hook_id = hook.id
resp = client.delete(f"/api/automation/hooks/{hook_id}")
assert resp.status_code == 204
def test_unsubscribe_not_found(self, client):
"""DELETE /api/automation/hooks/9999 returns 404."""
self._with_auth(client)
resp = client.delete("/api/automation/hooks/9999")
assert resp.status_code == 404
# ── List hooks ───────────────────────────────────────────────────
def test_list_hooks(self, client, db_session):
"""GET /api/automation/hooks returns all hooks."""
self._with_auth(client)
hook = AutomationHook(
target_url="https://hooks.zapier.com/list",
events=json.dumps(["document.processed"]),
is_active=True,
hook_type="make",
)
db_session.add(hook)
db_session.commit()
resp = client.get("/api/automation/hooks")
assert resp.status_code == 200
items = resp.json()
assert any(h["target_url"] == "https://hooks.zapier.com/list" for h in items)
# ── Sample trigger data ──────────────────────────────────────────
def test_trigger_sample(self, client):
"""GET /api/automation/triggers/sample/{event} returns sample data."""
self._with_auth(client)
resp = client.get("/api/automation/triggers/sample/document.uploaded")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["event"] == "document.uploaded"
assert "id" in data[0]
def test_trigger_sample_unknown_event(self, client):
"""GET /api/automation/triggers/sample/bad returns 404."""
self._with_auth(client)
resp = client.get("/api/automation/triggers/sample/bad.event")
assert resp.status_code == 404
# ── Events listing ───────────────────────────────────────────────
def test_list_events(self, client):
"""GET /api/automation/events returns valid event types."""
self._with_auth(client)
resp = client.get("/api/automation/events")
assert resp.status_code == 200
events = resp.json()
assert "document.uploaded" in events
assert "document.processed" in events
assert "document.failed" in events
# ── Incoming action: upload ──────────────────────────────────────
def test_action_upload(self, client, mocker):
"""POST /api/automation/actions/upload accepts a file."""
self._with_auth(client)
mock_task = MagicMock()
mock_task.id = "task-123"
mocker.patch("app.tasks.process_document.process_document.delay", return_value=mock_task)
resp = client.post(
"/api/automation/actions/upload",
files={"file": ("test.pdf", b"fake-pdf-content", "application/pdf")},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "accepted"
assert data["filename"] == "test.pdf"
assert data["task_id"] == "task-123"
def test_action_upload_no_filename(self, client):
"""POST /api/automation/actions/upload rejects empty filename."""
self._with_auth(client)
resp = client.post(
"/api/automation/actions/upload",
files={"file": ("", b"content", "application/pdf")},
)
# FastAPI/Starlette may return 422 (multipart validation) or 400
# (our explicit check) depending on how the empty filename is
# parsed by the underlying multipart parser version.
assert resp.status_code in (400, 422)
# ── Auth required ────────────────────────────────────────────────
def test_requires_auth(self, client):
"""Endpoints return 401 without authentication."""
from app.api.automation import _require_api_user
client.app.dependency_overrides.pop(_require_api_user, None)
resp = client.get("/api/automation/hooks")
assert resp.status_code == 401
+139
View File
@@ -224,3 +224,142 @@ class TestTaskFailureHandler:
# Simply verify that importing the handler doesn't cause errors
# The actual signal connection is tested implicitly by the other tests
assert callable(task_failure_handler)
@pytest.mark.unit
class TestDispatchUserFailureNotification:
"""Tests for _dispatch_user_failure_notification helper."""
@patch("app.celery_app._dispatch_user_failure_notification")
@patch("app.celery_app.settings")
@patch("app.utils.notification.notify_celery_failure")
def test_task_failure_handler_calls_user_failure_dispatch(self, mock_notify_sys, mock_settings, mock_dispatch):
"""task_failure_handler also calls _dispatch_user_failure_notification."""
mock_settings.notify_on_task_failure = True
from app.celery_app import task_failure_handler
mock_sender = MagicMock()
mock_sender.name = "app.tasks.process_document.process_document"
exc = ValueError("OCR timeout")
task_failure_handler(
sender=mock_sender,
task_id="tid",
exception=exc,
args=["/tmp/f.pdf"],
kwargs={"file_id": 42},
)
mock_dispatch.assert_called_once_with(mock_sender, exc, ["/tmp/f.pdf"], {"file_id": 42})
def test_dispatch_ignores_non_document_tasks(self):
"""Non app.tasks.* tasks should be silently ignored."""
from app.celery_app import _dispatch_user_failure_notification
sender = MagicMock()
sender.name = "celery.backend_cleanup"
# Should complete without error or DB access
_dispatch_user_failure_notification(sender, ValueError("x"), [], {})
def test_dispatch_ignores_when_no_file_id(self):
"""If file_id is not in args or kwargs, nothing happens."""
from app.celery_app import _dispatch_user_failure_notification
sender = MagicMock()
sender.name = "app.tasks.process_document.process_document"
# No file_id anywhere
_dispatch_user_failure_notification(sender, ValueError("x"), ["/tmp/f.pdf"], {})
@patch("app.database.SessionLocal")
def test_dispatch_extracts_file_id_from_kwargs(self, mock_session):
"""file_id should be extracted from kwargs when present."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_record = MagicMock()
mock_record.owner_id = "alice@example.com"
mock_record.original_filename = "invoice.pdf"
mock_record.local_filename = "/tmp/invoice.pdf"
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.finalize_document_storage.finalize_document_storage"
exc = RuntimeError("Upload failed")
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
_dispatch_user_failure_notification(sender, exc, ["/tmp/f.pdf"], {"file_id": 10})
mock_notify.assert_called_once_with(
owner_id="alice@example.com",
filename="invoice.pdf",
error="RuntimeError: Upload failed",
file_id=10,
)
@patch("app.database.SessionLocal")
def test_dispatch_extracts_file_id_from_positional_args(self, mock_session):
"""file_id should be extracted from positional args for known tasks."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_record = MagicMock()
mock_record.owner_id = "bob@test.com"
mock_record.original_filename = "scan.pdf"
mock_record.local_filename = "/tmp/scan.pdf"
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.process_with_ocr.process_with_ocr"
exc = ValueError("OCR error")
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
# process_with_ocr: file_id is args[1]
_dispatch_user_failure_notification(sender, exc, ["filename.pdf", 77], {})
mock_notify.assert_called_once_with(
owner_id="bob@test.com",
filename="scan.pdf",
error="ValueError: OCR error",
file_id=77,
)
@patch("app.database.SessionLocal")
def test_dispatch_skips_when_no_owner(self, mock_session):
"""When file record has no owner_id, no notification is sent."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_record = MagicMock()
mock_record.owner_id = None
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.process_document.process_document"
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
_dispatch_user_failure_notification(sender, ValueError("x"), [], {"file_id": 5})
mock_notify.assert_not_called()
@patch("app.database.SessionLocal")
def test_dispatch_skips_when_record_not_found(self, mock_session):
"""When file record doesn't exist, no notification is sent."""
from app.celery_app import _dispatch_user_failure_notification
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_session.return_value.__enter__.return_value = mock_db
sender = MagicMock()
sender.name = "app.tasks.process_document.process_document"
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
_dispatch_user_failure_notification(sender, ValueError("x"), [], {"file_id": 999})
mock_notify.assert_not_called()
+199
View File
@@ -0,0 +1,199 @@
"""Tests for scripts/check_alembic_migrations.py."""
# The script lives outside of the ``app`` package, so we import it by path.
import importlib.util
import textwrap
from pathlib import Path
import pytest
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_alembic_migrations.py"
_spec = importlib.util.spec_from_file_location("check_alembic_migrations", _SCRIPT)
assert _spec and _spec.loader
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
check_migrations = _mod.check_migrations
main = _mod.main
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _write_migration(
directory: Path, filename: str, revision: str, down_revision: str | tuple[str, ...] | None
) -> Path:
"""Helper to create a minimal migration file."""
if down_revision is None:
down_rev_str = "None"
elif isinstance(down_revision, tuple):
down_rev_str = repr(down_revision)
else:
down_rev_str = f'"{down_revision}"'
content = textwrap.dedent(f'''\
"""Test migration."""
from typing import Union
revision: str = "{revision}"
down_revision: Union[str, None] = {down_rev_str}
depends_on: Union[str, None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
''')
path = directory / filename
path.write_text(content)
return path
@pytest.fixture
def versions_dir(tmp_path: Path) -> Path:
"""Return a temporary versions directory."""
d = tmp_path / "versions"
d.mkdir()
return d
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCheckMigrations:
"""Tests for the check_migrations function."""
def test_valid_linear_chain(self, versions_dir: Path) -> None:
"""A simple linear chain should pass with no errors."""
_write_migration(versions_dir, "001_initial.py", "001_initial", None)
_write_migration(versions_dir, "002_add_col.py", "002_add_col", "001_initial")
_write_migration(versions_dir, "003_add_table.py", "003_add_table", "002_add_col")
errors = check_migrations(versions_dir)
assert errors == []
def test_valid_merge_migration(self, versions_dir: Path) -> None:
"""A chain with a merge point should pass."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
# Merge file with tuple down_revision
content = textwrap.dedent('''\
"""Merge."""
from typing import Union
revision: str = "003_merge"
down_revision: Union[str, tuple] = ("002_a", "002_b")
depends_on: Union[str, None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
''')
(versions_dir / "003_merge.py").write_text(content)
errors = check_migrations(versions_dir)
assert errors == []
def test_multiple_heads_detected(self, versions_dir: Path) -> None:
"""Two unmerged branches should report multiple heads."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
errors = check_migrations(versions_dir)
assert len(errors) == 1
assert "Multiple migration heads" in errors[0]
assert "002_a" in errors[0]
assert "002_b" in errors[0]
def test_broken_down_revision(self, versions_dir: Path) -> None:
"""A migration pointing to a non-existent parent should be flagged."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_orphan.py", "002_orphan", "NONEXISTENT")
errors = check_migrations(versions_dir)
assert any("Broken chain" in e for e in errors)
assert any("NONEXISTENT" in e for e in errors)
def test_duplicate_revision(self, versions_dir: Path) -> None:
"""Two files declaring the same revision should be flagged."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_first.py", "002_dup", "001_base")
_write_migration(versions_dir, "002_second.py", "002_dup", "001_base")
errors = check_migrations(versions_dir)
assert any("Duplicate revision" in e for e in errors)
def test_filename_mismatch(self, versions_dir: Path) -> None:
"""A file whose revision doesn't match its filename should be flagged."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
# filename stem is "002_wrong_name" but revision says "002_correct_name"
_write_migration(versions_dir, "002_wrong_name.py", "002_correct_name", "001_base")
errors = check_migrations(versions_dir)
assert any("Filename mismatch" in e for e in errors)
def test_empty_directory(self, versions_dir: Path) -> None:
"""An empty versions directory should report an error."""
errors = check_migrations(versions_dir)
assert len(errors) == 1
assert "No migration files found" in errors[0]
def test_init_py_is_skipped(self, versions_dir: Path) -> None:
"""__init__.py files should be ignored."""
(versions_dir / "__init__.py").write_text("")
_write_migration(versions_dir, "001_base.py", "001_base", None)
errors = check_migrations(versions_dir)
assert errors == []
def test_non_migration_file_skipped(self, versions_dir: Path) -> None:
"""A .py file without a revision variable should be silently skipped."""
(versions_dir / "helper.py").write_text("# just a helper\nx = 1\n")
_write_migration(versions_dir, "001_base.py", "001_base", None)
errors = check_migrations(versions_dir)
assert errors == []
@pytest.mark.unit
class TestMainCLI:
"""Tests for the CLI entry-point."""
def test_success_returns_zero(self, versions_dir: Path) -> None:
"""Valid chain should exit 0."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
rc = main(["--versions-dir", str(versions_dir)])
assert rc == 0
def test_failure_returns_one(self, versions_dir: Path) -> None:
"""Invalid chain should exit 1."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
rc = main(["--versions-dir", str(versions_dir)])
assert rc == 1
def test_missing_directory_returns_two(self, tmp_path: Path) -> None:
"""Non-existent versions directory should exit 2."""
rc = main(["--versions-dir", str(tmp_path / "does_not_exist")])
assert rc == 2
def test_verbose_flag(self, versions_dir: Path) -> None:
"""The --verbose flag should not crash."""
_write_migration(versions_dir, "001_base.py", "001_base", None)
rc = main(["--versions-dir", str(versions_dir), "--verbose"])
assert rc == 0
def test_real_migrations(self) -> None:
"""Smoke test against the actual project migrations."""
real_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
if not real_dir.is_dir():
pytest.skip("migrations/versions directory not found in working tree")
rc = main(["--versions-dir", str(real_dir)])
assert rc == 0
+422
View File
@@ -0,0 +1,422 @@
"""Tests for the rule-based document classification engine.
Covers the classification engine logic in ``app/utils/classification_rules.py``:
built-in rules, custom rules, confidence scoring, and edge cases.
"""
import pytest
from app.utils.classification_rules import (
BUILTIN_CATEGORIES,
BUILTIN_RULES,
RULE_TYPE_CONTENT,
RULE_TYPE_FILENAME,
RULE_TYPE_METADATA,
ClassificationResult,
ClassificationRule,
MatchedRule,
classify_document,
db_rule_to_engine_rule,
)
# ---------------------------------------------------------------------------
# Built-in categories & rules smoke tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuiltinCategories:
"""Verify the pre-built categories and rules are sane."""
def test_builtin_categories_not_empty(self):
"""There must be at least one built-in category."""
assert len(BUILTIN_CATEGORIES) > 0
def test_unknown_category_exists(self):
"""The 'unknown' fallback category must be present."""
assert "unknown" in BUILTIN_CATEGORIES
def test_core_categories_present(self):
"""Invoice, contract, and receipt categories must exist."""
for cat in ("invoice", "contract", "receipt"):
assert cat in BUILTIN_CATEGORIES, f"Missing built-in category: {cat}"
def test_builtin_rules_not_empty(self):
"""There must be at least one built-in rule."""
assert len(BUILTIN_RULES) > 0
def test_all_builtin_rules_reference_valid_types(self):
"""Every built-in rule must use a valid rule_type."""
valid_types = {RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA}
for rule in BUILTIN_RULES:
assert rule.rule_type in valid_types, f"Rule {rule.name!r} has invalid type {rule.rule_type!r}"
# ---------------------------------------------------------------------------
# ClassificationRule dataclass validation
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestClassificationRuleValidation:
"""Test ClassificationRule dataclass validation."""
def test_valid_rule_types(self):
"""Valid rule types should not raise."""
for rt in (RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA):
rule = ClassificationRule(name="test", category="test", rule_type=rt, pattern="test")
assert rule.rule_type == rt
def test_invalid_rule_type_raises(self):
"""An invalid rule_type should raise ValueError."""
with pytest.raises(ValueError, match="Invalid rule_type"):
ClassificationRule(name="test", category="test", rule_type="invalid", pattern="test")
# ---------------------------------------------------------------------------
# Filename pattern matching
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFilenamePatternMatching:
"""Test classification via filename patterns."""
def test_invoice_filename(self):
"""A filename containing 'invoice' should classify as invoice."""
result = classify_document(filename="2024-03-01_Invoice_Acme.pdf")
assert result.category == "invoice"
assert result.confidence > 0
def test_german_invoice_filename(self):
"""A filename containing 'Rechnung' should classify as invoice."""
result = classify_document(filename="Rechnung_2024.pdf")
assert result.category == "invoice"
assert result.confidence > 0
def test_contract_filename(self):
"""A filename containing 'contract' should classify as contract."""
result = classify_document(filename="Service_Contract_2024.pdf")
assert result.category == "contract"
def test_receipt_filename(self):
"""A filename containing 'receipt' should classify as receipt."""
result = classify_document(filename="Payment_Receipt.pdf")
assert result.category == "receipt"
def test_unrecognized_filename(self):
"""A generic filename with no keywords should return 'unknown'."""
result = classify_document(filename="document_12345.pdf")
assert result.category == "unknown"
assert result.confidence == 0
def test_empty_filename(self):
"""An empty filename should not match any rule."""
result = classify_document(filename="")
assert result.category == "unknown"
# ---------------------------------------------------------------------------
# Content keyword matching
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestContentKeywordMatching:
"""Test classification via content keywords."""
def test_invoice_content(self):
"""Text containing 'invoice number' should classify as invoice."""
result = classify_document(text="Please pay the invoice number 12345. Amount due: $500")
assert result.category == "invoice"
assert result.confidence > 0
def test_contract_content(self):
"""Text containing 'terms and conditions' should classify as contract."""
result = classify_document(text="The parties hereby agree to the following terms and conditions.")
assert result.category == "contract"
def test_receipt_content(self):
"""Text containing 'payment received' should classify as receipt."""
result = classify_document(text="Thank you. Payment received for order #789.")
assert result.category == "receipt"
def test_bank_statement_content(self):
"""Text containing 'account statement' should classify as bank_statement."""
result = classify_document(text="Monthly account statement. Opening balance: $1,000.")
assert result.category == "bank_statement"
def test_empty_text(self):
"""Empty text should not match any content rule."""
result = classify_document(text="")
assert result.category == "unknown"
def test_case_insensitive_matching(self):
"""Content matching should be case-insensitive by default."""
result = classify_document(text="INVOICE NUMBER 12345")
assert result.category == "invoice"
# ---------------------------------------------------------------------------
# Metadata matching
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMetadataMatching:
"""Test classification via metadata field matching."""
def test_document_type_invoice(self):
"""metadata document_type=Invoice should classify as invoice."""
result = classify_document(metadata={"document_type": "Invoice"})
assert result.category == "invoice"
assert result.confidence >= 90
def test_document_type_contract(self):
"""metadata document_type=Contract should classify as contract."""
result = classify_document(metadata={"document_type": "Contract"})
assert result.category == "contract"
def test_kommunikationsart_rechnung(self):
"""German classification metadata should classify as invoice."""
result = classify_document(metadata={"kommunikationsart": "Rechnung"})
assert result.category == "invoice"
def test_no_metadata(self):
"""None metadata should not match."""
result = classify_document(metadata=None)
assert result.category == "unknown"
def test_empty_metadata(self):
"""Empty metadata dict should not match."""
result = classify_document(metadata={})
assert result.category == "unknown"
def test_metadata_case_insensitive(self):
"""Metadata matching should be case-insensitive by default."""
result = classify_document(metadata={"document_type": "invoice"})
assert result.category == "invoice"
# ---------------------------------------------------------------------------
# Combined matching / confidence boosting
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCombinedMatching:
"""Test that multiple matching rules boost confidence."""
def test_filename_and_content_boost(self):
"""Filename + content matching should produce higher confidence than either alone."""
filename_only = classify_document(filename="Invoice_2024.pdf")
combined = classify_document(filename="Invoice_2024.pdf", text="Invoice number: 12345. Amount due: $500.")
assert combined.confidence >= filename_only.confidence
assert len(combined.matched_rules) > len(filename_only.matched_rules)
def test_all_three_signals(self):
"""Filename + content + metadata should produce highest confidence."""
result = classify_document(
filename="Invoice_Acme.pdf",
text="Invoice number: 12345. Amount due: $500.",
metadata={"document_type": "Invoice"},
)
assert result.category == "invoice"
assert result.confidence >= 90
def test_conflicting_signals_most_matches_wins(self):
"""When filename says 'invoice' but content says 'contract', most matches wins."""
result = classify_document(
filename="Invoice.pdf",
text="The parties hereby agree to the following terms and conditions. "
"This agreement between Company A and Company B is effective immediately.",
)
# Content has more keyword matches for contract, but filename matches invoice.
# Either is acceptable as long as the result is deterministic.
assert result.category in ("invoice", "contract")
# ---------------------------------------------------------------------------
# Custom rules
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCustomRules:
"""Test user-defined custom classification rules."""
def test_custom_rule_matches(self):
"""A custom filename rule should match when pattern hits."""
custom = [
ClassificationRule(
name="custom_hr_doc",
category="hr_document",
rule_type=RULE_TYPE_FILENAME,
pattern=r"(?i)employee|hiring|hr",
)
]
result = classify_document(filename="Employee_Handbook.pdf", custom_rules=custom)
assert result.category == "hr_document"
def test_custom_content_rule(self):
"""A custom content keyword rule should match."""
custom = [
ClassificationRule(
name="custom_medical",
category="medical",
rule_type=RULE_TYPE_CONTENT,
pattern="diagnosis|prescription|patient record",
)
]
result = classify_document(text="Patient record for Jane Doe. Diagnosis: common cold.", custom_rules=custom)
assert result.category == "medical"
def test_custom_metadata_rule(self):
"""A custom metadata rule should match."""
custom = [
ClassificationRule(
name="custom_legal",
category="legal",
rule_type=RULE_TYPE_METADATA,
pattern="department=legal",
)
]
result = classify_document(metadata={"department": "legal"}, custom_rules=custom)
assert result.category == "legal"
def test_custom_rule_overrides_builtin(self):
"""Custom rules with more matches should override built-in rules."""
custom = [
ClassificationRule(
name="custom_internal_invoice",
category="internal_invoice",
rule_type=RULE_TYPE_FILENAME,
pattern=r"(?i)invoice",
priority=100,
),
ClassificationRule(
name="custom_internal_invoice_content",
category="internal_invoice",
rule_type=RULE_TYPE_CONTENT,
pattern="invoice number",
priority=100,
),
]
result = classify_document(
filename="Invoice_2024.pdf",
text="Invoice number: 12345",
custom_rules=custom,
)
# Both builtin and custom rules for "invoice" patterns match, but custom
# has "internal_invoice" as category. The category with more total matches wins.
assert result.category in ("invoice", "internal_invoice")
assert result.confidence > 0
# ---------------------------------------------------------------------------
# db_rule_to_engine_rule converter
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDbRuleConversion:
"""Test the database model to engine rule converter."""
def test_converts_basic_fields(self):
"""All basic fields should be mapped correctly."""
class FakeDbRule:
name = "test_rule"
category = "invoice"
rule_type = RULE_TYPE_FILENAME
pattern = r"(?i)invoice"
priority = 10
case_sensitive = True
engine_rule = db_rule_to_engine_rule(FakeDbRule())
assert engine_rule.name == "test_rule"
assert engine_rule.category == "invoice"
assert engine_rule.rule_type == RULE_TYPE_FILENAME
assert engine_rule.pattern == r"(?i)invoice"
assert engine_rule.priority == 10
assert engine_rule.case_sensitive is True
def test_defaults_case_sensitive_to_false(self):
"""When case_sensitive is missing, default to False."""
class FakeDbRule:
name = "test"
category = "test"
rule_type = RULE_TYPE_CONTENT
pattern = "test"
priority = 0
engine_rule = db_rule_to_engine_rule(FakeDbRule())
assert engine_rule.case_sensitive is False
# ---------------------------------------------------------------------------
# ClassificationResult
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestClassificationResult:
"""Test the ClassificationResult dataclass."""
def test_default_matched_rules(self):
"""matched_rules should default to an empty list."""
result = ClassificationResult(category="test", confidence=50)
assert result.matched_rules == []
def test_with_matched_rules(self):
"""matched_rules should be populated when provided."""
match = MatchedRule(rule_name="test", rule_type=RULE_TYPE_FILENAME, category="invoice", confidence=60)
result = ClassificationResult(category="invoice", confidence=60, matched_rules=[match])
assert len(result.matched_rules) == 1
assert result.matched_rules[0].rule_name == "test"
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestEdgeCases:
"""Test edge cases in the classification engine."""
def test_no_inputs_at_all(self):
"""No filename, text, or metadata should return 'unknown'."""
result = classify_document()
assert result.category == "unknown"
assert result.confidence == 0
assert result.matched_rules == []
def test_metadata_pattern_without_equals(self):
"""A metadata pattern without '=' should not match."""
custom = [
ClassificationRule(
name="bad_pattern",
category="test",
rule_type=RULE_TYPE_METADATA,
pattern="no_equals_sign",
)
]
result = classify_document(metadata={"no_equals_sign": "value"}, custom_rules=custom)
assert result.category == "unknown"
def test_confidence_capped_at_100(self):
"""Confidence should never exceed 100."""
# Create many rules that all match to test the cap
custom = [
ClassificationRule(
name=f"flood_{i}",
category="flood",
rule_type=RULE_TYPE_CONTENT,
pattern="test keyword",
)
for i in range(20)
]
result = classify_document(text="test keyword is here", custom_rules=custom)
assert result.confidence <= 100
+232
View File
@@ -0,0 +1,232 @@
"""Tests for the classify_document Celery task.
Covers the ``classify_document_task`` in ``app/tasks/classify_document.py``.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from app.models import ClassificationRuleModel, FileRecord
from app.tasks.classify_document import _load_custom_rules
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_file_record(db_session, **overrides):
"""Insert a minimal FileRecord and return it."""
defaults = {
"owner_id": "test-user",
"filehash": "abc123",
"original_filename": "Invoice_2024.pdf",
"local_filename": "/tmp/test.pdf",
"file_size": 1024,
"mime_type": "application/pdf",
"ocr_text": "Invoice number: 12345. Amount due: $500.",
"ai_metadata": None,
}
defaults.update(overrides)
fr = FileRecord(**defaults)
db_session.add(fr)
db_session.commit()
db_session.refresh(fr)
return fr
def _make_rule(db_session, **overrides):
"""Insert a ClassificationRuleModel and return it."""
defaults = {
"owner_id": None,
"name": "test_rule",
"category": "test_category",
"rule_type": "filename_pattern",
"pattern": r"(?i)test",
"priority": 0,
"case_sensitive": False,
"enabled": True,
}
defaults.update(overrides)
rule = ClassificationRuleModel(**defaults)
db_session.add(rule)
db_session.commit()
db_session.refresh(rule)
return rule
# ---------------------------------------------------------------------------
# _load_custom_rules
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestLoadCustomRules:
"""Test the custom rule loading helper."""
@patch("app.tasks.classify_document.SessionLocal")
def test_loads_enabled_rules(self, mock_session_local):
"""Should load enabled rules from the database."""
mock_rule = MagicMock()
mock_rule.name = "rule1"
mock_rule.category = "invoice"
mock_rule.rule_type = "filename_pattern"
mock_rule.pattern = r"(?i)invoice"
mock_rule.priority = 10
mock_rule.case_sensitive = False
mock_db = MagicMock()
mock_query = MagicMock()
mock_db.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.all.return_value = [mock_rule]
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
rules = _load_custom_rules(owner_id="test-user")
assert len(rules) == 1
assert rules[0].name == "rule1"
assert rules[0].category == "invoice"
# ---------------------------------------------------------------------------
# classify_document_task (integration-style with mocked DB and Celery)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestClassifyDocumentTask:
"""Test the Celery classify_document_task."""
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_invoice_file(self, mock_session_local, mock_load_rules, mock_log):
"""Should classify a file with invoice filename and text as 'invoice'."""
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 1
mock_file.original_filename = "Invoice_2024.pdf"
mock_file.ocr_text = "Invoice number: 12345. Amount due: $500."
mock_file.ai_metadata = None
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
# Call the underlying function directly via .run(), bypassing Celery
result = classify_document_task.run(1, owner_id="test-user")
assert result["status"] == "success"
assert result["category"] == "invoice"
assert result["confidence"] > 0
# Verify ai_metadata was updated
assert mock_file.ai_metadata is not None
metadata = json.loads(mock_file.ai_metadata)
assert "classification" in metadata
assert metadata["classification"]["category"] == "invoice"
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_file_not_found(self, mock_session_local, mock_log):
"""Should return error when file record is not found."""
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
result = classify_document_task.run(99999)
assert result["status"] == "error"
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_preserves_existing_metadata(self, mock_session_local, mock_load_rules, mock_log):
"""Should preserve existing ai_metadata fields and add classification."""
existing_meta = json.dumps({"document_type": "Invoice", "tags": ["finance"]})
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 2
mock_file.original_filename = "doc.pdf"
mock_file.ocr_text = ""
mock_file.ai_metadata = existing_meta
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
classify_document_task.run(2)
# Check that existing fields are preserved
metadata = json.loads(mock_file.ai_metadata)
assert metadata["tags"] == ["finance"]
assert metadata["document_type"] == "Invoice"
assert "classification" in metadata
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_sets_document_type_when_missing(self, mock_session_local, mock_load_rules, mock_log):
"""Should set document_type from classification when not already present."""
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 3
mock_file.original_filename = "Invoice_2024.pdf"
mock_file.ocr_text = "Invoice number: 12345"
mock_file.ai_metadata = json.dumps({"tags": ["test"]})
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
classify_document_task.run(3)
metadata = json.loads(mock_file.ai_metadata)
assert metadata["document_type"] == "Invoice"
@patch("app.tasks.classify_document.log_task_progress")
@patch("app.tasks.classify_document._load_custom_rules", return_value=[])
@patch("app.tasks.classify_document.SessionLocal")
def test_classify_unknown_document(self, mock_session_local, mock_load_rules, mock_log):
"""Should classify as 'unknown' when no rules match."""
mock_file = MagicMock(spec=FileRecord)
mock_file.id = 4
mock_file.original_filename = "random_file.pdf"
mock_file.ocr_text = "Lorem ipsum dolor sit amet."
mock_file.ai_metadata = None
mock_file.owner_id = "test-user"
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_file
mock_session_local.return_value.__enter__ = MagicMock(return_value=mock_db)
mock_session_local.return_value.__exit__ = MagicMock(return_value=False)
from app.tasks.classify_document import classify_document_task
result = classify_document_task.run(4)
assert result["category"] == "unknown"
assert result["confidence"] == 0
def test_classify_document_task_is_celery_task(self):
"""Task should be registered as a Celery task."""
from app.tasks.classify_document import classify_document_task
assert hasattr(classify_document_task, "apply_async")
assert hasattr(classify_document_task, "delay")
assert callable(classify_document_task)
+525
View File
@@ -0,0 +1,525 @@
"""Tests for the document comments and annotations API."""
import pytest
from app.models import DocumentAnnotation, DocumentComment, FileRecord, UserProfile
def _create_file(db_session, owner_id="testuser") -> FileRecord:
"""Helper to create a minimal FileRecord for testing."""
f = FileRecord(
owner_id=owner_id,
filehash="abc123",
original_filename="test.pdf",
local_filename="test.pdf",
file_size=1024,
mime_type="application/pdf",
)
db_session.add(f)
db_session.commit()
db_session.refresh(f)
return f
# ---------------------------------------------------------------------------
# Comment tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListComments:
"""Tests for GET /api/files/{file_id}/comments."""
def test_list_comments_empty(self, client, db_session):
f = _create_file(db_session)
resp = client.get(f"/api/files/{f.id}/comments")
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == f.id
assert data["comments"] == []
assert data["total"] == 0
def test_list_comments_file_not_found(self, client):
resp = client.get("/api/files/99999/comments")
assert resp.status_code == 404
def test_list_comments_threaded(self, client, db_session):
f = _create_file(db_session)
# Root comment
c1 = DocumentComment(file_id=f.id, user_id="alice", body="Hello")
db_session.add(c1)
db_session.commit()
db_session.refresh(c1)
# Reply
c2 = DocumentComment(file_id=f.id, user_id="bob", parent_id=c1.id, body="Hi back")
db_session.add(c2)
db_session.commit()
resp = client.get(f"/api/files/{f.id}/comments")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert len(data["comments"]) == 1 # only root
assert len(data["comments"][0]["replies"]) == 1
assert data["comments"][0]["replies"][0]["body"] == "Hi back"
@pytest.mark.unit
class TestCreateComment:
"""Tests for POST /api/files/{file_id}/comments."""
def test_create_comment(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "Great document!"},
)
assert resp.status_code == 201
data = resp.json()
assert data["body"] == "Great document!"
assert data["file_id"] == f.id
assert data["parent_id"] is None
def test_create_comment_with_mention(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "Hey @alice please review"},
)
assert resp.status_code == 201
data = resp.json()
assert data["mentions"] == ["alice"]
def test_create_comment_with_parent(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="user1", body="root")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "reply", "parent_id": c.id},
)
assert resp.status_code == 201
assert resp.json()["parent_id"] == c.id
def test_create_comment_parent_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "reply", "parent_id": 99999},
)
assert resp.status_code == 404
def test_create_comment_empty_body(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": " "},
)
assert resp.status_code == 422
def test_create_comment_file_not_found(self, client):
resp = client.post(
"/api/files/99999/comments",
json={"body": "test"},
)
assert resp.status_code == 404
def test_create_comment_body_too_long(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "x" * 10_001},
)
assert resp.status_code == 422
@pytest.mark.unit
class TestUpdateComment:
"""Tests for PUT /api/files/{file_id}/comments/{comment_id}."""
def test_update_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="old body")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.put(
f"/api/files/{f.id}/comments/{c.id}",
json={"body": "new body @bob"},
)
assert resp.status_code == 200
data = resp.json()
assert data["body"] == "new body @bob"
assert data["mentions"] == ["bob"]
def test_update_comment_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.put(
f"/api/files/{f.id}/comments/99999",
json={"body": "new"},
)
assert resp.status_code == 404
def test_update_comment_forbidden(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="other_user", body="old")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.put(
f"/api/files/{f.id}/comments/{c.id}",
json={"body": "new"},
)
assert resp.status_code == 403
@pytest.mark.unit
class TestDeleteComment:
"""Tests for DELETE /api/files/{file_id}/comments/{comment_id}."""
def test_delete_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="to delete")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
assert resp.status_code == 204
# Verify deleted
assert db_session.query(DocumentComment).filter(DocumentComment.id == c.id).first() is None
def test_delete_comment_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.delete(f"/api/files/{f.id}/comments/99999")
assert resp.status_code == 404
def test_delete_comment_forbidden(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="other_user", body="mine")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.delete(f"/api/files/{f.id}/comments/{c.id}")
assert resp.status_code == 403
@pytest.mark.unit
class TestResolveComment:
"""Tests for PATCH /api/files/{file_id}/comments/{comment_id}/resolve."""
def test_resolve_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue")
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.patch(
f"/api/files/{f.id}/comments/{c.id}/resolve",
json={"is_resolved": True},
)
assert resp.status_code == 200
assert resp.json()["is_resolved"] is True
def test_unresolve_comment(self, client, db_session):
f = _create_file(db_session)
c = DocumentComment(file_id=f.id, user_id="anonymous", body="issue", is_resolved=True)
db_session.add(c)
db_session.commit()
db_session.refresh(c)
resp = client.patch(
f"/api/files/{f.id}/comments/{c.id}/resolve",
json={"is_resolved": False},
)
assert resp.status_code == 200
assert resp.json()["is_resolved"] is False
def test_resolve_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.patch(
f"/api/files/{f.id}/comments/99999/resolve",
json={"is_resolved": True},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Annotation tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListAnnotations:
"""Tests for GET /api/files/{file_id}/annotations."""
def test_list_annotations_empty(self, client, db_session):
f = _create_file(db_session)
resp = client.get(f"/api/files/{f.id}/annotations")
assert resp.status_code == 200
data = resp.json()
assert data["file_id"] == f.id
assert data["annotations"] == []
assert data["total"] == 0
def test_list_annotations_file_not_found(self, client):
resp = client.get("/api/files/99999/annotations")
assert resp.status_code == 404
@pytest.mark.unit
class TestCreateAnnotation:
"""Tests for POST /api/files/{file_id}/annotations."""
def test_create_annotation(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={
"page": 1,
"x": 100.0,
"y": 200.0,
"content": "Important note",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["page"] == 1
assert data["x"] == 100.0
assert data["y"] == 200.0
assert data["content"] == "Important note"
assert data["annotation_type"] == "note"
def test_create_annotation_with_all_fields(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={
"page": 2,
"x": 50.0,
"y": 100.0,
"width": 200.0,
"height": 30.0,
"content": "Highlighted text",
"annotation_type": "highlight",
"color": "#ffff00",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["annotation_type"] == "highlight"
assert data["color"] == "#ffff00"
assert data["width"] == 200.0
assert data["height"] == 30.0
def test_create_annotation_file_not_found(self, client):
resp = client.post(
"/api/files/99999/annotations",
json={"page": 1, "x": 0, "y": 0, "content": "test"},
)
assert resp.status_code == 404
def test_create_annotation_empty_content(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 1, "x": 0, "y": 0, "content": " "},
)
assert resp.status_code == 422
def test_create_annotation_invalid_page(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 0, "x": 0, "y": 0, "content": "test"},
)
assert resp.status_code == 422
def test_create_annotation_invalid_type(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 1, "x": 0, "y": 0, "content": "test", "annotation_type": "invalid"},
)
assert resp.status_code == 422
def test_create_annotation_content_too_long(self, client, db_session):
f = _create_file(db_session)
resp = client.post(
f"/api/files/{f.id}/annotations",
json={"page": 1, "x": 0, "y": 0, "content": "x" * 5_001},
)
assert resp.status_code == 422
@pytest.mark.unit
class TestUpdateAnnotation:
"""Tests for PUT /api/files/{file_id}/annotations/{annotation_id}."""
def test_update_annotation(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.put(
f"/api/files/{f.id}/annotations/{a.id}",
json={"content": "updated note", "color": "#00ff00"},
)
assert resp.status_code == 200
data = resp.json()
assert data["content"] == "updated note"
assert data["color"] == "#00ff00"
def test_update_annotation_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.put(
f"/api/files/{f.id}/annotations/99999",
json={"content": "new"},
)
assert resp.status_code == 404
def test_update_annotation_forbidden(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.put(
f"/api/files/{f.id}/annotations/{a.id}",
json={"content": "hijack"},
)
assert resp.status_code == 403
def test_update_annotation_invalid_type(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="old")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.put(
f"/api/files/{f.id}/annotations/{a.id}",
json={"annotation_type": "invalid"},
)
assert resp.status_code == 422
@pytest.mark.unit
class TestDeleteAnnotation:
"""Tests for DELETE /api/files/{file_id}/annotations/{annotation_id}."""
def test_delete_annotation(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(
file_id=f.id, user_id="anonymous", page=1, x=0, y=0, width=0, height=0, content="to delete"
)
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
assert resp.status_code == 204
assert db_session.query(DocumentAnnotation).filter(DocumentAnnotation.id == a.id).first() is None
def test_delete_annotation_not_found(self, client, db_session):
f = _create_file(db_session)
resp = client.delete(f"/api/files/{f.id}/annotations/99999")
assert resp.status_code == 404
def test_delete_annotation_forbidden(self, client, db_session):
f = _create_file(db_session)
a = DocumentAnnotation(file_id=f.id, user_id="other_user", page=1, x=0, y=0, width=0, height=0, content="mine")
db_session.add(a)
db_session.commit()
db_session.refresh(a)
resp = client.delete(f"/api/files/{f.id}/annotations/{a.id}")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Mentionable users tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListMentionableUsers:
"""Tests for GET /api/users/mentionable."""
def test_list_mentionable_empty(self, client, db_session):
resp = client.get("/api/users/mentionable")
assert resp.status_code == 200
assert resp.json() == []
def test_list_mentionable_users(self, client, db_session):
p1 = UserProfile(user_id="alice", display_name="Alice A")
p2 = UserProfile(user_id="bob", display_name="Bob B")
db_session.add_all([p1, p2])
db_session.commit()
resp = client.get("/api/users/mentionable")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["user_id"] == "alice"
assert data[1]["user_id"] == "bob"
def test_blocked_users_excluded(self, client, db_session):
p1 = UserProfile(user_id="alice", display_name="Alice A", is_blocked=False)
p2 = UserProfile(user_id="blocked", display_name="Blocked", is_blocked=True)
db_session.add_all([p1, p2])
db_session.commit()
resp = client.get("/api/users/mentionable")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["user_id"] == "alice"
# ---------------------------------------------------------------------------
# Mention extraction helper tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestExtractMentions:
"""Tests for the _extract_mentions helper function."""
def test_no_mentions(self):
from app.api.comments import _extract_mentions
assert _extract_mentions("Hello world") == []
def test_single_mention(self):
from app.api.comments import _extract_mentions
assert _extract_mentions("Hey @alice check this") == ["alice"]
def test_multiple_mentions(self):
from app.api.comments import _extract_mentions
assert _extract_mentions("@alice @bob @charlie") == ["alice", "bob", "charlie"]
def test_duplicate_mentions(self):
from app.api.comments import _extract_mentions
result = _extract_mentions("@alice and @alice again")
assert result == ["alice"]
def test_mention_with_dots_and_dashes(self):
from app.api.comments import _extract_mentions
result = _extract_mentions("@user.name @user-name")
assert result == ["user.name", "user-name"]
+210
View File
@@ -0,0 +1,210 @@
"""Tests for the comments and annotations UI on the file annotations page."""
import pytest
from fastapi.testclient import TestClient
from app.models import FileRecord
def _create_file(db_session, tmp_path) -> FileRecord:
"""Create a minimal FileRecord with a real file path for the annotations page."""
file_path = tmp_path / "test.pdf"
file_path.write_bytes(b"%PDF-1.4")
f = FileRecord(
filehash="uihash123",
original_filename="test.pdf",
local_filename=str(file_path),
original_file_path=str(file_path),
file_size=1024,
mime_type="application/pdf",
)
db_session.add(f)
db_session.commit()
db_session.refresh(f)
return f
@pytest.mark.unit
class TestCommentsUIRendering:
"""Verify the file annotations page includes the comments panel HTML."""
def test_annotations_page_contains_comments_section(self, client: TestClient, db_session, tmp_path):
"""The annotations page should render the comments panel container."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'id="comments-list"' in html
assert 'id="comment-form"' in html
assert 'id="comment-input"' in html
def test_annotations_page_contains_annotations_section(self, client: TestClient, db_session, tmp_path):
"""The annotations page should render the annotations panel container."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'id="annotations-list"' in html
assert 'id="annotation-form"' in html
assert 'id="annotation-content-input"' in html
def test_annotations_page_loads_comments_js(self, client: TestClient, db_session, tmp_path):
"""The annotations page should include the comments JavaScript file."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "js/comments.js" in resp.text
def test_annotations_page_loads_annotations_js(self, client: TestClient, db_session, tmp_path):
"""The annotations page should include the annotations JavaScript file."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "js/annotations.js" in resp.text
def test_annotations_page_has_mention_dropdown(self, client: TestClient, db_session, tmp_path):
"""The mention autocomplete dropdown should be present."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert 'id="mention-dropdown"' in resp.text
def test_annotations_page_has_annotation_form_fields(self, client: TestClient, db_session, tmp_path):
"""Annotation form should have page, type, and color inputs."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'id="annotation-page-input"' in html
assert 'id="annotation-type-input"' in html
assert 'id="annotation-color-input"' in html
def test_annotations_page_has_collab_grid(self, client: TestClient, db_session, tmp_path):
"""Comments and annotations should be in a side-by-side grid layout."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "collab-grid" in resp.text
def test_annotations_page_no_comments_for_missing_file(self, client: TestClient):
"""When file is not found, no comments section should appear."""
resp = client.get("/files/99999/annotations")
assert resp.status_code == 200
# The error block is shown, not the main content
assert 'id="comments-list"' not in resp.text
def test_annotations_page_annotation_type_options(self, client: TestClient, db_session, tmp_path):
"""Annotation type selector should include all four types."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'value="note"' in html
assert 'value="highlight"' in html
assert 'value="underline"' in html
assert 'value="strikethrough"' in html
def test_annotations_page_comments_panel_accessibility(self, client: TestClient, db_session, tmp_path):
"""Comments panel should have proper ARIA attributes."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'aria-live="polite"' in html
assert 'role="listbox"' in html
def test_annotations_page_init_script(self, client: TestClient, db_session, tmp_path):
"""The init script should call initComments and initAnnotations."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert "initComments" in html
assert "initAnnotations" in html
def test_comments_url_redirects_to_annotations(self, client: TestClient, db_session, tmp_path):
"""The /comments URL should redirect to /annotations."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/comments", follow_redirects=False)
assert resp.status_code == 302
assert f"/files/{f.id}/annotations" in resp.headers["location"]
def test_process_page_no_comments_section(self, client: TestClient, db_session, tmp_path):
"""The process page should NOT render the comments panel."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/process")
assert resp.status_code == 200
html = resp.text
assert 'id="comments-list"' not in html
assert 'id="comment-form"' not in html
def test_detail_page_no_comments_section(self, client: TestClient, db_session, tmp_path):
"""The detail page should NOT render the comments panel."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/detail")
assert resp.status_code == 200
html = resp.text
assert 'id="comments-list"' not in html
assert 'id="annotation-form"' not in html
def test_annotations_page_has_embedpdf_viewer_for_pdf(self, client: TestClient, db_session, tmp_path):
"""The annotations page should include the EmbedPDF viewer for PDF files."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert 'id="embedpdf-viewer"' in html
assert "@embedpdf/snippet" in html
def test_embedpdf_init_subscribes_to_page_change(self, client: TestClient, db_session, tmp_path):
"""The EmbedPDF init script should subscribe to page change events to sync the form."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
# Verifies the viewer registry is awaited and scroll plugin is used
assert "viewer.registry" in html
assert "onPageChange" in html
assert "annotation-page-input" in html
def test_embedpdf_init_exposes_scroll_function(self, client: TestClient, db_session, tmp_path):
"""The EmbedPDF init script must expose _embedpdfScrollToPage for the annotations panel."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "_embedpdfScrollToPage" in resp.text
assert "scrollToPage" in resp.text
def test_embedpdf_init_saves_viewer_annotations(self, client: TestClient, db_session, tmp_path):
"""The EmbedPDF init script should capture annotation events and POST to the API."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
html = resp.text
assert "onAnnotationEvent" in html
# Verifies the POST target is the annotations API for this file
assert "/api/files/" in html and "/annotations" in html
def test_embedpdf_init_reloads_annotation_list(self, client: TestClient, db_session, tmp_path):
"""After auto-saving a viewer annotation, the panel list should be refreshed."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "_reloadAnnotations" in resp.text
def test_annotations_page_has_go_to_page_i18n(self, client: TestClient, db_session, tmp_path):
"""The annotations i18n bundle should include the go_to_page key."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}/annotations")
assert resp.status_code == 200
assert "go_to_page" in resp.text
def test_summary_page_renders(self, client: TestClient, db_session, tmp_path):
"""The summary page at /files/{id} should render correctly."""
f = _create_file(db_session, tmp_path)
resp = client.get(f"/files/{f.id}")
assert resp.status_code == 200
html = resp.text
assert "Document Detail" in html
assert "Processing" in html
assert "Comments" in html or "Annotations" in html
+494
View File
@@ -0,0 +1,494 @@
"""Tests for the Connections admin page and new authentication providers."""
from unittest.mock import MagicMock, patch
import pytest
from fastapi import status
@pytest.mark.unit
class TestDropboxComplianceFix:
"""Tests for the Dropbox userinfo compliance fix."""
def test_dropbox_compliance_fix_adds_sub(self):
"""Test that compliance fix adds 'sub' from account_id."""
from app.auth import _dropbox_userinfo_compliance_fix
data = {"account_id": "dbid:abc123", "email": "test@example.com"}
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
assert result["sub"] == "dbid:abc123"
def test_dropbox_compliance_fix_does_not_overwrite_sub(self):
"""Test that compliance fix preserves existing 'sub'."""
from app.auth import _dropbox_userinfo_compliance_fix
data = {"account_id": "dbid:abc123", "sub": "existing-sub"}
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
assert result["sub"] == "existing-sub"
def test_dropbox_compliance_fix_normalizes_name(self):
"""Test that compliance fix normalizes nested name object."""
from app.auth import _dropbox_userinfo_compliance_fix
data = {"name": {"display_name": "John Doe", "given_name": "John"}}
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
assert result["name"] == "John Doe"
def test_dropbox_compliance_fix_handles_no_name(self):
"""Test compliance fix works when no name is present."""
from app.auth import _dropbox_userinfo_compliance_fix
data = {"email": "test@example.com"}
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
assert "email" in result
@pytest.mark.unit
class TestGitHubNormalization:
"""Tests for GitHub userinfo normalization."""
def test_github_normalize_standard(self):
"""Test GitHub userinfo normalization with standard response."""
from app.auth import _normalize_social_userinfo
raw = {
"id": 12345,
"login": "octocat",
"name": "The Octocat",
"email": "octocat@github.com",
"avatar_url": "https://avatars.githubusercontent.com/u/12345",
}
result = _normalize_social_userinfo("github", {}, raw)
assert result["sub"] == "12345"
assert result["email"] == "octocat@github.com"
assert result["name"] == "The Octocat"
assert result["preferred_username"] == "octocat"
assert result["picture"] == "https://avatars.githubusercontent.com/u/12345"
def test_github_normalize_no_name_uses_login(self):
"""Test GitHub normalization falls back to login when name is empty."""
from app.auth import _normalize_social_userinfo
raw = {"id": 12345, "login": "octocat", "name": "", "email": "octocat@github.com"}
result = _normalize_social_userinfo("github", {}, raw)
assert result["name"] == "octocat"
def test_github_normalize_missing_fields(self):
"""Test GitHub normalization handles missing fields gracefully."""
from app.auth import _normalize_social_userinfo
result = _normalize_social_userinfo("github", {}, {})
assert result["sub"] == ""
assert result["email"] == ""
assert result["name"] == ""
assert result["preferred_username"] == ""
@pytest.mark.unit
class TestSSOAutoLogin:
"""Tests for SSO Auto Login configuration."""
def test_sso_auto_login_default_false(self):
"""Test that SSO auto login defaults to False."""
from app.config import Settings
s = Settings(
_env_file=None,
auth_enabled=True,
)
assert s.sso_auto_login is False
def test_sso_auto_login_can_be_enabled(self):
"""Test that SSO auto login can be set to True."""
from app.config import Settings
s = Settings(
_env_file=None,
auth_enabled=True,
sso_auto_login=True,
)
assert s.sso_auto_login is True
@pytest.mark.unit
class TestNewConfigFields:
"""Tests for new configuration fields."""
def test_github_config_defaults(self):
"""Test GitHub social auth config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.social_auth_github_enabled is False
assert s.social_auth_github_client_id is None
assert s.social_auth_github_client_secret is None
def test_keycloak_config_defaults(self):
"""Test Keycloak social auth config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.social_auth_keycloak_enabled is False
assert s.social_auth_keycloak_client_id is None
assert s.social_auth_keycloak_server_url is None
assert s.social_auth_keycloak_realm is None
def test_generic_oauth2_config_defaults(self):
"""Test Generic OAuth2 config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.social_auth_generic_oauth2_enabled is False
assert s.social_auth_generic_oauth2_scope == "openid profile email"
assert s.social_auth_generic_oauth2_name == "OAuth2"
def test_saml2_config_defaults(self):
"""Test SAML2 config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.social_auth_saml2_enabled is False
assert s.social_auth_saml2_name == "SAML2"
def test_telegram_config_defaults(self):
"""Test Telegram config defaults."""
from app.config import Settings
s = Settings(_env_file=None, auth_enabled=True)
assert s.telegram_enabled is False
assert s.telegram_bot_token is None
assert s.telegram_chat_id is None
@pytest.mark.unit
class TestSettingsMetadata:
"""Tests that new settings have metadata entries."""
def test_github_settings_have_metadata(self):
"""Test GitHub settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "social_auth_github_enabled" in SETTING_METADATA
assert "social_auth_github_client_id" in SETTING_METADATA
assert "social_auth_github_client_secret" in SETTING_METADATA
def test_keycloak_settings_have_metadata(self):
"""Test Keycloak settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "social_auth_keycloak_enabled" in SETTING_METADATA
assert "social_auth_keycloak_client_id" in SETTING_METADATA
assert "social_auth_keycloak_server_url" in SETTING_METADATA
assert "social_auth_keycloak_realm" in SETTING_METADATA
def test_generic_oauth2_settings_have_metadata(self):
"""Test Generic OAuth2 settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "social_auth_generic_oauth2_enabled" in SETTING_METADATA
assert "social_auth_generic_oauth2_authorize_url" in SETTING_METADATA
assert "social_auth_generic_oauth2_token_url" in SETTING_METADATA
def test_saml2_settings_have_metadata(self):
"""Test SAML2 settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "social_auth_saml2_enabled" in SETTING_METADATA
assert "social_auth_saml2_sso_url" in SETTING_METADATA
assert "social_auth_saml2_entity_id" in SETTING_METADATA
def test_telegram_settings_have_metadata(self):
"""Test Telegram settings are in SETTING_METADATA."""
from app.utils.settings_service import SETTING_METADATA
assert "telegram_enabled" in SETTING_METADATA
assert "telegram_bot_token" in SETTING_METADATA
assert "telegram_chat_id" in SETTING_METADATA
def test_sso_auto_login_has_metadata(self):
"""Test SSO auto login has metadata."""
from app.utils.settings_service import SETTING_METADATA
assert "sso_auto_login" in SETTING_METADATA
meta = SETTING_METADATA["sso_auto_login"]
assert meta["category"] == "Authentication"
assert meta["type"] == "boolean"
def test_github_category_is_social_login(self):
"""Test GitHub settings are in Social Login category."""
from app.utils.settings_service import SETTING_METADATA
assert SETTING_METADATA["social_auth_github_enabled"]["category"] == "Social Login"
def test_github_secret_is_sensitive(self):
"""Test GitHub client secret is marked sensitive."""
from app.utils.settings_service import SETTING_METADATA
assert SETTING_METADATA["social_auth_github_client_secret"]["sensitive"] is True
def test_github_has_help_link(self):
"""Test GitHub has a help link to developer settings."""
from app.utils.settings_service import SETTING_METADATA
assert "help_link" in SETTING_METADATA["social_auth_github_enabled"]
@pytest.mark.unit
class TestConnectionsPageRoute:
"""Tests for the /admin/connections route."""
@pytest.mark.asyncio
async def test_connections_page_non_admin_redirected(self):
"""Test that non-admin users are redirected from connections page."""
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": False}}
mock_db = MagicMock()
# The require_admin_access decorator should handle this, so we test the decorator
from app.views.settings import require_admin_access
@require_admin_access
async def dummy_view(request):
return "success"
result = await dummy_view(mock_request)
assert result.status_code == status.HTTP_302_FOUND
@pytest.mark.asyncio
async def test_connections_page_returns_services(self):
"""Test that connections page includes expected services in context."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
with (
patch("app.views.settings.get_all_settings_from_db", return_value={}),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
result = await connections_page(mock_request, db=mock_db)
# Check TemplateResponse was called
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
template_name = call_args[0][0]
context = call_args[0][1]
assert template_name == "admin_connections.html"
assert "services" in context
assert "service_settings" in context
assert "sso_auto_login" in context
# Verify expected service keys
service_keys = [s["key"] for s in context["services"]]
assert "google" in service_keys
assert "github" in service_keys
assert "keycloak" in service_keys
assert "generic_oauth2" in service_keys
assert "saml2" in service_keys
assert "smtp" in service_keys
assert "telegram" in service_keys
@pytest.mark.asyncio
async def test_connections_page_linked_status_from_db(self):
"""Linked status is derived from DB/effective settings, not SOCIAL_PROVIDERS."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# Simulate GitHub configured only in DB (not in SOCIAL_PROVIDERS yet)
db_values = {
"social_auth_github_enabled": "true",
"social_auth_github_client_id": "gh-id",
"social_auth_github_client_secret": "gh-secret",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
# GitHub should be linked because DB values say so
assert services_by_key["github"]["linked"] is True
@pytest.mark.asyncio
async def test_connections_page_unlinked_when_credentials_missing(self):
"""Provider is unlinked when enabled=true but credentials are absent."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
# enabled but no credentials
db_values = {"social_auth_github_enabled": "true"}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["github"]["linked"] is False
@pytest.mark.asyncio
async def test_connections_page_oidc_linked_from_db(self):
"""OIDC linked status derives from DB effective settings."""
from app.views.settings import connections_page
mock_request = MagicMock()
mock_request.session = {"user": {"is_admin": True}}
mock_db = MagicMock()
db_values = {
"authentik_client_id": "my-client-id",
"authentik_client_secret": "my-secret",
"oauth_provider_name": "My SSO",
}
with (
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
patch("app.views.settings.templates") as mock_templates,
patch("app.views.settings.SETTING_METADATA", {}),
patch("app.views.settings.get_setting_metadata", return_value={}),
):
mock_templates.TemplateResponse.return_value = "response"
await connections_page(mock_request, db=mock_db)
context = mock_templates.TemplateResponse.call_args[0][1]
services_by_key = {s["key"]: s for s in context["services"]}
assert services_by_key["oidc"]["linked"] is True
assert services_by_key["oidc"]["name"] == "My SSO"
# oauth_configured template var should also reflect the DB state
assert context["oauth_configured"] is True
@pytest.mark.unit
class TestRefreshSocialProviders:
"""Tests for the refresh_social_providers() mechanism."""
def test_refresh_social_providers_exists(self):
"""refresh_social_providers is importable from app.auth."""
from app.auth import refresh_social_providers
assert callable(refresh_social_providers)
def test_refresh_social_providers_clears_and_repopulates(self):
"""After refresh, SOCIAL_PROVIDERS reflects current settings."""
import app.auth as auth_module
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
mock_settings.social_auth_google_enabled = True
mock_settings.social_auth_google_client_id = "gid"
mock_settings.social_auth_google_client_secret = "gsecret"
mock_settings.social_auth_google_use_global_credentials = False
# All other providers disabled
for attr in (
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "google" in auth_module.SOCIAL_PROVIDERS
assert auth_module.OAUTH_CONFIGURED is False
def test_refresh_clears_previous_providers(self):
"""Providers removed from settings are cleared after refresh."""
import app.auth as auth_module
# Pre-populate with a stale entry
auth_module.SOCIAL_PROVIDERS["stale_provider"] = {"name": "Stale", "icon": "", "color": ""}
with (
patch.object(auth_module, "AUTH_ENABLED", True),
patch.object(auth_module, "settings") as mock_settings,
):
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
for attr in (
"social_auth_google_enabled",
"social_auth_microsoft_enabled",
"social_auth_apple_enabled",
"social_auth_dropbox_enabled",
"social_auth_github_enabled",
"social_auth_keycloak_enabled",
"social_auth_generic_oauth2_enabled",
):
setattr(mock_settings, attr, False)
with patch.object(auth_module, "_register_oauth_client"):
auth_module._setup_social_providers()
assert "stale_provider" not in auth_module.SOCIAL_PROVIDERS
def test_register_oauth_client_clears_cache(self):
"""_register_oauth_client removes the cached client before re-registering."""
import app.auth as auth_module
# Inject a fake cached client
auth_module.oauth._clients["test_provider"] = object()
with patch.object(auth_module.oauth, "register"):
auth_module._register_oauth_client("test_provider", client_id="x", client_secret="y")
assert "test_provider" not in auth_module.oauth._clients
@pytest.mark.unit
class TestTranslationKeys:
"""Tests for new translation keys."""
def test_connections_translation_keys_exist(self):
"""Test that connections translation keys are in en.json."""
import json
from pathlib import Path
en_path = Path(__file__).parents[1] / "frontend" / "translations" / "en.json"
translations = json.loads(en_path.read_text())
expected_keys = [
"connections.title",
"connections.description",
"connections.configure",
"connections.linked",
"connections.unlinked",
"connections.sso_auto_login",
"connections.sso_auto_login_title",
"connections.sso_auto_login_description",
"connections.mobile_upload_title",
"connections.qr_code_enabled",
"connections.unlinked_services",
"nav.connections",
]
for key in expected_keys:
assert key in translations, f"Missing translation key: {key}"
+7
View File
@@ -38,6 +38,11 @@ class TestConvertPdfToPdfa:
assert "pdfa-2" in cmd
assert "--quiet" in cmd
assert "--invalidate-digital-signatures" in cmd
# SECURITY: Verify `--` end-of-options separator is present and precedes
# the file paths to prevent option/argument injection.
assert "--" in cmd
assert cmd.index("--") < cmd.index("/input.pdf")
assert cmd.index("--") < cmd.index("/output.pdf")
assert "/input.pdf" in cmd
assert "/output.pdf" in cmd
@@ -72,6 +77,8 @@ class TestConvertPdfToPdfa:
_convert_pdf_to_pdfa("/input.pdf", "/output.pdf", fmt)
cmd = mock_run.call_args[0][0]
assert f"pdfa-{fmt}" in cmd
assert "--" in cmd
assert cmd.index("--") < cmd.index("/input.pdf")
def test_invalid_pdfa_format_rejected(self):
"""Test that invalid PDF/A format values are rejected."""
+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
@@ -520,14 +520,14 @@ class TestURLUploadAdditionalCoverage:
assert exc_info.value.status_code == 400
def test_is_private_ip_unresolvable_hostname(self):
"""Cover DNS resolution failure branch (lines 67-72)."""
"""Cover DNS resolution failure branch blocking unresolvable domains."""
import socket as _socket
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")
assert result is False
assert result is True # Fail securely by returning True
def test_is_private_ip_hostname_resolves_to_private(self):
"""Cover branch where hostname resolves to a private IP (line 64-65)."""
@@ -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"
+152 -4
View File
@@ -69,8 +69,9 @@ class TestViewsBase:
context = {"request": req}
template_response_with_version("template.html", context)
args, _ = mock_orig.call_args
assert args[1].get("csrf_token") == "my-csrf"
args, kwargs = mock_orig.call_args
context = kwargs.get("context", {})
assert context.get("csrf_token") == "my-csrf"
def test_kwargs_context_no_request(self):
"""Test kwargs context path when request is not in context."""
@@ -704,6 +705,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 +724,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 +891,83 @@ class TestSettingsSyncAdditional:
# Should not raise
handler_fn(sender=None)
def test_signal_handler_no_version_returned(self):
"""Test that handler does nothing if Redis returns None for version."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = None # Return None for version
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
handler_fn(sender=None)
mock_reload.assert_not_called()
def test_signal_handler_ocr_language_manager_exception(self):
"""Test that OCR language check exception inside handler is caught and logged."""
from app.utils.settings_sync import register_settings_reload_signal
handler_fn = None
def capture_connect(fn=None, weak=None, **kwargs):
nonlocal handler_fn
if fn is not None:
handler_fn = fn
return fn
def decorator(func):
nonlocal handler_fn
handler_fn = func
return func
return decorator
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = capture_connect
register_settings_reload_signal()
assert handler_fn is not None
mock_redis = MagicMock()
mock_redis.get.return_value = b"9999999.0" # New version
with patch("app.utils.settings_sync.redis") as mock_redis_mod:
mock_redis_mod.from_url.return_value = mock_redis
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
with patch("app.utils.settings_sync._last_seen_version", "111.0"):
with patch("app.utils.settings_sync.logger") as mock_logger:
with patch(
"app.utils.ocr_language_manager.ensure_ocr_languages_async",
side_effect=Exception("OCR failed"),
):
handler_fn(sender=None)
mock_reload.assert_called_once()
mock_logger.warning.assert_called_with(
"Could not schedule OCR language check on worker: OCR failed"
)
# ===========================================================================
# app/api/logs.py additional branches
@@ -665,6 +665,7 @@ def _all_should_upload_false():
"email",
"onedrive",
"s3",
"sharepoint",
"icloud",
]
return [patch(f"app.tasks.send_to_all._should_upload_to_{s}", return_value=False) for s in services]
@@ -694,6 +695,7 @@ class TestSendToAllCoverage:
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal") as mock_session_cls,
):
@@ -806,6 +808,7 @@ class TestSendToAllCoverage:
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal"),
):
@@ -866,6 +869,7 @@ class TestSendToAllCoverage:
patch("app.tasks.send_to_all._should_upload_to_email", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_onedrive", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_s3", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_sharepoint", return_value=False),
patch("app.tasks.send_to_all._should_upload_to_icloud", return_value=False),
patch("app.tasks.send_to_all.SessionLocal"),
):
+22
View File
@@ -337,6 +337,27 @@ class TestCSRFMiddlewareDispatch:
call_next.assert_called_once_with(request)
@pytest.mark.asyncio
async def test_qr_auth_claim_is_exempt(self):
"""QR auth claim path is exempt from CSRF validation.
The mobile app calls this endpoint without a browser session and
therefore without a CSRF token. The cryptographically-random,
single-use challenge token provides equivalent protection.
"""
middleware = self._make_middleware()
request = self._make_request(
method="POST",
path="/api/qr-auth/claim",
session={},
)
call_next = AsyncMock(return_value=MagicMock())
with patch.object(CSRFMiddleware, "_get_submitted_token", new=AsyncMock(return_value=None)):
result = await middleware.dispatch(request, call_next)
call_next.assert_called_once_with(request)
# ---------------------------------------------------------------------------
# Integration tests via TestClient
@@ -362,6 +383,7 @@ class TestCSRFIntegration:
assert "PATCH" in CSRF_PROTECTED_METHODS
assert "GET" not in CSRF_PROTECTED_METHODS
assert "/oauth-callback" in CSRF_EXEMPT_PATHS
assert "/api/qr-auth/claim" in CSRF_EXEMPT_PATHS
def test_csrf_middleware_noop_when_auth_disabled(self):
"""When AUTH_ENABLED=False the middleware dispatch is a no-op (no validation)."""
+4 -4
View File
@@ -55,8 +55,8 @@ class TestDarkModeTemplateInjection:
captured = {}
def fake_original(name, ctx, **kw):
captured.update(ctx)
def fake_original(request_obj, name, context=None, **kw):
captured.update(context or {})
with patch("app.views.base.original_template_response", side_effect=fake_original):
mock_request = MagicMock()
@@ -73,8 +73,8 @@ class TestDarkModeTemplateInjection:
captured = {}
def fake_original(name, ctx, **kw):
captured.update(ctx)
def fake_original(request_obj, name, context=None, **kw):
captured.update(context or {})
with patch("app.views.base.original_template_response", side_effect=fake_original):
mock_request = MagicMock()
+46
View File
@@ -998,3 +998,49 @@ class TestAlembicUpgrade:
# Verify head is reachable
heads = script.get_heads()
assert len(heads) == 1 # Should be a single linear chain
@pytest.mark.unit
class TestEnginePoolConfiguration:
"""Tests for database engine pool configuration (pool class and options)."""
def test_sqlite_engine_uses_null_pool(self):
"""SQLite engines must use NullPool to prevent QueuePool exhaustion."""
from sqlalchemy.pool import NullPool
from app.database import engine
# The test environment uses SQLite, so NullPool should be in effect.
assert isinstance(engine.pool, NullPool)
def test_create_engine_sqlite_null_pool(self):
"""Explicitly create a SQLite engine to confirm NullPool is applied."""
from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool
test_engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
assert isinstance(test_engine.pool, NullPool)
test_engine.dispose()
def test_pool_settings_exist_in_config(self):
"""Verify that pool tuning settings are exposed through config."""
from app.config import settings
assert hasattr(settings, "db_pool_size")
assert hasattr(settings, "db_max_overflow")
assert hasattr(settings, "db_pool_timeout")
assert hasattr(settings, "db_pool_recycle")
def test_pool_settings_have_sensible_defaults(self):
"""Default pool settings should be larger than SQLAlchemy's built-in defaults."""
from app.config import settings
# SQLAlchemy defaults: pool_size=5, max_overflow=10
assert settings.db_pool_size >= 10
assert settings.db_max_overflow >= 20
assert settings.db_pool_timeout >= 30
assert settings.db_pool_recycle >= 1800
+201
View File
@@ -0,0 +1,201 @@
"""Tests for the Devices page and mobile token filtering (app/api/api_tokens.py mobile endpoint).
These tests validate:
- ``GET /api/api-tokens/mobile`` returns only mobile tokens
- ``GET /api/api-tokens/`` excludes mobile tokens
- ``GET /devices`` renders the devices page
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import ApiToken
# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------
_OWNER = "devices_user@example.com"
_OTHER_OWNER = "other_devices@example.com"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def dev_engine():
"""In-memory SQLite engine."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def dev_session(dev_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=dev_engine)
session = Session()
yield session
session.close()
def _make_client(dev_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.api_tokens import _get_owner_id
from app.main import app
Session = sessionmaker(bind=dev_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _override_owner():
return owner_id
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _override_owner
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
return client
def _cleanup(app):
"""Remove dependency overrides after test."""
app.dependency_overrides.clear()
def _seed_tokens(session, owner_id: str = _OWNER):
"""Create a mix of regular and mobile tokens for testing."""
from app.api.api_tokens import generate_api_token, hash_token
tokens = []
# Regular API tokens
for name in ["CI Pipeline", "Webhook Upload"]:
pt = generate_api_token()
t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
session.add(t)
tokens.append(t)
# Mobile tokens (various naming patterns)
for name in [
"Mobile App iPhone 15 Pro",
"Mobile App (QR) Christian's iPad",
"Mobile App",
]:
pt = generate_api_token()
t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
session.add(t)
tokens.append(t)
session.commit()
return tokens
# ---------------------------------------------------------------------------
# Tests Mobile Token Filtering
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMobileTokenFiltering:
"""Tests for GET /api/api-tokens/mobile and filtering from GET /api/api-tokens/."""
def test_list_mobile_tokens_returns_only_mobile(self, dev_engine, dev_session):
"""GET /api/api-tokens/mobile should only return tokens starting with 'Mobile App'."""
_seed_tokens(dev_session)
client = _make_client(dev_engine)
try:
res = client.get("/api/api-tokens/mobile")
assert res.status_code == 200
data = res.json()
assert len(data) == 3
for t in data:
assert t["name"].startswith("Mobile App")
finally:
_cleanup(client.app)
def test_list_regular_tokens_excludes_mobile(self, dev_engine, dev_session):
"""GET /api/api-tokens/ should NOT return tokens starting with 'Mobile App'."""
_seed_tokens(dev_session)
client = _make_client(dev_engine)
try:
res = client.get("/api/api-tokens/")
assert res.status_code == 200
data = res.json()
assert len(data) == 2
for t in data:
assert not t["name"].startswith("Mobile App")
finally:
_cleanup(client.app)
def test_list_mobile_tokens_empty(self, dev_engine):
"""GET /api/api-tokens/mobile returns [] when no mobile tokens exist."""
client = _make_client(dev_engine)
try:
res = client.get("/api/api-tokens/mobile")
assert res.status_code == 200
assert res.json() == []
finally:
_cleanup(client.app)
def test_list_mobile_tokens_isolation(self, dev_engine, dev_session):
"""Mobile tokens for other users should not appear."""
_seed_tokens(dev_session, owner_id=_OTHER_OWNER)
client = _make_client(dev_engine, owner_id=_OWNER)
try:
res = client.get("/api/api-tokens/mobile")
assert res.status_code == 200
assert res.json() == []
finally:
_cleanup(client.app)
def test_mobile_token_revoke_via_api_tokens_endpoint(self, dev_engine, dev_session):
"""Mobile tokens can still be revoked via DELETE /api/api-tokens/{id}."""
tokens = _seed_tokens(dev_session)
mobile_token = next(t for t in tokens if t.name.startswith("Mobile App"))
client = _make_client(dev_engine)
try:
res = client.delete(f"/api/api-tokens/{mobile_token.id}")
assert res.status_code == 200
# Verify it's gone from mobile list
res2 = client.get("/api/api-tokens/mobile")
active_names = [t["name"] for t in res2.json() if t["is_active"]]
assert mobile_token.name not in active_names
finally:
_cleanup(client.app)
# ---------------------------------------------------------------------------
# Tests Devices Page View
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDevicesPageView:
"""Tests for GET /devices page rendering."""
def test_devices_page_renders(self, dev_engine):
"""GET /devices should return 200 with the devices template."""
from app.views.devices import router as _ # noqa: F401 ensures route is registered
client = _make_client(dev_engine)
try:
res = client.get("/devices")
assert res.status_code == 200
assert "devices.heading" in res.text or "Mobile Devices" in res.text
finally:
_cleanup(client.app)
+80
View File
@@ -5,6 +5,86 @@ from unittest.mock import MagicMock, patch
import pytest
@pytest.mark.unit
class TestLivenessProbe:
"""Tests for GET /api/diagnostic/healthz/live (unauthenticated)."""
def test_liveness_returns_200(self, client):
"""Liveness probe always returns 200 OK."""
response = client.get("/api/diagnostic/healthz/live")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
@pytest.mark.unit
class TestReadinessProbe:
"""Tests for GET /api/diagnostic/healthz/ready (unauthenticated)."""
def test_readiness_returns_200_when_all_ok(self, client):
"""Readiness probe returns 200 when database and Redis are reachable."""
with (
patch("app.api.diagnostic.engine") as mock_engine,
patch("app.api.diagnostic.redis_lib") as mock_redis,
):
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
mock_redis_inst = MagicMock()
mock_redis.from_url.return_value = mock_redis_inst
response = client.get("/api/diagnostic/healthz/ready")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ready"
assert data["checks"]["database"]["status"] == "ok"
def test_readiness_returns_503_when_database_fails(self, client):
"""Readiness probe returns 503 when database is unreachable."""
with (
patch("app.api.diagnostic.engine") as mock_engine,
patch("app.api.diagnostic.redis_lib") as mock_redis,
):
mock_engine.connect.side_effect = Exception("DB unavailable")
mock_redis_inst = MagicMock()
mock_redis.from_url.return_value = mock_redis_inst
response = client.get("/api/diagnostic/healthz/ready")
assert response.status_code == 503
data = response.json()
assert data["status"] == "not_ready"
assert data["checks"]["database"]["status"] == "error"
def test_readiness_returns_200_when_redis_fails(self, client):
"""Readiness remains 200 when only Redis is down (non-critical)."""
with (
patch("app.api.diagnostic.engine") as mock_engine,
patch("app.api.diagnostic.redis_lib") as mock_redis,
):
mock_conn = MagicMock()
mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
mock_redis.from_url.return_value = MagicMock()
mock_redis.from_url.return_value.ping.side_effect = Exception("Connection refused")
response = client.get("/api/diagnostic/healthz/ready")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ready"
assert data["checks"]["redis"]["status"] == "error"
def test_readiness_contains_checks_keys(self, client):
"""Readiness response always contains database and redis checks."""
response = client.get("/api/diagnostic/healthz/ready")
data = response.json()
assert "checks" in data
assert "database" in data["checks"]
assert "redis" in data["checks"]
@pytest.mark.unit
class TestHealthEndpoint:
"""Tests for GET /api/diagnostic/health endpoint."""
+18 -18
View File
@@ -57,7 +57,7 @@ class TestFileViewPdfJs:
pdf.write_bytes(b"%PDF-1.4 test")
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
response = client.get(f"/files/{rec.id}")
response = client.get(f"/files/{rec.id}/detail")
assert response.status_code == 200
html = response.text
@@ -80,7 +80,7 @@ class TestFileViewPdfJs:
pdf.write_bytes(b"%PDF-1.4 test")
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
response = client.get(f"/files/{rec.id}")
response = client.get(f"/files/{rec.id}/detail")
html = response.text
# The preview section should use pdf-viewer, not iframe
@@ -93,7 +93,7 @@ class TestFileViewPdfJs:
pdf.write_bytes(b"%PDF-1.4 test")
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
response = client.get(f"/files/{rec.id}")
response = client.get(f"/files/{rec.id}/detail")
html = response.text
assert 'id="pdf-prev-btn"' in html
@@ -116,7 +116,7 @@ class TestFileViewImagePreview:
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # minimal JPEG header
rec = _create_file_record(db_session, filename="photo.jpg", mime_type="image/jpeg", file_path=str(img))
response = client.get(f"/files/{rec.id}")
response = client.get(f"/files/{rec.id}/detail")
assert response.status_code == 200
html = response.text
@@ -132,7 +132,7 @@ class TestFileViewImagePreview:
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
rec = _create_file_record(db_session, filename="photo.png", mime_type="image/png", file_path=str(img))
response = client.get(f"/files/{rec.id}")
response = client.get(f"/files/{rec.id}/detail")
html = response.text
assert 'aria-label="Zoom in"' in html
@@ -146,7 +146,7 @@ class TestFileViewImagePreview:
img.write_bytes(b"RIFF" + b"\x00" * 50)
rec = _create_file_record(db_session, filename="wide.webp", mime_type="image/webp", file_path=str(img))
response = client.get(f"/files/{rec.id}")
response = client.get(f"/files/{rec.id}/detail")
html = response.text
# Pan support is implemented via JavaScript on img-wrap
@@ -170,7 +170,7 @@ class TestFileViewTextPreview:
txt.write_text("Hello world\nSecond line\n")
rec = _create_file_record(db_session, filename="readme.txt", mime_type="text/plain", file_path=str(txt))
response = client.get(f"/files/{rec.id}")
response = client.get(f"/files/{rec.id}/detail")
assert response.status_code == 200
html = response.text
@@ -184,7 +184,7 @@ class TestFileViewTextPreview:
txt.write_text("print('hello')\n")
rec = _create_file_record(db_session, filename="code.py", mime_type="text/x-python", file_path=str(txt))
response = client.get(f"/files/{rec.id}")
response = client.get(f"/files/{rec.id}/detail")
html = response.text
assert "copyTextPreview" in html
@@ -196,7 +196,7 @@ class TestFileViewTextPreview:
txt.write_text("a,b,c\n1,2,3\n")
rec = _create_file_record(db_session, filename="data.csv", mime_type="text/csv", file_path=str(txt))
response = client.get(f"/files/{rec.id}")
response = client.get(f"/files/{rec.id}/detail")
html = response.text
# JS builds line-number spans
@@ -218,7 +218,7 @@ class TestFileViewPreviewIcon:
pdf.write_bytes(b"%PDF-1.4")
rec = _create_file_record(db_session, file_path=str(pdf), mime_type="application/pdf")
html = client.get(f"/files/{rec.id}").text
html = client.get(f"/files/{rec.id}/detail").text
assert "fa-file-pdf" in html
def test_image_icon(self, client: TestClient, db_session, tmp_path):
@@ -227,7 +227,7 @@ class TestFileViewPreviewIcon:
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 10)
rec = _create_file_record(db_session, filename="p.jpg", mime_type="image/jpeg", file_path=str(img))
html = client.get(f"/files/{rec.id}").text
html = client.get(f"/files/{rec.id}/detail").text
assert "fa-image" in html
def test_text_icon(self, client: TestClient, db_session, tmp_path):
@@ -236,7 +236,7 @@ class TestFileViewPreviewIcon:
txt.write_text("hello")
rec = _create_file_record(db_session, filename="t.txt", mime_type="text/plain", file_path=str(txt))
html = client.get(f"/files/{rec.id}").text
html = client.get(f"/files/{rec.id}/detail").text
assert "fa-file-code" in html
@@ -321,7 +321,7 @@ class TestFileDetailBottomPreview:
pdf.write_bytes(b"%PDF-1.4")
rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf))
response = client.get(f"/files/{rec.id}/detail")
response = client.get(f"/files/{rec.id}/process")
assert response.status_code == 200
html = response.text
@@ -335,7 +335,7 @@ class TestFileDetailBottomPreview:
pdf.write_bytes(b"%PDF-1.4")
rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf))
response = client.get(f"/files/{rec.id}/detail")
response = client.get(f"/files/{rec.id}/process")
html = response.text
assert f"/api/files/{rec.id}/download" in html
@@ -350,7 +350,7 @@ class TestFileDetailBottomPreview:
file_path=str(img),
)
response = client.get(f"/files/{rec.id}/detail")
response = client.get(f"/files/{rec.id}/process")
html = response.text
assert f"/api/files/{rec.id}/preview?version=original" in html
@@ -372,7 +372,7 @@ class TestFileViewOcrText:
rec.ocr_text = "Sample extracted OCR text content"
db_session.commit()
html = client.get(f"/files/{rec.id}").text
html = client.get(f"/files/{rec.id}/detail").text
assert "toggleOcrText" in html
assert "ocr-text-block" in html
assert "Sample extracted OCR text content" in html
@@ -383,7 +383,7 @@ class TestFileViewOcrText:
pdf.write_bytes(b"%PDF-1.4")
rec = _create_file_record(db_session, file_path=str(pdf))
html = client.get(f"/files/{rec.id}").text
html = client.get(f"/files/{rec.id}/detail").text
assert "loadText" in html or "Extract" in html
@@ -409,5 +409,5 @@ class TestFileViewNoFile:
db_session.commit()
db_session.refresh(rec)
html = client.get(f"/files/{rec.id}").text
html = client.get(f"/files/{rec.id}/detail").text
assert "No file available for preview" in html
+62 -26
View File
@@ -3,11 +3,12 @@
Covers:
- ``GET /api/duplicates`` — list all exact-duplicate groups
- ``GET /api/files/{id}/duplicates`` — per-file exact + near-duplicate info
- ``POST /api/ui-upload`` — exact-duplicate warning in upload response
- ``POST /api/ui-upload`` — exact-duplicate rejection at upload time
- ``GET /duplicates`` — duplicate management UI page
"""
import json
import os
from unittest.mock import patch
import pytest
@@ -283,17 +284,25 @@ class TestGetFileDuplicates:
# ---------------------------------------------------------------------------
# POST /api/ui-upload — exact-duplicate warning
# POST /api/ui-upload — exact-duplicate rejection
# ---------------------------------------------------------------------------
class TestUploadDuplicateWarning:
"""Tests for duplicate warning injected into the upload response."""
class TestUploadDuplicateRejection:
"""Tests for duplicate rejection at upload time.
When ``ENABLE_DEDUPLICATION`` is ``True`` (the default) and the uploaded
file's SHA-256 hash matches an already-processed document, the upload
endpoint must:
- return ``status: "duplicate"`` instead of ``"queued"``
- **not** enqueue a Celery task
- clean up the temporary file from disk
"""
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_no_warning_for_unique_file(self, mock_delay, client: TestClient, tmp_path):
"""Uploading a unique file should not produce a duplicate_warning."""
"""Uploading a unique file should not produce a duplicate response."""
mock_delay.return_value.id = "task-unique"
pdf = tmp_path / "unique.pdf"
pdf.write_bytes(b"%PDF-1.4\n%%EOF")
@@ -306,14 +315,12 @@ class TestUploadDuplicateWarning:
assert response.status_code == 200
data = response.json()
assert "duplicate_warning" not in data or data.get("duplicate_warning") is None
assert data["status"] == "queued"
assert "duplicate_of" not in data
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_warning_for_exact_duplicate(self, mock_delay, client: TestClient, db_session, tmp_path):
"""Uploading a file with the same hash as an existing record returns a warning."""
mock_delay.return_value.id = "task-dup"
def test_exact_duplicate_rejected(self, client: TestClient, db_session, tmp_path):
"""Uploading a file with the same hash as an existing record is rejected."""
# Create a real PDF with known content
pdf_bytes = b"%PDF-1.4\nsome unique content for test\n%%EOF"
pdf = tmp_path / "existing.pdf"
@@ -335,16 +342,14 @@ class TestUploadDuplicateWarning:
assert response.status_code == 200
data = response.json()
assert "duplicate_warning" in data
assert data["duplicate_warning"]["duplicate_type"] == "exact"
assert data["duplicate_warning"]["original_file_id"] == existing.id
assert data["status"] == "duplicate"
assert "duplicate_of" in data
assert data["duplicate_of"]["duplicate_type"] == "exact"
assert data["duplicate_of"]["original_file_id"] == existing.id
@pytest.mark.integration
@patch("app.tasks.process_document.process_document.delay")
def test_upload_still_queued_despite_warning(self, mock_delay, client: TestClient, db_session, tmp_path):
"""Even when a duplicate is detected, the file should still be queued."""
mock_delay.return_value.id = "task-still-queued"
def test_duplicate_not_enqueued(self, client: TestClient, db_session, tmp_path):
"""When a duplicate is detected, no Celery task should be created."""
pdf_bytes = b"%PDF-1.4\nqueue test content\n%%EOF"
pdf = tmp_path / "queue_test.pdf"
pdf.write_bytes(pdf_bytes)
@@ -354,16 +359,47 @@ class TestUploadDuplicateWarning:
filehash = hash_file(str(pdf))
_make_file(db_session, filehash=filehash, filename="queue_orig.pdf")
with open(pdf, "rb") as f:
response = client.post(
"/api/ui-upload",
files={"file": ("queue_test.pdf", f, "application/pdf")},
)
with patch("app.tasks.process_document.process_document.delay") as mock_delay:
with open(pdf, "rb") as f:
response = client.post(
"/api/ui-upload",
files={"file": ("queue_test.pdf", f, "application/pdf")},
)
assert response.status_code == 200
data = response.json()
assert "task_id" in data
assert data["status"] == "queued"
assert data["status"] == "duplicate"
assert "task_id" not in data
mock_delay.assert_not_called()
@pytest.mark.integration
def test_duplicate_temp_file_cleaned_up(self, client: TestClient, db_session, tmp_path):
"""The temporary file saved to disk should be removed for a duplicate."""
pdf_bytes = b"%PDF-1.4\ncleanup test content\n%%EOF"
pdf = tmp_path / "cleanup_test.pdf"
pdf.write_bytes(pdf_bytes)
from app.utils.file_operations import hash_file
filehash = hash_file(str(pdf))
_make_file(db_session, filehash=filehash, filename="cleanup_orig.pdf")
with patch("app.tasks.process_document.process_document.delay"):
with open(pdf, "rb") as f:
response = client.post(
"/api/ui-upload",
files={"file": ("cleanup_test.pdf", f, "application/pdf")},
)
assert response.status_code == 200
data = response.json()
# The stored_filename is returned so we can verify cleanup
stored = data.get("stored_filename")
assert stored is not None
from app.config import settings
assert not os.path.exists(os.path.join(settings.workdir, stored))
# ---------------------------------------------------------------------------
+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:
+3 -3
View File
@@ -447,7 +447,7 @@ class TestFileDetailView:
db_session.commit()
# Test detail view
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
# Check that response contains HTML with file information
assert b"File Information" in response.content
@@ -504,7 +504,7 @@ class TestFileDetailView:
db_session.commit()
# Test detail view
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
# Check that response contains branching visualization elements
assert b"Process Flow Visualization" in response.content
@@ -514,7 +514,7 @@ class TestFileDetailView:
def test_file_detail_view_nonexistent(self, client: TestClient):
"""Test file detail view for nonexistent file."""
response = client.get("/files/99999/detail")
response = client.get("/files/99999/process")
assert response.status_code == 200 # Returns page with error message
assert b"not found" in response.content.lower()
+3 -3
View File
@@ -50,7 +50,7 @@ def test_file_detail_page_with_metadata(client: TestClient, db_session, sample_p
db_session.refresh(file_record)
# Get detail page
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
html = response.text
@@ -85,7 +85,7 @@ def test_file_detail_with_gpt_metadata(client: TestClient, db_session, sample_pd
db_session.refresh(file_record)
# Get detail page
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
html = response.text
@@ -190,7 +190,7 @@ def test_file_detail_shows_file_status_indicators(client: TestClient, db_session
db_session.commit()
db_session.refresh(file_record)
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
html = response.text
+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(
+4 -4
View File
@@ -142,7 +142,7 @@ class TestFileDetailPage:
db_session.commit()
# Test file detail page
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
content = response.text
assert "test.pdf" in content
@@ -150,7 +150,7 @@ class TestFileDetailPage:
def test_file_detail_page_with_missing_file(self, client: TestClient, db_session):
"""Test file detail page with non-existent file"""
# Try to access non-existent file
response = client.get("/files/99999/detail")
response = client.get("/files/99999/process")
assert response.status_code == 200
content = response.text
assert "not found" in content.lower()
@@ -193,7 +193,7 @@ class TestFileDetailPage:
db_session.commit()
# Test file detail page
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
content = response.text
assert "create_file_record" in content
@@ -232,7 +232,7 @@ class TestFileDetailPage:
db_session.commit()
# Test file detail page
response = client.get(f"/files/{file_record.id}/detail")
response = client.get(f"/files/{file_record.id}/process")
assert response.status_code == 200
content = response.text
# Should show metadata
+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()
+145
View File
@@ -0,0 +1,145 @@
"""Tests for frontend build configuration and Docker build consistency.
Validates that the frontend build toolchain (Tailwind CSS) is correctly
configured in package.json and that the Dockerfile installs all required
dependencies for the build step.
"""
import json
import re
from pathlib import Path
import pytest
# Resolve the project root from the test file location
PROJECT_ROOT = Path(__file__).resolve().parent.parent
FRONTEND_DIR = PROJECT_ROOT / "frontend"
DOCKERFILE_PATH = PROJECT_ROOT / "Dockerfile"
@pytest.mark.unit
class TestFrontendPackageJson:
"""Validate frontend/package.json structure and scripts."""
def test_package_json_exists(self) -> None:
"""package.json must exist in the frontend directory."""
pkg_path = FRONTEND_DIR / "package.json"
assert pkg_path.exists(), "frontend/package.json not found"
def test_package_json_is_valid_json(self) -> None:
"""package.json must be parseable JSON."""
pkg_path = FRONTEND_DIR / "package.json"
data = json.loads(pkg_path.read_text(encoding="utf-8"))
assert isinstance(data, dict), "package.json must be a JSON object"
def test_build_script_defined(self) -> None:
"""A 'build' script must be defined in package.json."""
pkg_path = FRONTEND_DIR / "package.json"
data = json.loads(pkg_path.read_text(encoding="utf-8"))
scripts = data.get("scripts", {})
assert "build" in scripts, "Missing 'build' script in package.json"
def test_build_script_uses_tailwindcss(self) -> None:
"""The build script must invoke the tailwindcss CLI."""
pkg_path = FRONTEND_DIR / "package.json"
data = json.loads(pkg_path.read_text(encoding="utf-8"))
build_cmd = data["scripts"]["build"]
assert "tailwindcss" in build_cmd, f"Build script does not reference tailwindcss: {build_cmd}"
def test_tailwindcss_listed_as_dependency(self) -> None:
"""tailwindcss must be listed in dependencies or devDependencies."""
pkg_path = FRONTEND_DIR / "package.json"
data = json.loads(pkg_path.read_text(encoding="utf-8"))
deps = data.get("dependencies", {})
dev_deps = data.get("devDependencies", {})
all_deps = {**deps, **dev_deps}
assert "tailwindcss" in all_deps, "tailwindcss is not listed in dependencies or devDependencies"
@pytest.mark.unit
class TestFrontendBuildAssets:
"""Validate that required frontend build source files exist."""
def test_input_css_exists(self) -> None:
"""The Tailwind CSS input file must exist."""
input_css = FRONTEND_DIR / "input.css"
assert input_css.exists(), "frontend/input.css not found"
def test_input_css_has_tailwind_directives(self) -> None:
"""input.css must include Tailwind CSS directives."""
input_css = FRONTEND_DIR / "input.css"
content = input_css.read_text(encoding="utf-8")
assert "@tailwind base" in content, "Missing @tailwind base directive"
assert "@tailwind components" in content, "Missing @tailwind components directive"
assert "@tailwind utilities" in content, "Missing @tailwind utilities directive"
def test_tailwind_config_exists(self) -> None:
"""tailwind.config.js must exist in the frontend directory."""
config_path = FRONTEND_DIR / "tailwind.config.js"
assert config_path.exists(), "frontend/tailwind.config.js not found"
def test_package_lock_exists(self) -> None:
"""package-lock.json must exist for reproducible installs."""
lock_path = FRONTEND_DIR / "package-lock.json"
assert lock_path.exists(), "frontend/package-lock.json not found"
@pytest.mark.unit
class TestDockerfileFrontendBuilder:
"""Validate the Dockerfile frontend-builder stage installs build dependencies."""
def test_dockerfile_exists(self) -> None:
"""Production Dockerfile must exist at the project root."""
assert DOCKERFILE_PATH.exists(), "Dockerfile not found at project root"
def test_dockerfile_has_frontend_builder_stage(self) -> None:
"""Dockerfile must define a frontend-builder stage."""
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
assert "AS frontend-builder" in content, "Dockerfile does not define a frontend-builder stage"
def test_dockerfile_npm_ci_does_not_omit_dev(self) -> None:
"""npm ci must NOT use --omit=dev in the frontend-builder stage.
The tailwindcss CLI is a devDependency required at build time.
Using --omit=dev would skip installing it, causing the build to
fail with 'tailwindcss: not found'.
"""
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
# Extract the frontend-builder stage content
# Look for the stage start and the next stage (or end of file)
stage_pattern = re.compile(
r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)",
re.DOTALL,
)
match = stage_pattern.search(content)
assert match is not None, "Could not find frontend-builder stage in Dockerfile"
stage_content = match.group(1)
assert "--omit=dev" not in stage_content, (
"Dockerfile frontend-builder stage uses 'npm ci --omit=dev' which "
"excludes tailwindcss (a devDependency) needed for the build step. "
"Use 'npm ci' instead to install all dependencies."
)
def test_dockerfile_runs_npm_build(self) -> None:
"""Dockerfile frontend-builder stage must run npm run build."""
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
stage_pattern = re.compile(
r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)",
re.DOTALL,
)
match = stage_pattern.search(content)
assert match is not None, "Could not find frontend-builder stage in Dockerfile"
stage_content = match.group(1)
assert "npm run build" in stage_content, "Dockerfile frontend-builder stage does not run 'npm run build'"
def test_dockerfile_copies_compiled_css(self) -> None:
"""Dockerfile must copy the compiled styles.css from the frontend-builder stage."""
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
assert "COPY --from=frontend-builder" in content, (
"Dockerfile does not copy assets from the frontend-builder stage"
)
assert "styles.css" in content, "Dockerfile does not reference the compiled styles.css"
+458
View File
@@ -1,7 +1,13 @@
"""Tests for app/api/imap_profiles.py and app/utils/allowed_types category helpers."""
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 ImapIngestionProfile
from app.utils.allowed_types import (
ALL_CATEGORIES,
DEFAULT_CATEGORIES,
@@ -9,6 +15,104 @@ from app.utils.allowed_types import (
get_allowed_types_for_categories,
)
# ---------------------------------------------------------------------------
# Integration test constants
# ---------------------------------------------------------------------------
_OWNER = "profile_user@example.com"
_OTHER = "other_user@example.com"
# ---------------------------------------------------------------------------
# Shared integration fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def profile_engine():
"""In-memory SQLite engine for IMAP profile 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 profile_session(profile_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=profile_engine)
session = Session()
yield session
session.close()
@pytest.fixture()
def profile_client(profile_engine):
"""TestClient authenticated as _OWNER with DB overridden."""
from app.api.imap_profiles import _get_owner_id
from app.main import app
def override_db():
Session = sessionmaker(bind=profile_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_owner():
return _OWNER
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = override_owner
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
yield c
app.dependency_overrides.clear()
@pytest.fixture()
def anon_client(profile_engine):
"""TestClient without authentication (DB still overridden)."""
from app.main import app
def override_db():
Session = sessionmaker(bind=profile_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.clear()
def _make_profile(
session,
owner: str | None = _OWNER,
name: str = "Test Profile",
categories_json: str = '["pdf","office"]',
is_builtin: bool = False,
) -> ImapIngestionProfile:
"""Create an ImapIngestionProfile row in the database."""
prof = ImapIngestionProfile(
name=name,
description="A test profile",
owner_id=owner,
allowed_categories=categories_json,
is_builtin=is_builtin,
)
session.add(prof)
session.commit()
session.refresh(prof)
return prof
@pytest.mark.unit
class TestFileTypeCategories:
@@ -156,3 +260,357 @@ class TestImapProfilesApiLogic:
result = _to_response(profile)
assert result["allowed_categories"] == []
def test_get_owner_id_returns_owner_when_authenticated(self):
"""Test that _get_owner_id returns the owner_id when get_current_owner_id succeeds."""
from unittest.mock import MagicMock, patch
from app.api.imap_profiles import _get_owner_id
request = MagicMock()
with patch("app.api.imap_profiles.get_current_owner_id", return_value="user@example.com"):
result = _get_owner_id(request)
assert result == "user@example.com"
# ---------------------------------------------------------------------------
# Integration tests list categories endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestListCategories:
"""Tests for GET /api/imap-profiles/categories."""
def test_list_categories_returns_all(self, profile_client):
"""Authenticated request returns all available file-type categories."""
resp = profile_client.get("/api/imap-profiles/categories")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
keys = {item["key"] for item in data}
assert keys == set(FILE_TYPE_CATEGORIES.keys())
for item in data:
assert "key" in item
assert "label" in item
assert "description" in item
def test_list_categories_unauthenticated(self, anon_client):
"""Unauthenticated request returns 401."""
resp = anon_client.get("/api/imap-profiles/categories")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Integration tests list profiles endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestListProfiles:
"""Tests for GET /api/imap-profiles/."""
def test_list_empty(self, profile_client, profile_session):
"""Listing profiles when none exist returns an empty list."""
resp = profile_client.get("/api/imap-profiles/")
assert resp.status_code == 200
assert resp.json() == []
def test_list_includes_own_profiles(self, profile_client, profile_session):
"""Returns profiles owned by the current user."""
_make_profile(profile_session, owner=_OWNER, name="My Profile")
resp = profile_client.get("/api/imap-profiles/")
assert resp.status_code == 200
data = resp.json()
assert any(p["name"] == "My Profile" for p in data)
def test_list_includes_global_profiles(self, profile_client, profile_session):
"""Returns system-global profiles (owner_id=None)."""
_make_profile(profile_session, owner=None, name="Global Profile", is_builtin=True)
resp = profile_client.get("/api/imap-profiles/")
assert resp.status_code == 200
data = resp.json()
assert any(p["name"] == "Global Profile" for p in data)
def test_list_excludes_other_users_profiles(self, profile_client, profile_session):
"""Profiles owned by other users are not returned."""
_make_profile(profile_session, owner=_OTHER, name="Other Profile")
resp = profile_client.get("/api/imap-profiles/")
assert resp.status_code == 200
data = resp.json()
assert not any(p["name"] == "Other Profile" for p in data)
def test_list_unauthenticated(self, anon_client):
"""Unauthenticated request returns 401."""
resp = anon_client.get("/api/imap-profiles/")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Integration tests create profile endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestCreateProfile:
"""Tests for POST /api/imap-profiles/."""
def test_create_success(self, profile_client, profile_session):
"""Creating a valid profile returns 201 with the new profile data."""
payload = {"name": "New Profile", "description": "desc", "allowed_categories": ["pdf", "office"]}
resp = profile_client.post("/api/imap-profiles/", json=payload)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "New Profile"
assert data["description"] == "desc"
assert data["allowed_categories"] == ["pdf", "office"]
assert data["is_builtin"] is False
assert data["owner_id"] == _OWNER
assert "id" in data
def test_create_deduplicates_categories(self, profile_client):
"""Duplicate categories in the request are de-duplicated."""
payload = {"name": "Dedup Profile", "allowed_categories": ["pdf", "pdf", "office"]}
resp = profile_client.post("/api/imap-profiles/", json=payload)
assert resp.status_code == 201
assert resp.json()["allowed_categories"] == ["pdf", "office"]
def test_create_invalid_category_returns_422(self, profile_client):
"""Unknown category keys cause a 422 response."""
payload = {"name": "Bad Profile", "allowed_categories": ["pdf", "nonexistent"]}
resp = profile_client.post("/api/imap-profiles/", json=payload)
assert resp.status_code == 422
def test_create_missing_name_returns_422(self, profile_client):
"""Missing required 'name' field causes a 422 response."""
payload = {"allowed_categories": ["pdf"]}
resp = profile_client.post("/api/imap-profiles/", json=payload)
assert resp.status_code == 422
def test_create_empty_categories_returns_422(self, profile_client):
"""An empty allowed_categories list causes a 422 response."""
payload = {"name": "Empty Cats", "allowed_categories": []}
resp = profile_client.post("/api/imap-profiles/", json=payload)
assert resp.status_code == 422
def test_create_unauthenticated(self, anon_client):
"""Unauthenticated request returns 401."""
payload = {"name": "X", "allowed_categories": ["pdf"]}
resp = anon_client.post("/api/imap-profiles/", json=payload)
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Integration tests get single profile endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestGetProfile:
"""Tests for GET /api/imap-profiles/{id}."""
def test_get_own_profile(self, profile_client, profile_session):
"""Owner can retrieve their own profile."""
prof = _make_profile(profile_session, owner=_OWNER)
resp = profile_client.get(f"/api/imap-profiles/{prof.id}")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == prof.id
assert data["name"] == prof.name
def test_get_global_profile(self, profile_client, profile_session):
"""Any authenticated user can retrieve a global (owner_id=None) profile."""
prof = _make_profile(profile_session, owner=None, name="Builtin", is_builtin=True)
resp = profile_client.get(f"/api/imap-profiles/{prof.id}")
assert resp.status_code == 200
assert resp.json()["name"] == "Builtin"
def test_get_not_found(self, profile_client):
"""Requesting a non-existent profile returns 404."""
resp = profile_client.get("/api/imap-profiles/99999")
assert resp.status_code == 404
def test_get_other_user_profile_returns_404(self, profile_client, profile_session):
"""Accessing another user's private profile returns 404."""
prof = _make_profile(profile_session, owner=_OTHER, name="Private")
resp = profile_client.get(f"/api/imap-profiles/{prof.id}")
assert resp.status_code == 404
def test_get_unauthenticated(self, anon_client, profile_session):
"""Unauthenticated request returns 401."""
prof = _make_profile(profile_session, owner=None, is_builtin=True)
resp = anon_client.get(f"/api/imap-profiles/{prof.id}")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Integration tests update profile endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestUpdateProfile:
"""Tests for PUT /api/imap-profiles/{id}."""
def test_update_name(self, profile_client, profile_session):
"""Updating the name of an owned profile returns the updated profile."""
prof = _make_profile(profile_session, owner=_OWNER, name="Old Name")
resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"name": "New Name"})
assert resp.status_code == 200
assert resp.json()["name"] == "New Name"
def test_update_categories(self, profile_client, profile_session):
"""Updating allowed_categories replaces the previous value."""
prof = _make_profile(profile_session, owner=_OWNER, categories_json='["pdf"]')
resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"allowed_categories": ["office", "text"]})
assert resp.status_code == 200
assert resp.json()["allowed_categories"] == ["office", "text"]
def test_update_description(self, profile_client, profile_session):
"""Setting description via model_fields_set path updates it."""
prof = _make_profile(profile_session, owner=_OWNER)
resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"description": "Updated desc"})
assert resp.status_code == 200
assert resp.json()["description"] == "Updated desc"
def test_update_builtin_returns_403(self, profile_client, profile_session):
"""Attempting to update a built-in profile returns 403."""
prof = _make_profile(profile_session, owner=None, is_builtin=True)
resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"name": "Renamed"})
assert resp.status_code == 403
def test_update_not_found(self, profile_client):
"""Updating a non-existent profile returns 404."""
resp = profile_client.put("/api/imap-profiles/99999", json={"name": "X"})
assert resp.status_code == 404
def test_update_other_user_profile_returns_404(self, profile_client, profile_session):
"""Updating another user's profile returns 404."""
prof = _make_profile(profile_session, owner=_OTHER)
resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"name": "X"})
assert resp.status_code == 404
def test_update_invalid_category_returns_422(self, profile_client, profile_session):
"""Updating with an invalid category key returns 422."""
prof = _make_profile(profile_session, owner=_OWNER)
resp = profile_client.put(f"/api/imap-profiles/{prof.id}", json={"allowed_categories": ["badcat"]})
assert resp.status_code == 422
def test_update_unauthenticated(self, anon_client, profile_session):
"""Unauthenticated request returns 401."""
prof = _make_profile(profile_session, owner=_OWNER)
resp = anon_client.put(f"/api/imap-profiles/{prof.id}", json={"name": "X"})
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Integration tests delete profile endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteProfile:
"""Tests for DELETE /api/imap-profiles/{id}."""
def test_delete_success(self, profile_client, profile_session):
"""Deleting an owned profile returns 204 and removes the row."""
prof = _make_profile(profile_session, owner=_OWNER)
resp = profile_client.delete(f"/api/imap-profiles/{prof.id}")
assert resp.status_code == 204
# Verify it is gone
get_resp = profile_client.get(f"/api/imap-profiles/{prof.id}")
assert get_resp.status_code == 404
def test_delete_builtin_returns_403(self, profile_client, profile_session):
"""Attempting to delete a built-in profile returns 403."""
prof = _make_profile(profile_session, owner=None, is_builtin=True)
resp = profile_client.delete(f"/api/imap-profiles/{prof.id}")
assert resp.status_code == 403
def test_delete_not_found(self, profile_client):
"""Deleting a non-existent profile returns 404."""
resp = profile_client.delete("/api/imap-profiles/99999")
assert resp.status_code == 404
def test_delete_other_user_profile_returns_404(self, profile_client, profile_session):
"""Deleting another user's private profile returns 404."""
prof = _make_profile(profile_session, owner=_OTHER)
resp = profile_client.delete(f"/api/imap-profiles/{prof.id}")
assert resp.status_code == 404
def test_delete_unauthenticated(self, anon_client, profile_session):
"""Unauthenticated request returns 401."""
prof = _make_profile(profile_session, owner=_OWNER)
resp = anon_client.delete(f"/api/imap-profiles/{prof.id}")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Integration tests DB error rollback paths
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDbErrorRollback:
"""Tests for exception-handling / rollback paths in create, update, and delete."""
def _make_failing_client(self, profile_engine, profile_session, *, fail_on: str = "commit"):
"""Return a TestClient whose DB session raises RuntimeError on commit/delete."""
from app.api.imap_profiles import _get_owner_id
from app.main import app
Session = sessionmaker(bind=profile_engine)
def override_db():
session = Session()
def raise_error(*args, **kwargs):
raise RuntimeError("Simulated DB failure")
setattr(session, fail_on, raise_error)
try:
yield session
finally:
session.close()
def override_owner():
return _OWNER
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = override_owner
return TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
def test_create_db_error_returns_500(self, profile_engine, profile_session):
"""A DB error during create triggers rollback and returns 500."""
client = self._make_failing_client(profile_engine, profile_session)
try:
resp = client.post("/api/imap-profiles/", json={"name": "X", "allowed_categories": ["pdf"]})
finally:
from app.main import app
app.dependency_overrides.clear()
assert resp.status_code == 500
def test_update_db_error_returns_500(self, profile_engine, profile_session):
"""A DB error during update triggers rollback and returns 500."""
prof = _make_profile(profile_session, owner=_OWNER)
client = self._make_failing_client(profile_engine, profile_session)
try:
resp = client.put(f"/api/imap-profiles/{prof.id}", json={"name": "New"})
finally:
from app.main import app
app.dependency_overrides.clear()
assert resp.status_code == 500
def test_delete_db_error_returns_500(self, profile_engine, profile_session):
"""A DB error during delete triggers rollback and returns 500."""
prof = _make_profile(profile_session, owner=_OWNER)
client = self._make_failing_client(profile_engine, profile_session, fail_on="delete")
try:
resp = client.delete(f"/api/imap-profiles/{prof.id}")
finally:
from app.main import app
app.dependency_overrides.clear()
assert resp.status_code == 500
+406
View File
@@ -8,6 +8,9 @@ from unittest.mock import MagicMock, patch
import pytest
from app.tasks.imap_tasks import (
_decrypt_imap_password,
_pull_user_imap_accounts,
_resolve_categories_for_profile,
acquire_lock,
check_and_pull_mailbox,
cleanup_old_entries,
@@ -1726,3 +1729,406 @@ class TestPullAllInboxesCallsIntegrations:
pull_all_inboxes()
mock_legacy.assert_called_once()
mock_integ.assert_called_once()
# ---------------------------------------------------------------------------
# Tests for _decrypt_imap_password
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDecryptImapPassword:
"""Tests for _decrypt_imap_password function."""
def test_returns_decrypted_value(self):
"""_decrypt_imap_password should delegate to decrypt_value."""
with patch("app.utils.encryption.decrypt_value", return_value="decrypted") as mock_decrypt:
result = _decrypt_imap_password("enc:something")
mock_decrypt.assert_called_once_with("enc:something")
assert result == "decrypted"
def test_returns_none_for_none_input(self):
"""_decrypt_imap_password should return None for None input."""
with patch("app.utils.encryption.decrypt_value", return_value=None):
result = _decrypt_imap_password(None)
assert result is None
def test_returns_plaintext_unchanged(self):
"""_decrypt_imap_password returns plaintext passwords unchanged."""
with patch("app.utils.encryption.decrypt_value", return_value="plain") as mock_decrypt:
result = _decrypt_imap_password("plain")
mock_decrypt.assert_called_once_with("plain")
assert result == "plain"
# ---------------------------------------------------------------------------
# Tests for _resolve_categories_for_profile
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestResolveCategoriesForProfile:
"""Tests for _resolve_categories_for_profile function."""
@patch("app.tasks.imap_tasks._get_db_session")
def test_returns_profile_categories_when_profile_found(self, mock_session_factory):
"""Returns categories from the profile when profile exists in DB."""
import json
mock_profile = MagicMock()
mock_profile.allowed_categories = json.dumps(["pdf", "images"])
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = mock_profile
mock_session_factory.return_value = mock_db
result = _resolve_categories_for_profile(1)
assert result == ["pdf", "images"]
mock_db.close.assert_called_once()
@patch("app.tasks.imap_tasks._get_db_session")
def test_falls_back_to_default_when_profile_not_found(self, mock_session_factory):
"""Falls back to global default when profile_id exists but profile is not in DB."""
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
mock_session_factory.return_value = mock_db
with patch("app.tasks.imap_tasks.settings") as mock_settings:
mock_settings.imap_attachment_filter = "documents_only"
result = _resolve_categories_for_profile(99)
assert result == DEFAULT_CATEGORIES
@patch("app.tasks.imap_tasks._get_db_session")
def test_falls_back_when_db_raises_exception(self, mock_session_factory):
"""Falls back to global default when DB query raises an exception."""
mock_session_factory.side_effect = Exception("DB error")
with patch("app.tasks.imap_tasks.settings") as mock_settings:
mock_settings.imap_attachment_filter = "documents_only"
result = _resolve_categories_for_profile(5)
assert result == DEFAULT_CATEGORIES
def test_returns_all_categories_when_filter_is_all(self):
"""Returns ALL_CATEGORIES when profile_id is None and filter is 'all'."""
with patch("app.tasks.imap_tasks.settings") as mock_settings:
mock_settings.imap_attachment_filter = "all"
result = _resolve_categories_for_profile(None)
assert result == ALL_CATEGORIES
def test_returns_default_categories_when_filter_is_not_all(self):
"""Returns DEFAULT_CATEGORIES when profile_id is None and filter is not 'all'."""
with patch("app.tasks.imap_tasks.settings") as mock_settings:
mock_settings.imap_attachment_filter = "documents_only"
result = _resolve_categories_for_profile(None)
assert result == DEFAULT_CATEGORIES
# ---------------------------------------------------------------------------
# Tests for _pull_user_imap_accounts
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPullUserImapAccounts:
"""Tests for _pull_user_imap_accounts function."""
@patch("app.tasks.imap_tasks._get_db_session")
@patch("app.tasks.imap_tasks.pull_inbox")
def test_polls_active_accounts(self, mock_pull, mock_session_factory):
"""Active accounts should be polled and last_checked_at updated."""
mock_acct = MagicMock()
mock_acct.id = 1
mock_acct.owner_id = "user-1"
mock_acct.host = "imap.example.com"
mock_acct.port = 993
mock_acct.username = "user@example.com"
mock_acct.password = "enc:pass"
mock_acct.use_ssl = True
mock_acct.delete_after_process = False
mock_acct.profile_id = None
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [mock_acct]
mock_session_factory.return_value = mock_db
with patch("app.utils.encryption.decrypt_value", return_value="plainpass"):
_pull_user_imap_accounts()
mock_pull.assert_called_once()
call_kwargs = mock_pull.call_args.kwargs
assert call_kwargs["host"] == "imap.example.com"
assert call_kwargs["owner_id"] == "user-1"
assert mock_acct.last_error is None
assert mock_acct.last_checked_at is not None
mock_db.commit.assert_called()
mock_db.close.assert_called_once()
@patch("app.tasks.imap_tasks._get_db_session")
@patch("app.tasks.imap_tasks.pull_inbox")
def test_records_error_on_pull_failure(self, mock_pull, mock_session_factory):
"""Errors during pull_inbox should be recorded on the account."""
mock_acct = MagicMock()
mock_acct.id = 2
mock_acct.owner_id = "user-2"
mock_acct.host = "imap.bad.com"
mock_acct.port = 993
mock_acct.username = "u@bad.com"
mock_acct.password = "enc:bad"
mock_acct.use_ssl = True
mock_acct.delete_after_process = False
mock_acct.profile_id = None
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [mock_acct]
mock_session_factory.return_value = mock_db
mock_pull.side_effect = Exception("Connection refused")
with patch("app.utils.encryption.decrypt_value", return_value="p"):
_pull_user_imap_accounts()
assert mock_acct.last_error is not None
assert "Connection refused" in mock_acct.last_error
mock_db.commit.assert_called()
@patch("app.tasks.imap_tasks._get_db_session")
@patch("app.tasks.imap_tasks.pull_inbox")
def test_rollback_when_error_commit_fails(self, mock_pull, mock_session_factory):
"""When both pull_inbox and the error-recording commit fail, db.rollback is called."""
mock_acct = MagicMock()
mock_acct.id = 3
mock_acct.owner_id = "user-3"
mock_acct.host = "imap.fail.com"
mock_acct.port = 993
mock_acct.username = "u@fail.com"
mock_acct.password = "enc:bad"
mock_acct.use_ssl = True
mock_acct.delete_after_process = False
mock_acct.profile_id = None
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [mock_acct]
# The error-recording commit itself raises
mock_db.commit.side_effect = Exception("DB unavailable")
mock_session_factory.return_value = mock_db
mock_pull.side_effect = Exception("IMAP error")
with patch("app.utils.encryption.decrypt_value", return_value="p"):
_pull_user_imap_accounts()
mock_db.rollback.assert_called()
@patch("app.tasks.imap_tasks._get_db_session")
def test_handles_db_failure_gracefully(self, mock_session_factory):
"""DB failures when loading accounts should be caught gracefully."""
mock_session_factory.side_effect = Exception("DB unavailable")
# Should not raise
_pull_user_imap_accounts()
@patch("app.tasks.imap_tasks._get_db_session")
@patch("app.tasks.imap_tasks.pull_inbox")
def test_no_accounts_does_not_call_pull(self, mock_pull, mock_session_factory):
"""When no active accounts exist, pull_inbox should not be called."""
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = []
mock_session_factory.return_value = mock_db
_pull_user_imap_accounts()
mock_pull.assert_not_called()
mock_db.close.assert_called_once()
# ---------------------------------------------------------------------------
# Tests for _pull_user_integration_imap rollback path
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPullUserIntegrationImapRollback:
"""Tests for the db.rollback path in _pull_user_integration_imap."""
@patch("app.tasks.imap_tasks._get_db_session")
@patch("app.tasks.imap_tasks.pull_inbox")
def test_rollback_when_error_commit_fails(self, mock_pull, mock_session_factory):
"""When both pull_inbox and the error-recording commit raise, db.rollback is called."""
from app.tasks.imap_tasks import _pull_user_integration_imap
mock_integ = MagicMock()
mock_integ.id = 99
mock_integ.owner_id = "owner-fail"
mock_integ.config = '{"host": "bad.host", "port": 993, "username": "u@x.com", "use_ssl": true}'
mock_integ.credentials = "enc:encrypted"
mock_integ.is_active = True
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.all.return_value = [mock_integ]
# The error-recording commit itself raises
mock_db.commit.side_effect = Exception("DB unavailable")
mock_session_factory.return_value = mock_db
mock_pull.side_effect = Exception("Connection refused")
with patch("app.utils.encryption.decrypt_value", return_value='{"password": "p"}'):
_pull_user_integration_imap()
mock_db.rollback.assert_called()
# ---------------------------------------------------------------------------
# Tests for pull_inbox with allowed_categories=None (resolves via profile)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPullInboxAllowedCategoriesDefault:
"""Tests for pull_inbox when allowed_categories is not provided."""
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks._resolve_categories_for_profile")
def test_resolves_categories_when_none_passed(self, mock_resolve, mock_load, mock_imap_class):
"""pull_inbox should call _resolve_categories_for_profile(None) when allowed_categories=None."""
mock_resolve.return_value = DEFAULT_CATEGORIES
mock_load.return_value = {}
mock_mail = MagicMock()
mock_imap_class.return_value = mock_mail
mock_mail.login.return_value = ("OK", [])
mock_mail.select.return_value = ("OK", [])
mock_mail.search.return_value = ("OK", [b""])
pull_inbox(
mailbox_key="imap1",
host="imap.example.com",
port=993,
username="user",
password=_TEST_CREDENTIAL,
use_ssl=True,
delete_after_process=False,
allowed_categories=None, # explicit None triggers resolve
)
mock_resolve.assert_called_once_with(None)
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
@patch("app.tasks.imap_tasks.load_processed_emails")
@patch("app.tasks.imap_tasks._resolve_categories_for_profile")
def test_skips_resolve_when_categories_provided(self, mock_resolve, mock_load, mock_imap_class):
"""pull_inbox should not call _resolve_categories_for_profile when allowed_categories is given."""
mock_load.return_value = {}
mock_mail = MagicMock()
mock_imap_class.return_value = mock_mail
mock_mail.login.return_value = ("OK", [])
mock_mail.select.return_value = ("OK", [])
mock_mail.search.return_value = ("OK", [b""])
pull_inbox(
mailbox_key="imap1",
host="imap.example.com",
port=993,
username="user",
password=_TEST_CREDENTIAL,
use_ssl=True,
delete_after_process=False,
allowed_categories=DEFAULT_CATEGORIES, # non-None skips resolve
)
mock_resolve.assert_not_called()
# ---------------------------------------------------------------------------
# Tests for fetch_attachments_and_enqueue: extension-only match (not PDF, not MIME)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFetchAttachmentsExtensionOnlyMatch:
"""Tests for fetch_attachments_and_enqueue when file passes only via extension."""
@patch("app.tasks.imap_tasks.process_document")
@patch("app.tasks.imap_tasks.convert_to_pdf")
def test_extension_match_without_mime_type_match(self, mock_convert, mock_process, tmp_path):
"""A file with an allowed extension but wrong MIME type (not PDF) hits the else branch."""
# .docx is in allowed extensions, but application/octet-stream is not in allowed MIME types
# and it's not a PDF by extension -> passes the filter but skips both dispatch branches
msg = EmailMessage()
msg["Subject"] = "Test"
msg.add_attachment(
b"docx content",
maintype="application",
subtype="octet-stream", # wrong MIME type
filename="document.docx", # allowed extension
)
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)
# File is "accepted" (has_attachment=True) but no task is dispatched because
# neither the PDF nor the mime-type branch matched
assert result is True
mock_process.delay.assert_not_called()
mock_convert.delay.assert_not_called()
# ---------------------------------------------------------------------------
# Tests for find_all_mail_folder: XLIST returns None
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFindAllMailFolderXlistNone:
"""Tests for find_all_mail_folder when XLIST is available but returns None."""
@patch("app.tasks.imap_tasks.find_all_mail_xlist")
@patch("app.tasks.imap_tasks.get_capabilities")
def test_returns_none_when_xlist_finds_nothing(self, mock_get_caps, mock_xlist):
"""Should return None when XLIST is supported but finds no All Mail folder."""
mock_mail = MagicMock()
mock_mail.select.return_value = ("NO", None) # All common names fail
mock_get_caps.return_value = ["XLIST", "IMAP4REV1"]
mock_xlist.return_value = None # XLIST also found nothing
result = find_all_mail_folder(mock_mail)
assert result is None
mock_xlist.assert_called_once_with(mock_mail)
# ---------------------------------------------------------------------------
# Tests for find_all_mail_xlist edge cases
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestFindAllMailXlistEdgeCasesExtended:
"""Additional edge-case tests for find_all_mail_xlist."""
def test_returns_none_when_readline_returns_empty_bytes(self):
"""Should break out of loop and return None when readline returns empty bytes."""
mock_mail = MagicMock()
mock_mail._new_tag.return_value = b"A001"
# readline returns empty bytes immediately -> break on first iteration
mock_mail.readline.return_value = b""
result = find_all_mail_xlist(mock_mail)
assert result is None
def test_allmail_line_without_quoted_folder_name(self):
"""XLIST AllMail line where regex finds no quoted name should not set folder."""
mock_mail = MagicMock()
mock_mail._new_tag.return_value = b"A001"
# XLIST response where AllMail flag is present but no double-quoted folder name at end
# -> regex r'"([^"]+)"$' will not match so all_mail_folder stays None
mock_mail.readline.side_effect = [
b"* XLIST (\\AllMail) / NoQuotesHere\r\n",
b"A001 OK XLIST completed\r\n",
]
result = find_all_mail_xlist(mock_mail)
assert result is None
+29
View File
@@ -322,6 +322,35 @@ def test_signup_duplicate_username(la_client, active_user):
assert "Username" in resp.json()["detail"]
@pytest.mark.integration
def test_signup_invalid_username_with_dot(la_client):
"""POST /api/auth/signup returns 422 with a list detail when username contains a dot.
This is a regression test for the bug where ``data.detail`` was an array,
causing the frontend to display ``[object Object]`` instead of a message.
"""
with patch("app.api.local_auth.settings") as mock_settings:
mock_settings.allow_local_signup = True
mock_settings.multi_user_enabled = True
mock_settings.email_host = "smtp.example.com"
resp = la_client.post(
"/api/auth/signup",
json={
"email": "a@example.com",
"username": "christian.louis",
"password": "password1",
"password_confirm": "password1",
},
)
assert resp.status_code == 422
detail = resp.json()["detail"]
# FastAPI returns a list of validation errors for Pydantic constraint failures.
# Each entry must be a dict with a "msg" key so the frontend can extract a readable message.
assert isinstance(detail, list), "detail should be a list for Pydantic validation errors"
assert len(detail) > 0
assert "msg" in detail[0]
@pytest.mark.integration
def test_signup_smtp_failure_cleans_up(la_client, la_session):
"""POST /api/auth/signup cleans up user records if email send fails."""
+285
View File
@@ -0,0 +1,285 @@
"""Tests for application logging configuration.
Validates that the LOG_LEVEL and DEBUG settings correctly control the
Python root-logger level and that the standard precedence rules are respected:
1. Explicit LOG_LEVEL always wins.
2. DEBUG=True without LOG_LEVEL effective DEBUG.
3. Neither set default INFO.
"""
import logging
import os
from unittest.mock import patch
import pytest
from app.config import Settings
@pytest.mark.unit
class TestLogLevelSetting:
"""Tests for the log_level config field."""
_BASE_KWARGS = {
"database_url": "sqlite:///test.db",
"redis_url": "redis://localhost:6379",
"openai_api_key": "test",
"azure_ai_key": "test",
"azure_region": "test",
"azure_endpoint": "https://test.example.com",
"gotenberg_url": "http://localhost:3000",
"workdir": "/tmp",
"auth_enabled": False,
"session_secret": None,
}
def test_log_level_default_is_info(self):
"""Test that log_level defaults to INFO."""
config = Settings(**self._BASE_KWARGS)
assert config.log_level.upper() == "INFO"
def test_log_level_accepts_debug(self):
"""Test that log_level accepts DEBUG."""
config = Settings(**self._BASE_KWARGS, log_level="DEBUG")
assert config.log_level.upper() == "DEBUG"
def test_log_level_accepts_warning(self):
"""Test that log_level accepts WARNING."""
config = Settings(**self._BASE_KWARGS, log_level="WARNING")
assert config.log_level.upper() == "WARNING"
def test_log_level_accepts_error(self):
"""Test that log_level accepts ERROR."""
config = Settings(**self._BASE_KWARGS, log_level="ERROR")
assert config.log_level.upper() == "ERROR"
def test_log_level_case_insensitive(self):
"""Test that log_level is case-insensitive in usage."""
config = Settings(**self._BASE_KWARGS, log_level="debug")
assert config.log_level.upper() == "DEBUG"
def test_debug_flag_defaults_to_false(self):
"""Test that debug defaults to False."""
config = Settings(**self._BASE_KWARGS)
assert config.debug is False
@pytest.mark.unit
class TestEffectiveLogLevel:
"""Tests for the effective log-level resolution logic in main.py."""
def test_debug_true_without_log_level_gives_debug(self):
"""When DEBUG=True and LOG_LEVEL is not set, effective level is DEBUG."""
with patch.dict(os.environ, {"DEBUG": "true"}, clear=False):
# Remove LOG_LEVEL from env if present
env = os.environ.copy()
env.pop("LOG_LEVEL", None)
with patch.dict(os.environ, env, clear=True):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
debug=True,
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "DEBUG"
def test_explicit_log_level_overrides_debug(self):
"""When LOG_LEVEL is explicitly set, it takes precedence over DEBUG=True."""
with patch.dict(os.environ, {"LOG_LEVEL": "WARNING", "DEBUG": "true"}, clear=False):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
debug=True,
log_level="WARNING",
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "WARNING"
def test_default_no_flags_gives_info(self):
"""When neither DEBUG nor LOG_LEVEL is set, effective level is INFO."""
env = os.environ.copy()
env.pop("LOG_LEVEL", None)
env.pop("DEBUG", None)
with patch.dict(os.environ, env, clear=True):
s = Settings(
database_url="sqlite:///test.db",
redis_url="redis://localhost:6379",
openai_api_key="test",
azure_ai_key="test",
azure_region="test",
azure_endpoint="https://test.example.com",
gotenberg_url="http://localhost:3000",
workdir="/tmp",
auth_enabled=False,
session_secret=None,
)
explicit = os.environ.get("LOG_LEVEL")
if s.debug and explicit is None:
effective = "DEBUG"
else:
effective = s.log_level.upper()
assert effective == "INFO"
def test_effective_level_maps_to_logging_constant(self):
"""The effective level string maps to a valid logging constant."""
for level_name in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
assert getattr(logging, level_name) is not None
@pytest.mark.unit
class TestLoggingConfiguredAtStartup:
"""Tests that the main module configures the root logger on import."""
def test_root_logger_has_handler(self):
"""Root logger should have at least one handler after app import."""
root = logging.getLogger()
assert len(root.handlers) > 0, "Root logger has no handlers after app startup"
def test_root_logger_level_is_not_warning_default(self):
"""Root logger should not be at the unconfigured WARNING default.
Our basicConfig(force=True) should have set it to at least INFO.
"""
root = logging.getLogger()
# The test env doesn't set DEBUG=True, so the level should be INFO (20)
assert root.level <= logging.INFO
@pytest.mark.unit
class TestJsonFormatter:
"""Tests for the _JsonFormatter used when LOG_FORMAT=json."""
def _make_formatter(self):
"""Lazily import the JSON formatter from main module."""
from app.main import _JsonFormatter
return _JsonFormatter()
def test_output_is_valid_json(self):
"""JSON formatter output should be parseable JSON."""
import json
fmt = self._make_formatter()
record = logging.LogRecord(
name="test.logger",
level=logging.INFO,
pathname="test.py",
lineno=42,
msg="Hello %s",
args=("world",),
exc_info=None,
)
result = fmt.format(record)
parsed = json.loads(result)
assert parsed["level"] == "INFO"
assert parsed["logger"] == "test.logger"
assert parsed["message"] == "Hello world"
assert parsed["lineno"] == 42
def test_includes_timestamp_iso8601(self):
"""JSON output should contain an ISO 8601 timestamp."""
import json
fmt = self._make_formatter()
record = logging.LogRecord(
name="x",
level=logging.DEBUG,
pathname="x.py",
lineno=1,
msg="test",
args=(),
exc_info=None,
)
parsed = json.loads(fmt.format(record))
assert "timestamp" in parsed
# ISO 8601 timestamps contain "T" and "+00:00" (UTC)
assert "T" in parsed["timestamp"]
def test_includes_exc_info_when_present(self):
"""JSON output should include exc_info when an exception is logged."""
import json
fmt = self._make_formatter()
try:
raise ValueError("boom") # noqa: TRY301
except ValueError:
import sys
record = logging.LogRecord(
name="x",
level=logging.ERROR,
pathname="x.py",
lineno=1,
msg="error",
args=(),
exc_info=sys.exc_info(),
)
parsed = json.loads(fmt.format(record))
assert "exc_info" in parsed
assert "ValueError" in parsed["exc_info"]
@pytest.mark.unit
class TestLogFormatSetting:
"""Tests for the log_format and log_syslog_* config fields."""
_BASE_KWARGS = {
"database_url": "sqlite:///test.db",
"redis_url": "redis://localhost:6379",
"openai_api_key": "test",
"azure_ai_key": "test",
"azure_region": "test",
"azure_endpoint": "https://test.example.com",
"gotenberg_url": "http://localhost:3000",
"workdir": "/tmp",
"auth_enabled": False,
"session_secret": None,
}
def test_log_format_default_is_text(self):
"""Test that log_format defaults to 'text'."""
config = Settings(**self._BASE_KWARGS)
assert config.log_format == "text"
def test_log_format_accepts_json(self):
"""Test that log_format accepts 'json'."""
config = Settings(**self._BASE_KWARGS, log_format="json")
assert config.log_format == "json"
def test_log_syslog_defaults(self):
"""Test syslog forwarding defaults."""
config = Settings(**self._BASE_KWARGS)
assert config.log_syslog_enabled is False
assert config.log_syslog_host == "localhost"
assert config.log_syslog_port == 514
assert config.log_syslog_protocol == "udp"
def test_log_syslog_can_be_enabled(self):
"""Test that syslog forwarding can be enabled."""
config = Settings(**self._BASE_KWARGS, log_syslog_enabled=True, log_syslog_host="syslog.example.com")
assert config.log_syslog_enabled is True
assert config.log_syslog_host == "syslog.example.com"
+117 -1
View File
@@ -18,7 +18,7 @@ from sqlalchemy.pool import StaticPool
from app.config import settings
from app.database import Base
from app.models import FileRecord
from app.models import ApiToken, FileRecord
# ---------------------------------------------------------------------------
# Fixtures
@@ -162,6 +162,122 @@ class TestGetCurrentOwnerId:
request.session = {}
assert get_current_owner_id(request) is None
@pytest.mark.unit
def test_resolves_from_api_token_user_state(self):
"""get_current_owner_id should resolve from request.state.api_token_user."""
from app.utils.user_scope import get_current_owner_id
request = MagicMock()
request.session = {}
request.state.api_token_user = {
"id": "tok-owner",
"preferred_username": "tok-owner",
"email": "tok-owner",
}
assert get_current_owner_id(request) == "tok-owner"
@pytest.mark.unit
def test_session_takes_precedence_over_api_token_user(self):
"""Session auth should take precedence over api_token_user in state."""
from app.utils.user_scope import get_current_owner_id
request = MagicMock()
request.session = {"user": {"sub": "session-sub", "email": "session@example.com"}}
request.state.api_token_user = {"id": "tok-owner"}
assert get_current_owner_id(request) == "session-sub"
@pytest.mark.unit
def test_resolves_bearer_token_directly(self, mu_engine, mu_session):
"""get_current_owner_id should resolve a Bearer token when no session exists."""
from types import SimpleNamespace
from app.api.api_tokens import generate_api_token, hash_token
from app.utils.user_scope import get_current_owner_id
# Create a token in the DB
plaintext = generate_api_token()
token_hash = hash_token(plaintext)
db_token = ApiToken(
owner_id="bearer-owner",
name="Test Bearer",
token_hash=token_hash,
token_prefix=plaintext[:12],
is_active=True,
)
mu_session.add(db_token)
mu_session.commit()
# Build a mock request with Bearer header but no session.
# SimpleNamespace starts with no attributes so getattr(..., None) works.
request = MagicMock()
request.session = {}
request.state = SimpleNamespace()
request.headers = {"authorization": f"Bearer {plaintext}"}
request.client.host = "127.0.0.1"
# Provide the test session and make close() a no-op so the shared
# session is not torn down prematurely.
noop_close = MagicMock()
with patch("app.database.SessionLocal", return_value=mu_session), patch.object(mu_session, "close", noop_close):
result = get_current_owner_id(request)
assert result == "bearer-owner"
# Verify the resolved user was cached in request.state
assert request.state.api_token_user["id"] == "bearer-owner"
@pytest.mark.unit
def test_returns_none_for_invalid_bearer_token(self, mu_engine, mu_session):
"""get_current_owner_id should return None for an invalid Bearer token."""
from types import SimpleNamespace
from app.utils.user_scope import get_current_owner_id
request = MagicMock()
request.session = {}
request.state = SimpleNamespace()
request.headers = {"authorization": "Bearer de_invalid_token_value"}
request.client.host = "127.0.0.1"
noop_close = MagicMock()
with patch("app.database.SessionLocal", return_value=mu_session), patch.object(mu_session, "close", noop_close):
result = get_current_owner_id(request)
assert result is None
class TestOwnerIdFromUser:
"""Tests for the _owner_id_from_user helper."""
@pytest.mark.unit
def test_prefers_sub(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"sub": "s", "preferred_username": "u", "email": "e"}) == "s"
@pytest.mark.unit
def test_falls_back_to_preferred_username(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"preferred_username": "u", "email": "e"}) == "u"
@pytest.mark.unit
def test_falls_back_to_email(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"email": "e"}) == "e"
@pytest.mark.unit
def test_falls_back_to_id(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({"id": "i"}) == "i"
@pytest.mark.unit
def test_returns_none_for_empty_dict(self):
from app.utils.user_scope import _owner_id_from_user
assert _owner_id_from_user({}) is None
class TestApplyOwnerFilter:
"""Tests for apply_owner_filter()."""
+55
View File
@@ -830,3 +830,58 @@ class TestUserNotificationService:
result = _send_email_notification({"smtp_host": "smtp.example.com"}, "Title", "Body")
assert result is False
class TestBenchmark:
@pytest.mark.unit
def test_update_preferences_benchmark(self, notif_engine, notif_session):
import statistics
import time
from app.main import app
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="webhook",
name="My Webhook",
config=json.dumps({"url": "https://x.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
client = _make_client(notif_engine, _OWNER)
try:
items_count = 100
preferences = []
for i in range(items_count):
preferences.append(
{
"event_type": f"event.type.{i}",
"channel_type": "webhook",
"is_enabled": True,
"target_id": target.id,
}
)
payload = {"preferences": preferences}
# Warm up
client.put("/api/user-notifications/preferences", json=payload)
times = []
for _ in range(5):
# Alter the values a bit so it's a real update
for p in payload["preferences"]:
p["is_enabled"] = not p["is_enabled"]
start = time.time()
resp = client.put("/api/user-notifications/preferences", json=payload)
end = time.time()
assert resp.status_code == 200
times.append(end - start)
print(f"\nAverage time: {statistics.mean(times):.4f}s")
finally:
_cleanup(app)
+27
View File
@@ -360,6 +360,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -368,6 +369,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_s3,
mock_icloud,
mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -397,6 +399,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
@@ -410,6 +413,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all.settings")
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
@patch("app.tasks.send_to_all._should_upload_to_paperless")
@@ -434,6 +438,7 @@ class TestSendToAllDestinations:
mock_paperless,
mock_nextcloud,
mock_should_s3,
mock_sharepoint,
mock_icloud,
mock_should_dropbox,
mock_settings,
@@ -456,6 +461,7 @@ class TestSendToAllDestinations:
mock_sftp.return_value = False
mock_email.return_value = False
mock_onedrive.return_value = False
mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_dropbox_upload.delay.return_value = MagicMock(id="dropbox-task")
mock_s3_upload.delay.return_value = MagicMock(id="s3-task")
@@ -478,12 +484,14 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
def test_skips_unconfigured_services(
self,
mock_s3,
mock_icloud,
mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -513,6 +521,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_sharepoint.return_value = False
mock_icloud.return_value = False
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
@@ -534,6 +543,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -542,6 +552,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_s3,
mock_icloud,
mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -571,6 +582,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
@@ -593,6 +605,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
@@ -603,6 +616,7 @@ class TestSendToAllDestinations:
mock_validator,
mock_s3,
mock_icloud,
mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -633,6 +647,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.return_value = MagicMock(id="task-123")
@@ -653,6 +668,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
@@ -661,6 +677,7 @@ class TestSendToAllDestinations:
mock_validator,
mock_s3,
mock_icloud,
mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -691,6 +708,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_sharepoint.return_value = False
mock_icloud.return_value = False
# Should not raise, should fall back to individual checks
@@ -710,6 +728,7 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all.upload_to_dropbox")
@@ -718,6 +737,7 @@ class TestSendToAllDestinations:
mock_upload,
mock_s3,
mock_icloud,
mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -747,6 +767,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_sharepoint.return_value = False
mock_icloud.return_value = False
mock_upload.delay.side_effect = Exception("Queue error")
@@ -758,6 +779,7 @@ class TestSendToAllDestinations:
assert "dropbox_error" in result.result["tasks"]
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_s3")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_email")
@@ -786,6 +808,7 @@ class TestSendToAllDestinations:
mock_email,
mock_onedrive,
mock_s3,
mock_sharepoint,
mock_icloud,
tmp_path,
):
@@ -808,6 +831,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_sharepoint.return_value = False
mock_icloud.return_value = False
# Mock database session
@@ -836,12 +860,14 @@ class TestSendToAllDestinations:
@patch("app.tasks.send_to_all._should_upload_to_sftp")
@patch("app.tasks.send_to_all._should_upload_to_email")
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
@patch("app.tasks.send_to_all._should_upload_to_icloud")
@patch("app.tasks.send_to_all._should_upload_to_s3")
def test_should_upload_check_exception_handling(
self,
mock_s3,
mock_icloud,
mock_sharepoint,
mock_onedrive,
mock_email,
mock_sftp,
@@ -871,6 +897,7 @@ class TestSendToAllDestinations:
mock_email.return_value = False
mock_onedrive.return_value = False
mock_s3.return_value = False
mock_sharepoint.return_value = False
mock_icloud.return_value = False
# Should not raise, should treat as not configured
+135
View File
@@ -210,6 +210,141 @@ class TestGetAppVersion:
assert _get_app_version() is None
@pytest.mark.unit
class TestSentryJsTemplateContext:
"""Test that Sentry Browser SDK config is injected into the template context."""
def test_sentry_dsn_exposed_when_configured(self, mocker):
"""sentry_dsn is set in the template context when SENTRY_DSN is configured."""
mock_settings = mocker.patch("app.views.base.settings")
mock_settings.sentry_dsn = "https://key@o1.ingest.sentry.io/1"
mock_settings.sentry_environment = "production"
mock_settings.sentry_js_traces_sample_rate = 0.1
mock_settings.sentry_js_replay_session_sample_rate = 0.0
mock_settings.sentry_js_replay_on_error_sample_rate = 0.1
from app.views.base import _inject_global_context
ctx: dict = {}
_inject_global_context(ctx)
assert ctx["sentry_dsn"] == "https://key@o1.ingest.sentry.io/1"
def test_sentry_dsn_none_when_not_configured(self, mocker):
"""sentry_dsn is None in the template context when SENTRY_DSN is unset."""
mock_settings = mocker.patch("app.views.base.settings")
mock_settings.sentry_dsn = None
mock_settings.sentry_environment = "production"
mock_settings.sentry_js_traces_sample_rate = 0.0
mock_settings.sentry_js_replay_session_sample_rate = 0.0
mock_settings.sentry_js_replay_on_error_sample_rate = 0.1
from app.views.base import _inject_global_context
ctx: dict = {}
_inject_global_context(ctx)
assert ctx["sentry_dsn"] is None
def test_sentry_dsn_empty_string_becomes_none(self, mocker):
"""An empty-string SENTRY_DSN is normalised to None in the template context."""
mock_settings = mocker.patch("app.views.base.settings")
mock_settings.sentry_dsn = ""
mock_settings.sentry_environment = "production"
mock_settings.sentry_js_traces_sample_rate = 0.0
mock_settings.sentry_js_replay_session_sample_rate = 0.0
mock_settings.sentry_js_replay_on_error_sample_rate = 0.1
from app.views.base import _inject_global_context
ctx: dict = {}
_inject_global_context(ctx)
assert ctx["sentry_dsn"] is None
def test_js_sample_rates_exposed_in_context(self, mocker):
"""Browser SDK sample rates are passed through to the template context."""
mock_settings = mocker.patch("app.views.base.settings")
mock_settings.sentry_dsn = "https://key@o1.ingest.sentry.io/1"
mock_settings.sentry_environment = "staging"
mock_settings.sentry_js_traces_sample_rate = 0.5
mock_settings.sentry_js_replay_session_sample_rate = 0.2
mock_settings.sentry_js_replay_on_error_sample_rate = 0.8
from app.views.base import _inject_global_context
ctx: dict = {}
_inject_global_context(ctx)
assert ctx["sentry_environment"] == "staging"
assert ctx["sentry_js_traces_sample_rate"] == 0.5
assert ctx["sentry_js_replay_session_sample_rate"] == 0.2
assert ctx["sentry_js_replay_on_error_sample_rate"] == 0.8
def test_js_sample_rates_default_values(self, mocker):
"""Browser SDK sample rates fall back to safe defaults when attrs are absent."""
mock_settings = mocker.patch("app.views.base.settings")
# Simulate settings object without the new JS attributes
del mock_settings.sentry_js_traces_sample_rate
del mock_settings.sentry_js_replay_session_sample_rate
del mock_settings.sentry_js_replay_on_error_sample_rate
mock_settings.sentry_dsn = None
mock_settings.sentry_environment = "production"
from app.views.base import _inject_global_context
ctx: dict = {}
_inject_global_context(ctx)
assert ctx["sentry_js_traces_sample_rate"] == 0.0
assert ctx["sentry_js_replay_session_sample_rate"] == 0.0
assert ctx["sentry_js_replay_on_error_sample_rate"] == 0.1
_MINIMAL_SETTINGS_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": "a-test-secret-that-is-at-least-32-chars-long!",
}
@pytest.mark.unit
class TestSentryJsConfig:
"""Test the new JS-specific Settings fields."""
def test_js_traces_sample_rate_default(self):
"""SENTRY_JS_TRACES_SAMPLE_RATE defaults to 0.0."""
from app.config import settings
assert settings.sentry_js_traces_sample_rate == 0.0
def test_js_replay_session_sample_rate_default(self):
"""SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE defaults to 0.0."""
from app.config import settings
assert settings.sentry_js_replay_session_sample_rate == 0.0
def test_js_replay_on_error_sample_rate_default(self):
"""SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE defaults to 0.1."""
from app.config import settings
assert settings.sentry_js_replay_on_error_sample_rate == 0.1
def test_js_traces_sample_rate_can_be_set(self):
"""SENTRY_JS_TRACES_SAMPLE_RATE can be set directly via constructor."""
from app.config import Settings
s = Settings(**_MINIMAL_SETTINGS_KWARGS, sentry_js_traces_sample_rate=0.5)
assert s.sentry_js_traces_sample_rate == 0.5
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
+699
View File
@@ -0,0 +1,699 @@
"""Tests for server-side session management and QR code login.
Covers:
* Session creation, validation, revocation, and cleanup
* "Log off everywhere" (revoke all sessions)
* QR login challenge creation, validation, claiming, and status polling
* Session management API endpoints (list, revoke, revoke-all)
* QR auth API endpoints (challenge, status, claim)
* Device info parsing from User-Agent strings
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.database import Base
from app.models import ApiToken, QRLoginChallenge, UserSession
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def db_session():
"""Provide an in-memory SQLite session with all tables created."""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
TestSession = sessionmaker(bind=engine)
session = TestSession()
yield session
session.close()
Base.metadata.drop_all(engine)
@pytest.fixture()
def sample_user_id():
return "user@example.com"
# ---------------------------------------------------------------------------
# Model Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUserSessionModel:
"""Tests for the UserSession ORM model."""
def test_create_user_session(self, db_session: Session, sample_user_id: str):
"""Test creating a UserSession record."""
now = datetime.now(timezone.utc)
session = UserSession(
session_token=secrets.token_urlsafe(64),
user_id=sample_user_id,
ip_address="192.168.1.1",
user_agent="Mozilla/5.0",
device_info="Chrome on macOS",
expires_at=now + timedelta(days=30),
)
db_session.add(session)
db_session.commit()
assert session.id is not None
assert session.user_id == sample_user_id
assert session.is_revoked is False
assert session.device_info == "Chrome on macOS"
def test_session_default_values(self, db_session: Session, sample_user_id: str):
"""Test that default values are set correctly."""
session = UserSession(
session_token="test_token_123",
user_id=sample_user_id,
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
)
db_session.add(session)
db_session.commit()
assert session.is_revoked is False
assert session.revoked_at is None
@pytest.mark.unit
class TestQRLoginChallengeModel:
"""Tests for the QRLoginChallenge ORM model."""
def test_create_challenge(self, db_session: Session, sample_user_id: str):
"""Test creating a QRLoginChallenge record."""
challenge = QRLoginChallenge(
challenge_token=secrets.token_urlsafe(64),
user_id=sample_user_id,
created_by_ip="10.0.0.1",
expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
)
db_session.add(challenge)
db_session.commit()
assert challenge.id is not None
assert challenge.is_claimed is False
assert challenge.is_cancelled is False
def test_challenge_default_values(self, db_session: Session, sample_user_id: str):
"""Test that QRLoginChallenge defaults are correct."""
challenge = QRLoginChallenge(
challenge_token="challenge_test_123",
user_id=sample_user_id,
expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
)
db_session.add(challenge)
db_session.commit()
assert challenge.is_claimed is False
assert challenge.is_cancelled is False
assert challenge.claimed_at is None
assert challenge.device_name is None
# ---------------------------------------------------------------------------
# Session Manager Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSessionManager:
"""Tests for app/utils/session_manager.py functions."""
@patch("app.utils.session_manager.settings")
def test_get_session_lifetime_days_default(self, mock_settings):
"""Test default session lifetime."""
from app.utils.session_manager import get_session_lifetime_days
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
assert get_session_lifetime_days() == 30
@patch("app.utils.session_manager.settings")
def test_get_session_lifetime_days_custom(self, mock_settings):
"""Test custom session lifetime overrides default."""
from app.utils.session_manager import get_session_lifetime_days
mock_settings.session_lifetime_custom_days = 90
mock_settings.session_lifetime_days = 30
assert get_session_lifetime_days() == 90
@patch("app.utils.session_manager.settings")
def test_get_session_lifetime_days_minimum(self, mock_settings):
"""Test session lifetime has a minimum of 1 day."""
from app.utils.session_manager import get_session_lifetime_days
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 0
assert get_session_lifetime_days() == 1
@patch("app.utils.session_manager.settings")
def test_get_session_max_age_seconds(self, mock_settings):
"""Test session max age in seconds."""
from app.utils.session_manager import get_session_max_age_seconds
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
assert get_session_max_age_seconds() == 30 * 86400
@patch("app.utils.session_manager.settings")
def test_create_session(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test creating a server-side session."""
from app.utils.session_manager import create_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
mock_settings.qr_login_challenge_ttl_seconds = 120
user_session = create_session(
db_session,
user_id=sample_user_id,
ip_address="10.0.0.1",
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0",
)
assert user_session.id is not None
assert user_session.user_id == sample_user_id
assert user_session.ip_address == "10.0.0.1"
assert user_session.session_token is not None
assert len(user_session.session_token) > 32
assert user_session.is_revoked is False
assert user_session.device_info is not None
@patch("app.utils.session_manager.settings")
def test_validate_session_valid(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test validating a valid session."""
from app.utils.session_manager import create_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
result = validate_session(db_session, user_session.session_token)
assert result is not None
assert result.id == user_session.id
@patch("app.utils.session_manager.settings")
def test_validate_session_revoked(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that revoked sessions are rejected."""
from app.utils.session_manager import create_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
user_session.is_revoked = True
db_session.commit()
result = validate_session(db_session, user_session.session_token)
assert result is None
@patch("app.utils.session_manager.settings")
def test_validate_session_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that expired sessions are rejected."""
from app.utils.session_manager import create_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
user_session.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
db_session.commit()
result = validate_session(db_session, user_session.session_token)
assert result is None
def test_validate_session_empty_token(self, db_session: Session):
"""Test that empty token returns None."""
from app.utils.session_manager import validate_session
assert validate_session(db_session, "") is None
assert validate_session(db_session, None) is None
def test_validate_session_nonexistent_token(self, db_session: Session):
"""Test that nonexistent token returns None."""
from app.utils.session_manager import validate_session
assert validate_session(db_session, "nonexistent_token_xyz") is None
@patch("app.utils.session_manager.settings")
def test_revoke_session(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test revoking a single session."""
from app.utils.session_manager import create_session, revoke_session, validate_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
assert revoke_session(db_session, user_session.id, sample_user_id) is True
# Session should now be invalid
assert validate_session(db_session, user_session.session_token) is None
@patch("app.utils.session_manager.settings")
def test_revoke_session_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a user cannot revoke another user's session."""
from app.utils.session_manager import create_session, revoke_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
user_session = create_session(db_session, user_id=sample_user_id)
assert revoke_session(db_session, user_session.id, "other_user@example.com") is False
@patch("app.utils.session_manager.settings")
def test_revoke_all_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test revoking all sessions for a user."""
from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
s1 = create_session(db_session, user_id=sample_user_id)
s2 = create_session(db_session, user_id=sample_user_id)
s3 = create_session(db_session, user_id=sample_user_id)
count = revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=False)
assert count == 3
# All sessions should be revoked
active = list_user_sessions(db_session, sample_user_id)
assert len(active) == 0
@patch("app.utils.session_manager.settings")
def test_revoke_all_except_current(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test revoking all sessions except the current one."""
from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
s1 = create_session(db_session, user_id=sample_user_id)
s2 = create_session(db_session, user_id=sample_user_id)
s3 = create_session(db_session, user_id=sample_user_id)
count = revoke_all_sessions(
db_session,
sample_user_id,
except_session_id=s1.id,
revoke_api_tokens=False,
)
assert count == 2
active = list_user_sessions(db_session, sample_user_id)
assert len(active) == 1
assert active[0].id == s1.id
@patch("app.utils.session_manager.settings")
def test_revoke_all_includes_api_tokens(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that revoke-all also revokes API tokens."""
from app.utils.session_manager import create_session, revoke_all_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
create_session(db_session, user_id=sample_user_id)
# Create an API token
token = ApiToken(
owner_id=sample_user_id,
name="Test Token",
token_hash="abc123hash",
token_prefix="de_abc12345",
)
db_session.add(token)
db_session.commit()
revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=True)
db_session.refresh(token)
assert token.is_active is False
@patch("app.utils.session_manager.settings")
def test_list_user_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test listing active sessions for a user."""
from app.utils.session_manager import create_session, list_user_sessions
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
create_session(db_session, user_id=sample_user_id)
create_session(db_session, user_id=sample_user_id)
create_session(db_session, user_id="other@example.com")
sessions = list_user_sessions(db_session, sample_user_id)
assert len(sessions) == 2
@patch("app.utils.session_manager.settings")
def test_cleanup_expired_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test cleaning up expired sessions."""
from app.utils.session_manager import cleanup_expired_sessions, create_session
mock_settings.session_lifetime_custom_days = None
mock_settings.session_lifetime_days = 30
# Create a session that expired 10 days ago
session = create_session(db_session, user_id=sample_user_id)
session.expires_at = datetime.now(timezone.utc) - timedelta(days=10)
db_session.commit()
count = cleanup_expired_sessions(db_session)
assert count == 1
# ---------------------------------------------------------------------------
# QR Login Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestQRLogin:
"""Tests for QR login challenge/claim flow."""
@patch("app.utils.session_manager.settings")
def test_create_qr_challenge(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test creating a QR login challenge."""
from app.utils.session_manager import create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id, ip_address="10.0.0.1")
assert challenge.id is not None
assert challenge.user_id == sample_user_id
assert challenge.challenge_token is not None
assert len(challenge.challenge_token) > 32
assert challenge.is_claimed is False
assert challenge.created_by_ip == "10.0.0.1"
# SQLite returns naive datetimes; normalise before comparison
expires = challenge.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
assert expires > datetime.now(timezone.utc)
@patch("app.utils.session_manager.settings")
def test_create_qr_challenge_ttl_seconds(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that ttl_seconds can be derived from created_at and expires_at.
The API endpoint computes ttl_seconds = (expires_at - created_at) to
allow the client to run a countdown timer without comparing absolute
timestamps (avoiding clock-skew issues).
"""
from app.utils.session_manager import create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
assert ttl_seconds == 120
@patch("app.utils.session_manager.settings")
def test_create_qr_challenge_custom_ttl(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a custom TTL is correctly reflected in the challenge timestamps."""
from app.utils.session_manager import create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 300
challenge = create_qr_challenge(db_session, sample_user_id)
ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
assert ttl_seconds == 300
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test validating a valid QR challenge."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is not None
assert result.id == challenge.id
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that expired challenges are rejected."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is None
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that claimed challenges are rejected (replay protection)."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.is_claimed = True
db_session.commit()
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is None
@patch("app.utils.session_manager.settings")
def test_validate_qr_challenge_cancelled(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that cancelled challenges are rejected."""
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.is_cancelled = True
db_session.commit()
result = validate_qr_challenge(db_session, challenge.challenge_token)
assert result is None
def test_validate_qr_challenge_empty(self, db_session: Session):
"""Test that empty challenge token returns None."""
from app.utils.session_manager import validate_qr_challenge
assert validate_qr_challenge(db_session, "") is None
assert validate_qr_challenge(db_session, None) is None
@patch("app.utils.session_manager.settings")
def test_claim_qr_challenge_success(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test successfully claiming a QR challenge."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
result = claim_qr_challenge(
db_session,
challenge.challenge_token,
device_name="Christian's iPhone 15 Pro",
ip_address="192.168.1.100",
)
assert result is not None
assert result["token"].startswith("de_")
assert result["token_id"] is not None
assert result["owner_id"] == sample_user_id
assert "QR" in result["name"]
# Challenge should now be claimed
db_session.refresh(challenge)
assert challenge.is_claimed is True
assert challenge.claimed_by_ip == "192.168.1.100"
assert challenge.device_name == "Christian's iPhone 15 Pro"
@patch("app.utils.session_manager.settings")
def test_claim_qr_challenge_replay_protection(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a claimed challenge cannot be claimed again."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
# First claim succeeds
result1 = claim_qr_challenge(db_session, challenge.challenge_token)
assert result1 is not None
# Second claim fails (replay protection)
result2 = claim_qr_challenge(db_session, challenge.challenge_token)
assert result2 is None
@patch("app.utils.session_manager.settings")
def test_claim_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that expired challenges cannot be claimed."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
result = claim_qr_challenge(db_session, challenge.challenge_token)
assert result is None
def test_claim_qr_challenge_invalid_token(self, db_session: Session):
"""Test claiming with an invalid token."""
from app.utils.session_manager import claim_qr_challenge
result = claim_qr_challenge(db_session, "nonexistent_token_xyz")
assert result is None
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_pending(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test getting status of a pending challenge."""
from app.utils.session_manager import create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
status = get_challenge_status(db_session, challenge.id, sample_user_id)
assert status is not None
assert status["status"] == "pending"
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test getting status of a claimed challenge."""
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
claim_qr_challenge(db_session, challenge.challenge_token, device_name="Test Device")
status = get_challenge_status(db_session, challenge.id, sample_user_id)
assert status is not None
assert status["status"] == "claimed"
assert status["device_name"] == "Test Device"
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_expired(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test getting status of an expired challenge."""
from app.utils.session_manager import create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
db_session.commit()
status = get_challenge_status(db_session, challenge.id, sample_user_id)
assert status["status"] == "expired"
@patch("app.utils.session_manager.settings")
def test_get_challenge_status_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
"""Test that a user cannot see another user's challenge status."""
from app.utils.session_manager import create_qr_challenge, get_challenge_status
mock_settings.qr_login_challenge_ttl_seconds = 120
challenge = create_qr_challenge(db_session, sample_user_id)
status = get_challenge_status(db_session, challenge.id, "other@example.com")
assert status is None
# ---------------------------------------------------------------------------
# Device Info Parsing Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeviceInfoParsing:
"""Tests for User-Agent parsing."""
def test_chrome_macos(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
result = _parse_device_info(ua)
assert "Chrome" in result
assert "macOS" in result
def test_safari_iphone(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
result = _parse_device_info(ua)
assert "Safari" in result
assert "iPhone" in result
def test_firefox_windows(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
result = _parse_device_info(ua)
assert "Firefox" in result
assert "Windows" in result
def test_edge_windows(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
result = _parse_device_info(ua)
assert "Edge" in result
assert "Windows" in result
def test_android_chrome(self):
from app.utils.session_manager import _parse_device_info
ua = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.210 Mobile Safari/537.36"
result = _parse_device_info(ua)
assert "Chrome" in result
assert "Android" in result
def test_none_user_agent(self):
from app.utils.session_manager import _parse_device_info
assert _parse_device_info(None) is None
def test_empty_user_agent(self):
from app.utils.session_manager import _parse_device_info
assert _parse_device_info("") is None
# ---------------------------------------------------------------------------
# Config Tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSessionConfig:
"""Tests for session-related configuration fields."""
def test_session_lifetime_days_field_exists(self):
"""Verify session_lifetime_days field is defined in Settings."""
from app.config import Settings
# Check the field exists in the model
assert "session_lifetime_days" in Settings.model_fields
def test_session_lifetime_custom_days_field_exists(self):
"""Verify session_lifetime_custom_days field is defined in Settings."""
from app.config import Settings
assert "session_lifetime_custom_days" in Settings.model_fields
def test_qr_login_challenge_ttl_field_exists(self):
"""Verify qr_login_challenge_ttl_seconds field is defined in Settings."""
from app.config import Settings
assert "qr_login_challenge_ttl_seconds" in Settings.model_fields
+31 -1
View File
@@ -289,7 +289,8 @@ class TestNotifySettingsUpdated:
call_args = mock_redis_instance.set.call_args[0]
assert call_args[0] == SETTINGS_VERSION_KEY
def test_does_not_raise_on_redis_failure(self):
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_redis_failure(self, mock_logger):
"""notify_settings_updated must not propagate Redis errors."""
from app.utils.settings_sync import notify_settings_updated
@@ -297,6 +298,35 @@ class TestNotifySettingsUpdated:
mock_redis_module.from_url.side_effect = Exception("Redis down")
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not publish settings update to Redis: Redis down")
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_reload_failure(self, mock_logger):
"""notify_settings_updated must not propagate settings reload errors."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
mock_reload.side_effect = Exception("Reload error")
# We mock redis so that we skip over the redis block, and mock ensure_ocr_languages_async to prevent its side effects.
with patch("app.utils.settings_sync.redis"):
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async"):
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not reload in-process settings: Reload error")
@patch("app.utils.settings_sync.logger")
def test_does_not_raise_on_ocr_language_check_failure(self, mock_logger):
"""notify_settings_updated must not propagate OCR language check errors."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async") as mock_ensure:
mock_ensure.side_effect = Exception("OCR error")
# We mock redis and reload_settings_from_db so we only test the OCR block failure.
with patch("app.utils.settings_sync.redis"):
with patch("app.utils.config_loader.reload_settings_from_db"):
# Should not raise
notify_settings_updated()
mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR error")
@pytest.mark.unit
+211
View File
@@ -0,0 +1,211 @@
from unittest.mock import MagicMock, patch
import pytest
import app.utils.settings_sync
from app.utils.settings_sync import (
SETTINGS_VERSION_KEY,
notify_settings_updated,
register_settings_reload_signal,
)
@pytest.fixture
def reset_last_seen_version():
"""Reset the global variable before and after tests."""
app.utils.settings_sync._last_seen_version = ""
yield
app.utils.settings_sync._last_seen_version = ""
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
@patch("app.utils.settings_sync.time.time", return_value=12345.0)
def test_notify_settings_updated_success(mock_time, mock_ensure_ocr, mock_reload, mock_redis):
# Setup mock redis instance
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verify redis calls
mock_redis.assert_called_once()
mock_redis_instance.set.assert_called_once_with(SETTINGS_VERSION_KEY, "12345.0")
# Verify other calls
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_redis_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup mock redis to fail
mock_redis.side_effect = Exception("Redis connection failed")
notify_settings_updated()
# Verification: should continue and call reload and ocr despite redis failure
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not publish settings update to Redis: Redis connection failed" in caplog.text
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_reload_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup reload to fail
mock_reload.side_effect = Exception("Reload failed")
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verification: redis should be called, reload fails, ocr should still be called
mock_redis_instance.set.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not reload in-process settings: Reload failed" in caplog.text
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_notify_settings_updated_ocr_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
# Setup ocr check to fail
mock_ensure_ocr.side_effect = Exception("OCR check failed")
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
notify_settings_updated()
# Verification: all should be called, ocr failure logged
mock_redis_instance.set.assert_called_once()
mock_reload.assert_called_once()
assert "Could not schedule OCR language check: OCR check failed" in caplog.text
@patch("app.utils.settings_sync.task_prerun.connect")
def test_register_settings_reload_signal(mock_connect):
register_settings_reload_signal()
# It should register a signal with task_prerun
mock_connect.assert_called_once_with(weak=False)
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_new_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
# Capture the registered callback
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
mock_connect.assert_called_once_with(weak=False)
# Get the callback function
callback = mock_decorator.call_args[0][0]
# Setup redis to return a new version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"new_version"
mock_redis.return_value = mock_redis_instance
# Initial state check
assert app.utils.settings_sync._last_seen_version == ""
# Call the callback
callback(sender="test")
# Verification
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert app.utils.settings_sync._last_seen_version == "new_version"
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_same_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
# Set initial state
app.utils.settings_sync._last_seen_version = "existing_version"
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to return the SAME version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"existing_version"
mock_redis.return_value = mock_redis_instance
# Call the callback
callback(sender="test")
# Verification
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
# Should NOT reload or check OCR
mock_reload.assert_not_called()
mock_ensure_ocr.assert_not_called()
assert app.utils.settings_sync._last_seen_version == "existing_version"
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
def test_reload_if_stale_redis_error(mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog):
import logging
caplog.set_level(logging.DEBUG)
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to fail
mock_redis.side_effect = Exception("Redis error")
# Call the callback
callback(sender="test")
# Verification
mock_reload.assert_not_called()
assert "Settings version check skipped: Redis error" in caplog.text
@patch("app.utils.settings_sync.task_prerun.connect")
@patch("app.utils.settings_sync.redis.from_url")
@patch("app.utils.config_loader.reload_settings_from_db")
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
def test_reload_if_stale_ocr_error(
mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog
):
mock_decorator = MagicMock()
mock_connect.return_value = mock_decorator
register_settings_reload_signal()
callback = mock_decorator.call_args[0][0]
# Setup redis to return a new version
mock_redis_instance = MagicMock()
mock_redis_instance.get.return_value = b"new_version"
mock_redis.return_value = mock_redis_instance
# Setup OCR check to fail
mock_ensure_ocr.side_effect = Exception("OCR error")
# Call the callback
callback(sender="test")
# Verification
mock_reload.assert_called_once()
mock_ensure_ocr.assert_called_once()
assert "Could not schedule OCR language check on worker: OCR error" in caplog.text
assert app.utils.settings_sync._last_seen_version == "new_version"
+229 -14
View File
@@ -36,6 +36,63 @@ class TestGetRequiredSettings:
assert "session_secret" in keys
assert "openai_api_key" in keys
def test_sensitive_flags_correct(self):
"""Test that sensitive flag is set correctly for each setting."""
settings_map = {s["key"]: s for s in get_required_settings()}
# Sensitive settings
assert settings_map["session_secret"]["sensitive"] is True
assert settings_map["admin_password"]["sensitive"] is True
assert settings_map["openai_api_key"]["sensitive"] is True
# Non-sensitive settings
assert settings_map["database_url"]["sensitive"] is False
assert settings_map["redis_url"]["sensitive"] is False
assert settings_map["workdir"]["sensitive"] is False
assert settings_map["admin_username"]["sensitive"] is False
def test_wizard_step_assignments(self):
"""Test that settings are assigned to the correct wizard steps."""
settings_map = {s["key"]: s for s in get_required_settings()}
# Step 1: Core infrastructure
assert settings_map["database_url"]["wizard_step"] == 1
assert settings_map["redis_url"]["wizard_step"] == 1
assert settings_map["workdir"]["wizard_step"] == 1
assert settings_map["gotenberg_url"]["wizard_step"] == 1
# Step 2: Security
assert settings_map["session_secret"]["wizard_step"] == 2
assert settings_map["admin_username"]["wizard_step"] == 2
assert settings_map["admin_password"]["wizard_step"] == 2
# Step 3: AI Services
assert settings_map["ai_provider"]["wizard_step"] == 3
assert settings_map["openai_api_key"]["wizard_step"] == 3
assert settings_map["openai_model"]["wizard_step"] == 3
def test_wizard_categories_correct(self):
"""Test that wizard categories are set correctly."""
settings_map = {s["key"]: s for s in get_required_settings()}
assert settings_map["database_url"]["wizard_category"] == "Core Infrastructure"
assert settings_map["session_secret"]["wizard_category"] == "Security"
assert settings_map["ai_provider"]["wizard_category"] == "AI Services"
def test_ai_provider_has_options(self):
"""Test that ai_provider setting has a list of options."""
settings_map = {s["key"]: s for s in get_required_settings()}
ai_provider = settings_map["ai_provider"]
assert "options" in ai_provider
assert isinstance(ai_provider["options"], list)
assert len(ai_provider["options"]) > 0
assert "openai" in ai_provider["options"]
def test_settings_have_string_type(self):
"""Test that all settings have the 'string' type."""
for setting in get_required_settings():
assert setting["type"] == "string", f"Expected string type for {setting['key']}"
def test_total_settings_count(self):
"""Test that the expected number of required settings is returned."""
# Ensures no accidental additions or removals
result = get_required_settings()
assert len(result) == 10
@pytest.mark.unit
class TestIsSetupRequired:
@@ -46,32 +103,55 @@ class TestIsSetupRequired:
result = is_setup_required()
assert isinstance(result, bool)
def test_setup_required_with_test_key(self):
"""Test that setup is required when using test-key placeholder."""
# In test environment, openai_api_key is "test-key" which is a placeholder
def test_setup_required_when_admin_password_is_none(self):
"""Test that setup is required when admin_password is None (test environment default)."""
# In the test environment, admin_password defaults to None which is a placeholder value
result = is_setup_required()
assert result is True
@patch("app.utils.setup_wizard.settings")
def test_setup_not_required_with_real_values(self, mock_settings):
"""Test that setup is not required with real values."""
"""Test that setup is not required when both critical settings have real values.
is_setup_required() only checks session_secret and admin_password, so only
these two attributes need to be configured on the mock.
"""
mock_settings.session_secret = "a_very_long_real_session_secret_that_is_definitely_not_placeholder"
mock_settings.admin_password = "my_real_secure_password_123"
mock_settings.openai_api_key = "sk-real-key-12345"
mock_settings.azure_ai_key = "real-azure-key-12345"
result = is_setup_required()
assert result is False
@patch("app.utils.setup_wizard.settings")
def test_handles_exception_gracefully(self, mock_settings):
"""Test that exceptions are handled gracefully."""
mock_settings.session_secret = property(lambda self: (_ for _ in ()).throw(Exception("test")))
# getattr on a mock with side_effect
type(mock_settings).session_secret = property(lambda s: (_ for _ in ()).throw(RuntimeError("boom")))
# This should not raise - it returns False on error
def test_setup_required_with_insecure_session_secret(self, mock_settings):
"""Test that setup is required when session_secret is the insecure default."""
mock_settings.session_secret = "INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS"
mock_settings.admin_password = "real_password_123"
result = is_setup_required()
# May return True or False depending on which setting fails, but shouldn't raise
assert isinstance(result, bool)
assert result is True
@pytest.mark.parametrize(
"placeholder",
[None, "", "your_secure_password", "changeme", "admin"],
)
@patch("app.utils.setup_wizard.settings")
def test_setup_required_for_each_admin_password_placeholder(self, mock_settings, placeholder):
"""Test that setup is required for each admin_password placeholder value."""
mock_settings.session_secret = "a_very_long_real_session_secret_that_is_definitely_not_placeholder"
mock_settings.admin_password = placeholder
result = is_setup_required()
assert result is True
@patch("app.utils.setup_wizard.settings")
def test_handles_exception_gracefully(self, mock_settings):
"""Test that exceptions are handled gracefully and return False (fail open)."""
def raise_error():
raise RuntimeError("boom")
type(mock_settings).session_secret = property(lambda s: raise_error())
# This should not raise - it returns False on error (fail open)
result = is_setup_required()
assert result is False
@pytest.mark.unit
@@ -89,6 +169,102 @@ class TestGetMissingRequiredSettings:
# In test environment, openai_api_key is "test-key" which is a placeholder
assert "openai_api_key" in missing
@patch("app.utils.setup_wizard.settings")
def test_returns_empty_when_all_configured(self, mock_settings):
"""Test that returns empty list when all settings are properly configured."""
mock_settings.database_url = "sqlite:///./real.db"
mock_settings.redis_url = "redis://localhost:6379/0"
mock_settings.workdir = "/data/workdir"
mock_settings.gotenberg_url = "http://gotenberg:3000"
mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars"
mock_settings.admin_username = "admin"
mock_settings.admin_password = "real_secure_password_456"
mock_settings.ai_provider = "openai"
mock_settings.openai_api_key = "sk-real-key-12345"
mock_settings.openai_model = "gpt-4o-mini"
result = get_missing_required_settings()
assert result == []
@patch("app.utils.setup_wizard.settings")
def test_detects_none_value_as_missing(self, mock_settings):
"""Test that a None value is detected as missing."""
mock_settings.database_url = None
mock_settings.redis_url = "redis://localhost:6379/0"
mock_settings.workdir = "/data/workdir"
mock_settings.gotenberg_url = "http://gotenberg:3000"
mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars"
mock_settings.admin_username = "admin"
mock_settings.admin_password = "real_secure_password_456"
mock_settings.ai_provider = "openai"
mock_settings.openai_api_key = "sk-real-key-12345"
mock_settings.openai_model = "gpt-4o-mini"
missing = get_missing_required_settings()
assert "database_url" in missing
@patch("app.utils.setup_wizard.settings")
def test_detects_empty_string_as_missing(self, mock_settings):
"""Test that an empty string value is detected as missing."""
mock_settings.database_url = "sqlite:///./real.db"
mock_settings.redis_url = "redis://localhost:6379/0"
mock_settings.workdir = "/data/workdir"
mock_settings.gotenberg_url = "http://gotenberg:3000"
mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars"
mock_settings.admin_username = "admin"
mock_settings.admin_password = ""
mock_settings.ai_provider = "openai"
mock_settings.openai_api_key = "sk-real-key-12345"
mock_settings.openai_model = "gpt-4o-mini"
missing = get_missing_required_settings()
assert "admin_password" in missing
@patch("app.utils.setup_wizard.settings")
def test_detects_insecure_default_as_missing(self, mock_settings):
"""Test that the insecure default session_secret is detected as missing."""
mock_settings.database_url = "sqlite:///./real.db"
mock_settings.redis_url = "redis://localhost:6379/0"
mock_settings.workdir = "/data/workdir"
mock_settings.gotenberg_url = "http://gotenberg:3000"
mock_settings.session_secret = "INSECURE_DEFAULT_FOR_DEVELOPMENT_ONLY_DO_NOT_USE_IN_PRODUCTION_MINIMUM_32_CHARS"
mock_settings.admin_username = "admin"
mock_settings.admin_password = "real_secure_password_456"
mock_settings.ai_provider = "openai"
mock_settings.openai_api_key = "sk-real-key-12345"
mock_settings.openai_model = "gpt-4o-mini"
missing = get_missing_required_settings()
assert "session_secret" in missing
@patch("app.utils.setup_wizard.settings")
def test_detects_placeholder_bracket_format_as_missing(self, mock_settings):
"""Test that <KEY_NAME> formatted placeholders are detected as missing."""
mock_settings.database_url = "sqlite:///./real.db"
mock_settings.redis_url = "redis://localhost:6379/0"
mock_settings.workdir = "/data/workdir"
mock_settings.gotenberg_url = "http://gotenberg:3000"
mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars"
mock_settings.admin_username = "admin"
mock_settings.admin_password = "real_secure_password_456"
mock_settings.ai_provider = "openai"
mock_settings.openai_api_key = "<OPENAI_API_KEY>"
mock_settings.openai_model = "gpt-4o-mini"
missing = get_missing_required_settings()
assert "openai_api_key" in missing
@patch("app.utils.setup_wizard.settings")
def test_detects_test_key_placeholder_as_missing(self, mock_settings):
"""Test that 'test-key' is detected as a missing placeholder."""
mock_settings.database_url = "sqlite:///./real.db"
mock_settings.redis_url = "redis://localhost:6379/0"
mock_settings.workdir = "/data/workdir"
mock_settings.gotenberg_url = "http://gotenberg:3000"
mock_settings.session_secret = "a_very_long_real_session_secret_minimum_32_chars"
mock_settings.admin_username = "admin"
mock_settings.admin_password = "real_secure_password_456"
mock_settings.ai_provider = "openai"
mock_settings.openai_api_key = "test-key"
mock_settings.openai_model = "gpt-4o-mini"
missing = get_missing_required_settings()
assert "openai_api_key" in missing
@pytest.mark.unit
class TestGetWizardSteps:
@@ -121,3 +297,42 @@ class TestGetWizardSteps:
required_keys = [s["key"] for s in get_required_settings()]
for key in required_keys:
assert key in all_step_keys, f"Setting {key} not assigned to any wizard step"
def test_has_three_steps(self):
"""Test that there are exactly three wizard steps."""
steps = get_wizard_steps()
assert len(steps) == 3
assert set(steps.keys()) == {1, 2, 3}
def test_step_1_contains_infrastructure_settings(self):
"""Test that step 1 contains the core infrastructure settings."""
steps = get_wizard_steps()
step_1_keys = [s["key"] for s in steps[1]]
assert "database_url" in step_1_keys
assert "redis_url" in step_1_keys
assert "workdir" in step_1_keys
assert "gotenberg_url" in step_1_keys
def test_step_2_contains_security_settings(self):
"""Test that step 2 contains the security settings."""
steps = get_wizard_steps()
step_2_keys = [s["key"] for s in steps[2]]
assert "session_secret" in step_2_keys
assert "admin_username" in step_2_keys
assert "admin_password" in step_2_keys
def test_step_3_contains_ai_settings(self):
"""Test that step 3 contains the AI service settings."""
steps = get_wizard_steps()
step_3_keys = [s["key"] for s in steps[3]]
assert "ai_provider" in step_3_keys
assert "openai_api_key" in step_3_keys
assert "openai_model" in step_3_keys
def test_settings_not_duplicated_across_steps(self):
"""Test that no setting appears in more than one step."""
steps = get_wizard_steps()
all_keys = []
for settings_list in steps.values():
all_keys.extend([s["key"] for s in settings_list])
assert len(all_keys) == len(set(all_keys)), "Some settings appear in multiple steps"
+691
View File
@@ -0,0 +1,691 @@
"""Tests for the file sharing API (FileShare model and /api/files/{id}/shares endpoints)."""
import pytest
from app.models import FILE_SHARE_ROLE_EDITOR, FILE_SHARE_ROLE_VIEWER, FileRecord, FileShare, UserProfile
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _create_file(db_session, owner_id="owner1") -> FileRecord:
"""Create a minimal owned FileRecord."""
f = FileRecord(
owner_id=owner_id,
filehash="sharehash",
original_filename="shared.pdf",
local_filename="shared.pdf",
file_size=1024,
mime_type="application/pdf",
)
db_session.add(f)
db_session.commit()
db_session.refresh(f)
return f
def _create_unowned_file(db_session) -> FileRecord:
"""Create a FileRecord with no owner."""
f = FileRecord(
owner_id=None,
filehash="unownedhash",
original_filename="unowned.pdf",
local_filename="unowned.pdf",
file_size=512,
mime_type="application/pdf",
)
db_session.add(f)
db_session.commit()
db_session.refresh(f)
return f
def _create_profile(db_session, user_id: str, display_name: str | None = None) -> UserProfile:
p = UserProfile(user_id=user_id, display_name=display_name)
db_session.add(p)
db_session.commit()
db_session.refresh(p)
return p
# ---------------------------------------------------------------------------
# get_file_role helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetFileRole:
"""Tests for the get_file_role() utility."""
def test_owner_returns_owner(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import get_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
assert get_file_role(f, "alice", db_session) == "owner"
def test_non_owner_no_share_returns_none(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import get_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
assert get_file_role(f, "bob", db_session) is None
def test_shared_viewer_returns_viewer(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import get_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role=FILE_SHARE_ROLE_VIEWER)
db_session.add(share)
db_session.commit()
assert get_file_role(f, "bob", db_session) == FILE_SHARE_ROLE_VIEWER
def test_shared_editor_returns_editor(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import get_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="carol", role=FILE_SHARE_ROLE_EDITOR)
db_session.add(share)
db_session.commit()
assert get_file_role(f, "carol", db_session) == FILE_SHARE_ROLE_EDITOR
def test_unowned_file_returns_viewer_when_setting_allows(self, db_session, monkeypatch):
from app.utils import user_scope
monkeypatch.setattr(user_scope.settings, "multi_user_enabled", True)
monkeypatch.setattr(user_scope.settings, "unowned_docs_visible_to_all", True)
f = _create_unowned_file(db_session)
role = user_scope.get_file_role(f, "anyone", db_session)
assert role == FILE_SHARE_ROLE_VIEWER
def test_none_user_returns_none(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import get_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
assert get_file_role(f, None, db_session) is None
# ---------------------------------------------------------------------------
# has_file_role helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestHasFileRole:
"""Tests for the has_file_role() utility."""
def test_owner_satisfies_viewer(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import has_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
assert has_file_role(f, "alice", db_session, minimum_role="viewer") is True
def test_owner_satisfies_editor(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import has_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
assert has_file_role(f, "alice", db_session, minimum_role="editor") is True
def test_owner_satisfies_owner(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import has_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
assert has_file_role(f, "alice", db_session, minimum_role="owner") is True
def test_viewer_does_not_satisfy_editor(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import has_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role=FILE_SHARE_ROLE_VIEWER)
db_session.add(share)
db_session.commit()
assert has_file_role(f, "bob", db_session, minimum_role="editor") is False
def test_editor_satisfies_viewer(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import has_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="carol", role=FILE_SHARE_ROLE_EDITOR)
db_session.add(share)
db_session.commit()
assert has_file_role(f, "carol", db_session, minimum_role="viewer") is True
def test_no_access_returns_false(self, db_session, monkeypatch):
from app.config import settings as real_settings
from app.utils.user_scope import has_file_role
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
assert has_file_role(f, "stranger", db_session) is False
# ---------------------------------------------------------------------------
# List shares
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListShares:
"""Tests for GET /api/files/{file_id}/shares."""
def test_owner_can_list_shares(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role=FILE_SHARE_ROLE_VIEWER)
db_session.add(share)
db_session.commit()
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
resp = client.get(f"/api/files/{f.id}/shares")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["shared_with_user_id"] == "bob"
assert data[0]["role"] == FILE_SHARE_ROLE_VIEWER
def test_non_owner_cannot_list_shares(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
f = _create_file(db_session, owner_id="alice")
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "bob")
# bob has no access to alice's file — the get_file_role call in list_shares
# will return None for bob, giving 404 not 403 (file not found for bob)
resp = client.get(f"/api/files/{f.id}/shares")
assert resp.status_code in (403, 404)
def test_list_shares_file_not_found(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
resp = client.get("/api/files/99999/shares")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Create share
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestCreateShare:
"""Tests for POST /api/files/{file_id}/shares."""
def test_owner_can_share_with_viewer(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.post(
f"/api/files/{f.id}/shares",
json={"shared_with_user_id": "bob", "role": "viewer"},
)
assert resp.status_code == 201
data = resp.json()
assert data["shared_with_user_id"] == "bob"
assert data["role"] == "viewer"
assert data["file_id"] == f.id
def test_owner_can_share_with_editor(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.post(
f"/api/files/{f.id}/shares",
json={"shared_with_user_id": "carol", "role": "editor"},
)
assert resp.status_code == 201
assert resp.json()["role"] == "editor"
def test_non_owner_cannot_share(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "bob")
f = _create_file(db_session, owner_id="alice")
resp = client.post(
f"/api/files/{f.id}/shares",
json={"shared_with_user_id": "carol", "role": "viewer"},
)
# bob doesn't own the file; get_file_role returns None → 404 for non-owner
assert resp.status_code in (403, 404)
def test_share_with_self_rejected(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.post(
f"/api/files/{f.id}/shares",
json={"shared_with_user_id": "alice", "role": "viewer"},
)
assert resp.status_code == 422
def test_invalid_role_rejected(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.post(
f"/api/files/{f.id}/shares",
json={"shared_with_user_id": "bob", "role": "admin"},
)
assert resp.status_code == 422
def test_empty_user_id_rejected(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.post(
f"/api/files/{f.id}/shares",
json={"shared_with_user_id": " ", "role": "viewer"},
)
assert resp.status_code == 422
def test_duplicate_share_updates_role(self, client, db_session, monkeypatch):
"""Creating a share for an already-shared user updates the role."""
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer")
db_session.add(share)
db_session.commit()
resp = client.post(
f"/api/files/{f.id}/shares",
json={"shared_with_user_id": "bob", "role": "editor"},
)
assert resp.status_code == 201
assert resp.json()["role"] == "editor"
# ---------------------------------------------------------------------------
# Update share role
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUpdateShare:
"""Tests for PUT /api/files/{file_id}/shares/{share_id}."""
def test_owner_can_update_role(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer")
db_session.add(share)
db_session.commit()
db_session.refresh(share)
resp = client.put(
f"/api/files/{f.id}/shares/{share.id}",
json={"role": "editor"},
)
assert resp.status_code == 200
assert resp.json()["role"] == "editor"
def test_non_owner_cannot_update_role(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "bob")
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer")
db_session.add(share)
db_session.commit()
db_session.refresh(share)
resp = client.put(
f"/api/files/{f.id}/shares/{share.id}",
json={"role": "editor"},
)
assert resp.status_code in (403, 404)
def test_invalid_role_rejected(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer")
db_session.add(share)
db_session.commit()
db_session.refresh(share)
resp = client.put(
f"/api/files/{f.id}/shares/{share.id}",
json={"role": "superuser"},
)
assert resp.status_code == 422
def test_share_not_found(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.put(
f"/api/files/{f.id}/shares/99999",
json={"role": "editor"},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Revoke share
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRevokeShare:
"""Tests for DELETE /api/files/{file_id}/shares/{share_id}."""
def test_owner_can_revoke(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer")
db_session.add(share)
db_session.commit()
db_session.refresh(share)
resp = client.delete(f"/api/files/{f.id}/shares/{share.id}")
assert resp.status_code == 200
assert resp.json()["status"] == "success"
# Confirm the share is gone
db_session.expire_all()
assert db_session.query(FileShare).filter(FileShare.id == share.id).first() is None
def test_non_owner_cannot_revoke(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "carol")
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer")
db_session.add(share)
db_session.commit()
db_session.refresh(share)
resp = client.delete(f"/api/files/{f.id}/shares/{share.id}")
assert resp.status_code in (403, 404)
def test_revoke_not_found(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.delete(f"/api/files/{f.id}/shares/99999")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# List shared-with
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListSharedWith:
"""Tests for GET /api/files/{file_id}/shared-with."""
def test_owner_can_see_shared_with(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
_create_profile(db_session, "bob", "Bob Smith")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer")
db_session.add(share)
db_session.commit()
resp = client.get(f"/api/files/{f.id}/shared-with")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["user_id"] == "bob"
assert data[0]["display_name"] == "Bob Smith"
assert data[0]["role"] == "viewer"
def test_viewer_can_see_shared_with(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "bob")
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer")
db_session.add(share)
db_session.commit()
resp = client.get(f"/api/files/{f.id}/shared-with")
assert resp.status_code == 200
def test_unauthorized_user_gets_404(self, client, db_session, monkeypatch):
import app.api.sharing as sharing_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(sharing_mod, "get_current_owner_id", lambda req: "stranger")
f = _create_file(db_session, owner_id="alice")
resp = client.get(f"/api/files/{f.id}/shared-with")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Auto-share on mention
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAutoShareOnMention:
"""Tests that @mentioning a user in a comment auto-shares the file."""
def test_mention_auto_shares_with_viewer(self, client, db_session, monkeypatch):
"""When multi_user_enabled is True, mentioning a user auto-shares the file."""
import app.api.comments as comments_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(comments_mod, "get_current_owner_id", lambda req: "alice")
monkeypatch.setattr(comments_mod, "get_current_user_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "Hey @bob, please look at this."},
)
assert resp.status_code == 201
# bob should now have a viewer share on the file
share = (
db_session.query(FileShare)
.filter(FileShare.file_id == f.id, FileShare.shared_with_user_id == "bob")
.first()
)
assert share is not None
assert share.role == FILE_SHARE_ROLE_VIEWER
def test_mention_does_not_duplicate_share(self, client, db_session, monkeypatch):
"""Mentioning a user that already has a share does not create a duplicate."""
import app.api.comments as comments_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(comments_mod, "get_current_owner_id", lambda req: "alice")
monkeypatch.setattr(comments_mod, "get_current_user_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
existing = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="editor")
db_session.add(existing)
db_session.commit()
existing_id = existing.id
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "Hey @bob again!"},
)
assert resp.status_code == 201
shares = (
db_session.query(FileShare).filter(FileShare.file_id == f.id, FileShare.shared_with_user_id == "bob").all()
)
assert len(shares) == 1
assert shares[0].id == existing_id
assert shares[0].role == "editor" # role unchanged
def test_mention_skipped_when_single_user_mode(self, client, db_session, monkeypatch):
import app.api.comments as comments_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", False)
monkeypatch.setattr(comments_mod, "get_current_owner_id", lambda req: "alice")
monkeypatch.setattr(comments_mod, "get_current_user_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.post(
f"/api/files/{f.id}/comments",
json={"body": "Hey @carol, look here."},
)
assert resp.status_code == 201
share = (
db_session.query(FileShare)
.filter(FileShare.file_id == f.id, FileShare.shared_with_user_id == "carol")
.first()
)
assert share is None
# ---------------------------------------------------------------------------
# Delete file owner-only enforcement
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteFileOwnerOnly:
"""Ensure non-owners (shared viewers/editors) cannot delete files."""
def test_owner_can_delete_in_multi_user_mode(self, client, db_session, monkeypatch):
import app.api.files as files_mod
import app.utils.user_scope as user_scope_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(real_settings, "allow_file_delete", True)
monkeypatch.setattr(files_mod, "get_current_owner_id", lambda req: "alice")
monkeypatch.setattr(user_scope_mod, "get_current_owner_id", lambda req: "alice")
f = _create_file(db_session, owner_id="alice")
resp = client.delete(f"/api/files/{f.id}")
assert resp.status_code == 200
def test_viewer_cannot_delete(self, client, db_session, monkeypatch):
import app.api.files as files_mod
import app.utils.user_scope as user_scope_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(real_settings, "allow_file_delete", True)
monkeypatch.setattr(files_mod, "get_current_owner_id", lambda req: "bob")
monkeypatch.setattr(user_scope_mod, "get_current_owner_id", lambda req: "bob")
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="bob", role="viewer")
db_session.add(share)
db_session.commit()
resp = client.delete(f"/api/files/{f.id}")
assert resp.status_code == 403
def test_editor_cannot_delete(self, client, db_session, monkeypatch):
import app.api.files as files_mod
import app.utils.user_scope as user_scope_mod
from app.config import settings as real_settings
monkeypatch.setattr(real_settings, "multi_user_enabled", True)
monkeypatch.setattr(real_settings, "allow_file_delete", True)
monkeypatch.setattr(files_mod, "get_current_owner_id", lambda req: "carol")
monkeypatch.setattr(user_scope_mod, "get_current_owner_id", lambda req: "carol")
f = _create_file(db_session, owner_id="alice")
share = FileShare(file_id=f.id, owner_id="alice", shared_with_user_id="carol", role="editor")
db_session.add(share)
db_session.commit()
resp = client.delete(f"/api/files/{f.id}")
assert resp.status_code == 403
+2 -2
View File
@@ -345,7 +345,7 @@ class TestLoginPageSocialProviders:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs.get("context", {})
assert context["social_providers"] == mock_providers
@pytest.mark.asyncio
@@ -371,7 +371,7 @@ class TestLoginPageSocialProviders:
mock_templates.TemplateResponse.assert_called_once()
call_args = mock_templates.TemplateResponse.call_args
context = call_args[0][1]
context = call_args.kwargs.get("context", {})
assert context["social_providers"] == {}
+393
View File
@@ -0,0 +1,393 @@
"""Tests for the system reset feature (app/api/system_reset.py, app/utils/system_reset.py, app/views/system_reset.py)."""
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import (
DocumentMetadata,
FileProcessingStep,
FileRecord,
ProcessingLog,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def reset_workdir():
"""Create a temporary workdir populated with sample user data."""
with tempfile.TemporaryDirectory() as tmpdir:
# Create data subdirectories with dummy files
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
d = Path(tmpdir) / subdir
d.mkdir()
(d / "sample.pdf").write_bytes(b"%PDF-1.4 fake")
# Create cache files
for cache in ("watch_folder_processed.json", "ftp_ingest_processed.json"):
(Path(tmpdir) / cache).write_text("{}")
# Create a per-user watch folder cache
(Path(tmpdir) / "user_wf_42.json").write_text("{}")
# Create a loose PDF in workdir root
(Path(tmpdir) / "abc123.pdf").write_bytes(b"%PDF-1.4 loose")
yield tmpdir
@pytest.fixture
def reset_db_session():
"""Fresh in-memory database with sample user data rows."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
session = Session()
# Seed with sample data
fr = FileRecord(
filehash="abc123",
original_filename="test.pdf",
local_filename="uuid.pdf",
file_size=1024,
mime_type="application/pdf",
)
session.add(fr)
session.flush()
session.add(ProcessingLog(file_id=fr.id, task_id="t1", step_name="hash_file", status="success"))
session.add(FileProcessingStep(file_id=fr.id, step_name="hash_file", status="success"))
session.add(DocumentMetadata(filename="test.pdf", sender="Alice", recipient="Bob"))
session.commit()
yield session
session.close()
Base.metadata.drop_all(bind=engine)
# ---------------------------------------------------------------------------
# Unit tests for app/utils/system_reset.py
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWipeWorkdirData:
"""Tests for _wipe_workdir_data()."""
def test_removes_data_subdirs(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
result = _wipe_workdir_data(reset_workdir)
# All data subdirectories should be gone
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
assert not (Path(reset_workdir) / subdir).exists()
assert result["deleted_dirs"] == 5
def test_removes_cache_files(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
result = _wipe_workdir_data(reset_workdir)
assert not (Path(reset_workdir) / "watch_folder_processed.json").exists()
assert not (Path(reset_workdir) / "ftp_ingest_processed.json").exists()
assert not (Path(reset_workdir) / "user_wf_42.json").exists()
assert result["deleted_files"] >= 3
def test_removes_loose_document_files(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
_wipe_workdir_data(reset_workdir)
assert not (Path(reset_workdir) / "abc123.pdf").exists()
def test_preserves_workdir_directory(self, reset_workdir):
from app.utils.system_reset import _wipe_workdir_data
_wipe_workdir_data(reset_workdir)
assert Path(reset_workdir).is_dir()
def test_handles_empty_workdir(self):
"""No errors when workdir has no data dirs or caches."""
from app.utils.system_reset import _wipe_workdir_data
with tempfile.TemporaryDirectory() as empty_dir:
result = _wipe_workdir_data(empty_dir)
assert result["deleted_dirs"] == 0
assert result["deleted_files"] == 0
@pytest.mark.unit
class TestWipeDatabase:
"""Tests for _wipe_database()."""
def test_deletes_all_user_data(self, reset_db_session):
from app.utils.system_reset import _wipe_database
result = _wipe_database(reset_db_session)
assert result.get("files", 0) >= 1
assert result.get("processing_logs", 0) >= 1
assert result.get("file_processing_steps", 0) >= 1
assert result.get("document_metadata", 0) >= 1
def test_tables_are_empty_after_wipe(self, reset_db_session):
from app.utils.system_reset import _wipe_database
_wipe_database(reset_db_session)
assert reset_db_session.query(FileRecord).count() == 0
assert reset_db_session.query(ProcessingLog).count() == 0
assert reset_db_session.query(FileProcessingStep).count() == 0
assert reset_db_session.query(DocumentMetadata).count() == 0
@pytest.mark.unit
class TestPerformFullReset:
"""Tests for perform_full_reset()."""
def test_wipes_db_and_filesystem(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_full_reset
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
result = perform_full_reset(reset_db_session)
assert "database" in result
assert "filesystem" in result
assert reset_db_session.query(FileRecord).count() == 0
assert not (Path(reset_workdir) / "original").exists()
@pytest.mark.unit
class TestPerformResetAndReimport:
"""Tests for perform_reset_and_reimport()."""
def test_copies_originals_to_reimport_then_wipes(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_reset_and_reimport
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
mock_settings.watch_folders = ""
mock_settings.watch_folder_delete_after_process = False
result = perform_reset_and_reimport(reset_db_session)
reimport_dir = Path(reset_workdir) / "reimport"
assert reimport_dir.is_dir()
assert result["reimport"]["files_moved"] >= 1
# DB should be wiped
assert reset_db_session.query(FileRecord).count() == 0
# Reimport folder should contain the original file
reimport_files = list(reimport_dir.iterdir())
assert len(reimport_files) >= 1
def test_configures_watch_folder(self, reset_db_session, reset_workdir):
from app.utils.system_reset import perform_reset_and_reimport
with patch("app.utils.system_reset.settings") as mock_settings:
mock_settings.workdir = reset_workdir
mock_settings.watch_folders = "/some/other/folder"
mock_settings.watch_folder_delete_after_process = False
perform_reset_and_reimport(reset_db_session)
reimport_path = str(Path(reset_workdir) / "reimport")
# watch_folders should now include the reimport path
assert reimport_path in mock_settings.watch_folders
@pytest.mark.unit
class TestStartupReset:
"""Tests for perform_startup_reset()."""
def test_startup_reset_calls_full_reset(self):
from app.utils.system_reset import perform_startup_reset
with patch("app.utils.system_reset.perform_full_reset") as mock_reset:
with patch("app.database.SessionLocal") as mock_sl:
mock_db = mock_sl.return_value
perform_startup_reset()
mock_reset.assert_called_once_with(mock_db)
mock_db.close.assert_called_once()
def test_startup_reset_handles_errors(self):
from app.utils.system_reset import perform_startup_reset
with patch("app.utils.system_reset.perform_full_reset", side_effect=RuntimeError("boom")):
with patch("app.database.SessionLocal") as mock_sl:
mock_db = mock_sl.return_value
# Should not raise
perform_startup_reset()
mock_db.rollback.assert_called_once()
mock_db.close.assert_called_once()
# ---------------------------------------------------------------------------
# Integration tests for API endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSystemResetApi:
"""Tests for the /api/admin/system-reset/ endpoints."""
def test_full_reset_requires_admin(self, client):
"""Non-admin users get 403."""
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
assert response.status_code == 403
def test_full_reset_requires_feature_flag(self, client):
"""Returns 404 when ENABLE_FACTORY_RESET is false."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = False
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 404
def test_full_reset_requires_confirmation(self, client):
"""Wrong confirmation string gets 400."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "WRONG"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 400
def test_reimport_requires_confirmation(self, client):
"""Wrong confirmation string gets 400."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
response = client.post(
"/api/admin/system-reset/reimport",
json={"confirmation": "WRONG"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 400
def test_status_endpoint(self, client):
"""The status endpoint returns feature-flag state."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
response = client.get("/api/admin/system-reset/status")
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
data = response.json()
assert "enabled" in data
assert "factory_reset_on_startup" in data
def test_full_reset_success(self, client):
"""Full reset succeeds with correct confirmation and feature flag."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
with patch(
"app.utils.system_reset.perform_full_reset", return_value={"database": {}, "filesystem": {}}
):
response = client.post(
"/api/admin/system-reset/full",
json={"confirmation": "DELETE"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_reimport_success(self, client):
"""Reimport succeeds with correct confirmation."""
from app.api.system_reset import _require_admin
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
try:
with patch("app.api.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
with patch(
"app.utils.system_reset.perform_reset_and_reimport",
return_value={"database": {}, "filesystem": {}, "reimport": {"files_moved": 3}},
):
response = client.post(
"/api/admin/system-reset/reimport",
json={"confirmation": "REIMPORT"},
)
finally:
client.app.dependency_overrides.pop(_require_admin, None)
assert response.status_code == 200
assert response.json()["status"] == "ok"
# ---------------------------------------------------------------------------
# Integration tests for the view
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSystemResetView:
"""Tests for the /admin/system-reset view."""
def test_view_redirects_when_disabled(self, client):
"""When ENABLE_FACTORY_RESET=False, accessing the page redirects away."""
with client:
client.cookies.set("session", "test")
with patch("app.views.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = False
response = client.get("/admin/system-reset", follow_redirects=False)
# Redirect to /settings (302) when disabled, or to login (302/307) when unauthenticated
assert response.status_code in (302, 307)
def test_view_requires_auth(self, client):
"""Unauthenticated users are redirected away from the page."""
with patch("app.views.system_reset.settings") as mock_s:
mock_s.enable_factory_reset = True
mock_s.factory_reset_on_startup = False
response = client.get("/admin/system-reset", follow_redirects=False)
# Should redirect to login since there's no active session
assert response.status_code in (302, 307)
+478
View File
@@ -0,0 +1,478 @@
"""Tests for document translation feature.
Covers:
- translate_to_default_language Celery task
- /api/files/{id}/translate on-the-fly translation endpoint
- /api/files/{id}/translation/default stored translation endpoint
- /files/{id}/text/default-language view endpoint
- _resolve_default_language helper
"""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.models import FileRecord, UserProfile
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def file_with_ocr(db_session):
"""Create a FileRecord with OCR text and detected language."""
record = FileRecord(
filehash="abc123translationtest",
local_filename="/tmp/test_translate.pdf",
file_size=1024,
mime_type="application/pdf",
original_filename="test_translate.pdf",
ocr_text="Dies ist ein Testdokument in deutscher Sprache.",
detected_language="de",
)
db_session.add(record)
db_session.commit()
db_session.refresh(record)
return record
@pytest.fixture
def file_with_translation(db_session):
"""Create a FileRecord with a persisted default-language translation."""
record = FileRecord(
filehash="def456translationtest",
local_filename="/tmp/test_translated.pdf",
file_size=2048,
mime_type="application/pdf",
original_filename="test_translated.pdf",
ocr_text="Ceci est un document de test en français.",
detected_language="fr",
default_language_text="This is a test document in French.",
default_language_code="en",
)
db_session.add(record)
db_session.commit()
db_session.refresh(record)
return record
@pytest.fixture
def file_without_ocr(db_session):
"""Create a FileRecord without OCR text."""
record = FileRecord(
filehash="ghi789translationtest",
local_filename="/tmp/test_no_ocr.pdf",
file_size=512,
mime_type="application/pdf",
original_filename="test_no_ocr.pdf",
)
db_session.add(record)
db_session.commit()
db_session.refresh(record)
return record
@pytest.fixture
def user_profile_with_language(db_session):
"""Create a UserProfile with a custom default_document_language."""
profile = UserProfile(
user_id="test-user-lang",
default_document_language="de",
)
db_session.add(profile)
db_session.commit()
db_session.refresh(profile)
return profile
# ---------------------------------------------------------------------------
# Model tests
# ---------------------------------------------------------------------------
class TestFileRecordTranslationFields:
"""Verify that the new translation columns exist on FileRecord."""
@pytest.mark.unit
def test_detected_language_column(self, file_with_ocr):
assert file_with_ocr.detected_language == "de"
@pytest.mark.unit
def test_default_language_text_column(self, file_with_translation):
assert file_with_translation.default_language_text == "This is a test document in French."
@pytest.mark.unit
def test_default_language_code_column(self, file_with_translation):
assert file_with_translation.default_language_code == "en"
@pytest.mark.unit
def test_translation_columns_nullable(self, file_with_ocr):
"""Translation columns should be NULL when no translation exists."""
assert file_with_ocr.default_language_text is None
assert file_with_ocr.default_language_code is None
class TestUserProfileDefaultLanguage:
"""Verify UserProfile.default_document_language column."""
@pytest.mark.unit
def test_default_document_language_set(self, user_profile_with_language):
assert user_profile_with_language.default_document_language == "de"
@pytest.mark.unit
def test_default_document_language_nullable(self, db_session):
profile = UserProfile(user_id="test-user-no-lang")
db_session.add(profile)
db_session.commit()
db_session.refresh(profile)
assert profile.default_document_language is None
# ---------------------------------------------------------------------------
# Celery task tests
# ---------------------------------------------------------------------------
class TestTranslateToDefaultLanguageTask:
"""Tests for the translate_to_default_language Celery task."""
@pytest.mark.unit
@patch("app.tasks.translate_to_default_language.get_ai_provider")
def test_translate_stores_result(self, mock_provider_fn, db_session, file_with_ocr):
"""Successful translation is persisted to the FileRecord."""
mock_provider = MagicMock()
mock_provider.chat_completion.return_value = "This is a test document in German."
mock_provider_fn.return_value = mock_provider
from app.tasks.translate_to_default_language import translate_to_default_language
# Patch SessionLocal to use our test session
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=db_session)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_session_cls.return_value = mock_ctx
task = translate_to_default_language
# Call the underlying function (not .delay) for synchronous testing
result = task.apply(
args=[file_with_ocr.id, file_with_ocr.ocr_text, "de"],
kwargs={"owner_id": None},
).get()
assert result["status"] == "success"
assert result["target_language"] == "en"
# Verify it was stored
db_session.refresh(file_with_ocr)
assert file_with_ocr.default_language_text == "This is a test document in German."
assert file_with_ocr.default_language_code == "en"
assert file_with_ocr.detected_language == "de"
@pytest.mark.unit
@patch("app.tasks.translate_to_default_language.get_ai_provider")
def test_skip_when_already_in_target_language(self, mock_provider_fn, db_session, file_with_ocr):
"""No translation when document language matches default target."""
file_with_ocr.detected_language = "en"
db_session.commit()
from app.tasks.translate_to_default_language import translate_to_default_language
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=db_session)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_session_cls.return_value = mock_ctx
result = translate_to_default_language.apply(
args=[file_with_ocr.id, file_with_ocr.ocr_text, "en"],
).get()
assert result["status"] == "skipped"
mock_provider_fn.assert_not_called()
@pytest.mark.unit
def test_resolve_default_language_global(self):
"""Falls back to the global setting when no user profile override."""
from app.tasks.translate_to_default_language import _resolve_default_language
with patch("app.tasks.translate_to_default_language.settings") as mock_settings:
mock_settings.default_document_language = "en"
assert _resolve_default_language(None) == "en"
@pytest.mark.unit
def test_resolve_default_language_user_override(self, db_session, user_profile_with_language):
"""Per-user override is used when available."""
from app.tasks.translate_to_default_language import _resolve_default_language
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
mock_ctx = MagicMock()
mock_ctx.__enter__ = MagicMock(return_value=db_session)
mock_ctx.__exit__ = MagicMock(return_value=False)
mock_session_cls.return_value = mock_ctx
result = _resolve_default_language("test-user-lang")
assert result == "de"
# ---------------------------------------------------------------------------
# API endpoint tests
# ---------------------------------------------------------------------------
class TestDefaultTranslationEndpoint:
"""Tests for GET /api/files/{id}/translation/default."""
@pytest.mark.integration
def test_returns_default_translation(self, client: TestClient, file_with_translation):
response = client.get(f"/api/files/{file_with_translation.id}/translation/default")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in French."
assert data["default_language_code"] == "en"
assert data["detected_language"] == "fr"
assert data["file_id"] == file_with_translation.id
@pytest.mark.integration
def test_404_when_no_translation(self, client: TestClient, file_with_ocr):
response = client.get(f"/api/files/{file_with_ocr.id}/translation/default")
assert response.status_code == 404
@pytest.mark.integration
def test_404_for_nonexistent_file(self, client: TestClient):
response = client.get("/api/files/999999/translation/default")
assert response.status_code == 404
class TestOnTheFlyTranslateEndpoint:
"""Tests for GET /api/files/{id}/translate?lang=xx."""
@pytest.mark.integration
@patch("app.api.translation.get_ai_provider")
def test_translate_on_the_fly(self, mock_provider_fn, client: TestClient, file_with_ocr):
mock_provider = MagicMock()
mock_provider.chat_completion.return_value = "This is a test document in German language."
mock_provider_fn.return_value = mock_provider
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=en")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in German language."
assert data["target_language"] == "en"
assert data["cached"] is False
@pytest.mark.integration
def test_returns_cached_default_language(self, client: TestClient, file_with_translation):
"""If the requested language matches the stored default, return cached text."""
response = client.get(f"/api/files/{file_with_translation.id}/translate?lang=en")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in French."
assert data["cached"] is True
@pytest.mark.integration
def test_returns_original_when_same_language(self, client: TestClient, file_with_ocr):
"""Return the original text when target matches detected language."""
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=de")
assert response.status_code == 200
data = response.json()
assert data["text"] == file_with_ocr.ocr_text
assert data["cached"] is True
@pytest.mark.integration
def test_400_when_no_ocr_text(self, client: TestClient, file_without_ocr):
response = client.get(f"/api/files/{file_without_ocr.id}/translate?lang=en")
assert response.status_code == 400
@pytest.mark.integration
def test_missing_lang_param(self, client: TestClient, file_with_ocr):
response = client.get(f"/api/files/{file_with_ocr.id}/translate")
assert response.status_code == 422 # validation error
@pytest.mark.integration
def test_404_for_nonexistent_file(self, client: TestClient):
response = client.get("/api/files/999999/translate?lang=en")
assert response.status_code == 404
@pytest.mark.integration
@patch("app.api.translation.get_ai_provider")
def test_502_on_provider_error(self, mock_provider_fn, client: TestClient, file_with_ocr):
mock_provider = MagicMock()
mock_provider.chat_completion.side_effect = RuntimeError("AI error")
mock_provider_fn.return_value = mock_provider
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=fr")
assert response.status_code == 502
# ---------------------------------------------------------------------------
# View endpoint tests
# ---------------------------------------------------------------------------
class TestDefaultLanguageTextView:
"""Tests for GET /files/{id}/text/default-language."""
@pytest.mark.integration
def test_returns_default_language_text(self, client: TestClient, file_with_translation):
response = client.get(f"/files/{file_with_translation.id}/text/default-language")
assert response.status_code == 200
data = response.json()
assert data["text"] == "This is a test document in French."
assert data["language_code"] == "en"
assert data["detected_language"] == "fr"
@pytest.mark.integration
def test_404_when_no_default_text(self, client: TestClient, file_with_ocr):
response = client.get(f"/files/{file_with_ocr.id}/text/default-language")
assert response.status_code == 404
@pytest.mark.integration
def test_404_for_nonexistent_file(self, client: TestClient):
response = client.get("/files/999999/text/default-language")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------
class TestDefaultDocumentLanguageConfig:
"""Verify the DEFAULT_DOCUMENT_LANGUAGE setting."""
@pytest.mark.unit
def test_default_value_is_english(self):
from app.config import settings
assert settings.default_document_language == "en"
# ---------------------------------------------------------------------------
# Profile API integration tests
# ---------------------------------------------------------------------------
class TestProfileDefaultDocumentLanguage:
"""Tests for default_document_language in the profile API."""
@pytest.fixture
def prof_engine(self):
"""In-memory SQLite engine for profile tests."""
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from app.database import Base
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
@pytest.fixture
def prof_session(self, prof_engine):
"""DB session for profile tests."""
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=self.prof_engine if hasattr(self, "prof_engine") else prof_engine)
session = Session()
yield session
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_get_profile_includes_default_document_language(self, prof_engine):
"""GET handler returns default_document_language in response."""
from unittest.mock import MagicMock
from sqlalchemy.orm import sessionmaker
from app.api.profile import get_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser", "email": "lang@test.com"}}
result = await get_profile(req, session)
assert hasattr(result, "default_document_language")
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_update_default_document_language(self, prof_engine):
"""PATCH handler updates default_document_language."""
from unittest.mock import MagicMock
from sqlalchemy.orm import sessionmaker
from app.api.profile import ProfileUpdateRequest, update_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser2", "email": "lang2@test.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(default_document_language="de")
result = await update_profile(body, req, resp, session)
assert result.default_document_language == "de"
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_clear_default_document_language(self, prof_engine):
"""Setting default_document_language to empty string clears it."""
from unittest.mock import MagicMock
from sqlalchemy.orm import sessionmaker
from app.api.profile import ProfileUpdateRequest, update_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser3", "email": "lang3@test.com"}}
resp = MagicMock()
# Set
body = ProfileUpdateRequest(default_document_language="fr")
await update_profile(body, req, resp, session)
# Clear
body = ProfileUpdateRequest(default_document_language="")
result = await update_profile(body, req, resp, session)
assert result.default_document_language is None
session.close()
@pytest.mark.asyncio
@pytest.mark.unit
async def test_reject_invalid_default_document_language(self, prof_engine):
"""Invalid language codes are rejected with 422."""
from unittest.mock import MagicMock
from fastapi import HTTPException
from sqlalchemy.orm import sessionmaker
from app.api.profile import ProfileUpdateRequest, update_profile
Session = sessionmaker(bind=prof_engine)
session = Session()
req = MagicMock()
req.session = {"user": {"preferred_username": "languser4", "email": "lang4@test.com"}}
resp = MagicMock()
body = ProfileUpdateRequest(default_document_language="xx_invalid")
with pytest.raises(HTTPException) as exc_info:
await update_profile(body, req, resp, session)
assert exc_info.value.status_code == 422
session.close()
+89
View File
@@ -121,6 +121,95 @@ class TestExtractMetadataFromFile:
assert result == {}
def test_extract_metadata_from_pdf(self, tmp_path):
"""Test extracting metadata from a PDF file using pypdf when JSON is missing."""
import pypdf
file_path = tmp_path / "test.pdf"
# Create a test PDF with metadata
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "Test Title",
"/Author": "Test Author",
"/Subject": "Test Document",
"/Keywords": "test, metadata, pypdf",
}
)
with open(file_path, "wb") as f:
writer.write(f)
result = extract_metadata_from_file(str(file_path))
# Keys are mapped to application-specific names
assert result.get("filename") == "Test Title"
assert result.get("absender") == "Test Author"
assert result.get("document_type") == "Test Document"
assert result.get("tags") == "test, metadata, pypdf"
def test_extracts_embedded_metadata_from_pdf(self, tmp_path):
"""Test that embedded PDF metadata is mapped to application-specific keys."""
import pypdf
file_path = tmp_path / "mapped.pdf"
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "Invoice 2024",
"/Author": "Acme Corp",
"/Subject": "invoice",
"/Keywords": "finance, billing",
}
)
with open(file_path, "wb") as f:
writer.write(f)
result = extract_metadata_from_file(str(file_path))
# Verify the PDF-to-app key mapping
assert result["filename"] == "Invoice 2024"
assert result["absender"] == "Acme Corp"
assert result["document_type"] == "invoice"
assert result["tags"] == "finance, billing"
def test_pdf_metadata_does_not_overwrite_json(self, tmp_path):
"""Test that JSON metadata takes precedence over embedded PDF metadata."""
import pypdf
file_path = tmp_path / "dual.pdf"
# Create a PDF with embedded metadata
writer = pypdf.PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_metadata(
{
"/Title": "PDF Title",
"/Author": "PDF Author",
"/Subject": "PDF Subject",
"/Keywords": "pdf, keywords",
}
)
with open(file_path, "wb") as f:
writer.write(f)
# Create a companion JSON file that sets some overlapping fields
json_metadata = {"filename": "JSON Filename", "absender": "JSON Author"}
json_path = tmp_path / "dual.json"
json_path.write_text(json.dumps(json_metadata))
result = extract_metadata_from_file(str(file_path))
# JSON values must not be overwritten by PDF metadata
assert result["filename"] == "JSON Filename"
assert result["absender"] == "JSON Author"
# Fields missing from JSON are filled from PDF metadata
assert result["document_type"] == "PDF Subject"
assert result["tags"] == "pdf, keywords"
@pytest.mark.unit
class TestAttachLogo:
+5
View File
@@ -769,6 +769,11 @@ class TestUploadRclone:
cmd = mock_run.call_args[0][0]
assert cmd[0] == "rclone"
assert cmd[1] == "copyto"
# SECURITY: Verify `--` end-of-options separator is present and precedes
# the file path and destination to prevent option/argument injection.
assert "--" in cmd
fp_index = next(i for i, v in enumerate(cmd) if v == fp)
assert cmd.index("--") < fp_index
def test_raises_on_rclone_nonzero_exit(self, tmp_path):
fp = str(tmp_path / "doc.pdf")
+265
View File
@@ -0,0 +1,265 @@
"""Tests for per-user health-aware upload rate limiting (app/middleware/upload_rate_limit.py)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.middleware.upload_rate_limit import compute_effective_limit
# ---------------------------------------------------------------------------
# Tests for compute_effective_limit (pure function, no Redis needed)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestComputeEffectiveLimit:
"""Tests for the health-aware effective-limit calculation."""
def test_normal_conditions_return_base_limit(self):
"""Under normal conditions the full base limit should be returned."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=0.0)
assert effective == 20
assert factor == 1.0
assert reason == "normal"
def test_moderate_queue_halves_limit(self):
"""Queue depth > 50 should halve the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=0.0)
assert effective == 10
assert factor == 0.5
assert "moderate_queue" in reason
def test_high_queue_quarters_limit(self):
"""Queue depth > 100 should quarter the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=120, cpu_load_ratio=0.0)
assert effective == 5
assert factor == 0.25
assert "high_queue" in reason
def test_critical_queue_drops_to_ten_percent(self):
"""Queue depth > 200 should drop to 10% of base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=250, cpu_load_ratio=0.0)
assert effective == 2
assert factor == 0.10
assert "critical_queue" in reason
def test_moderate_cpu_halves_limit(self):
"""CPU load ratio > 1.5 should halve the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=1.8)
assert effective == 10
assert factor == 0.5
assert "moderate_cpu" in reason
def test_high_cpu_quarters_limit(self):
"""CPU load ratio > 2.0 should quarter the base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=2.5)
assert effective == 5
assert factor == 0.25
assert "high_cpu" in reason
def test_critical_cpu_drops_to_ten_percent(self):
"""CPU load ratio > 3.0 should drop to 10% of base limit."""
effective, factor, reason = compute_effective_limit(20, queue_depth=0, cpu_load_ratio=4.0)
assert effective == 2
assert factor == 0.10
assert "critical_cpu" in reason
def test_worst_metric_wins(self):
"""The lowest factor from queue and CPU should be applied."""
# Queue says 0.5, CPU says 0.25 → 0.25 wins
effective, factor, reason = compute_effective_limit(20, queue_depth=60, cpu_load_ratio=2.5)
assert effective == 5
assert factor == 0.25
def test_minimum_effective_limit_is_one(self):
"""Even under extreme load the effective limit must be ≥ 1."""
effective, _factor, _reason = compute_effective_limit(1, queue_depth=999, cpu_load_ratio=10.0)
assert effective >= 1
def test_zero_base_limit_returns_zero(self):
"""A base limit of 0 (disabled) should clamp to at least 1."""
effective, _factor, _reason = compute_effective_limit(0, queue_depth=0, cpu_load_ratio=0.0)
# max(1, int(0 * 1.0)) = max(1, 0) = 1
# A base_limit of 0 means "disabled" and is handled upstream
# (the dependency skips the check entirely), but the pure function
# still clamps to 1 as a safety net.
assert effective == 1
# ---------------------------------------------------------------------------
# Tests for the FastAPI dependency (mocked Redis)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRequireUploadRateLimit:
"""Tests for the require_upload_rate_limit FastAPI dependency."""
@pytest.mark.asyncio
async def test_allows_request_when_redis_unavailable(self):
"""When Redis is down the dependency should fail open (allow the request)."""
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {}
mock_request.client = MagicMock()
mock_request.client.host = "127.0.0.1"
with patch("app.middleware.upload_rate_limit._get_redis", return_value=None):
# Should NOT raise
result = await require_upload_rate_limit(mock_request)
assert result is None
@pytest.mark.asyncio
async def test_allows_request_under_limit(self):
"""A user below the rate limit should be allowed through."""
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {"user": {"username": "testuser"}}
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.1"
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.execute.return_value = [
0, # zremrangebyscore result
5, # zcard — current count (under limit of 20)
[], # zrange oldest
]
mock_redis.pipeline.return_value = mock_pipe
mock_redis.llen.return_value = 0 # empty queues
mock_pipe2 = MagicMock()
mock_pipe2.execute.return_value = [True, True]
# The second pipeline call (record upload)
mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="testuser"),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.1),
):
result = await require_upload_rate_limit(mock_request)
assert result is None
@pytest.mark.asyncio
async def test_rejects_request_over_limit(self):
"""A user at or over the rate limit should receive a 429."""
from fastapi import HTTPException
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {"user": {"username": "spammer"}}
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.2"
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.execute.return_value = [
0, # zremrangebyscore
20, # zcard — at limit
[("oldest_entry", 1000000.0)], # oldest entry for retry_after
]
mock_redis.pipeline.return_value = mock_pipe
mock_redis.llen.return_value = 0
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="spammer"),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
):
with pytest.raises(HTTPException) as exc_info:
await require_upload_rate_limit(mock_request)
assert exc_info.value.status_code == 429
assert "Retry-After" in exc_info.value.headers
@pytest.mark.asyncio
async def test_health_reduces_effective_limit(self):
"""When queues are deep, the effective limit should drop, causing a 429 sooner."""
from fastapi import HTTPException
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {"user": {"username": "normaluser"}}
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.3"
mock_redis = MagicMock()
mock_pipe = MagicMock()
# 12 uploads already — under normal limit of 20 but over health-reduced limit
mock_pipe.execute.return_value = [
0, # zremrangebyscore
12, # zcard — 12 uploads in window
[("oldest", 1000000.0)],
]
mock_redis.pipeline.return_value = mock_pipe
# Simulate deep queue (>100) → effective limit = 25% of 20 = 5
mock_redis.llen.return_value = 40 # 40 per queue * 3 = 120 total
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value="normaluser"),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
):
with pytest.raises(HTTPException) as exc_info:
await require_upload_rate_limit(mock_request)
assert exc_info.value.status_code == 429
@pytest.mark.asyncio
async def test_falls_back_to_ip_when_no_user(self):
"""Unauthenticated requests should use IP-based rate limiting."""
from app.middleware.upload_rate_limit import require_upload_rate_limit
mock_request = MagicMock()
mock_request.session = {}
mock_request.client = MagicMock()
mock_request.client.host = "192.168.1.100"
mock_redis = MagicMock()
mock_pipe = MagicMock()
mock_pipe.execute.return_value = [0, 0, []]
mock_redis.pipeline.return_value = mock_pipe
mock_redis.llen.return_value = 0
mock_pipe2 = MagicMock()
mock_pipe2.execute.return_value = [True, True]
mock_redis.pipeline.side_effect = [mock_pipe, mock_pipe2]
with (
patch("app.middleware.upload_rate_limit._get_redis", return_value=mock_redis),
patch("app.middleware.upload_rate_limit.get_current_owner_id", return_value=None),
patch("app.middleware.upload_rate_limit._get_cpu_load_ratio", return_value=0.0),
):
result = await require_upload_rate_limit(mock_request)
assert result is None
# ---------------------------------------------------------------------------
# Tests for configuration
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUploadRateLimitConfig:
"""Tests for upload rate limit configuration settings."""
def test_settings_exist(self):
"""Verify per-user upload rate limit settings are exposed in config."""
from app.config import settings
assert hasattr(settings, "upload_rate_limit_per_user")
assert hasattr(settings, "upload_rate_limit_window")
def test_sensible_defaults(self):
"""Default values should be reasonable for a multi-user system."""
from app.config import settings
assert settings.upload_rate_limit_per_user >= 10
assert settings.upload_rate_limit_per_user <= 100
assert settings.upload_rate_limit_window >= 30
assert settings.upload_rate_limit_window <= 300
+189
View File
@@ -203,3 +203,192 @@ class TestUploadIcloudHandler:
assert result["status"] == "Completed"
assert result["icloud_folder"] == "/"
mock_api.drive.upload.assert_called_once()
# ---------------------------------------------------------------------------
# upload_to_icloud Celery task
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestUploadToIcloudTask:
"""Tests for the upload_to_icloud Celery task."""
@patch("app.tasks.upload_to_icloud.log_task_progress")
def test_raises_file_not_found(self, mock_log):
"""Task raises FileNotFoundError when the file does not exist."""
from app.tasks.upload_to_icloud import upload_to_icloud
upload_to_icloud.request.id = TASK_ID
with pytest.raises(FileNotFoundError, match="File not found"):
upload_to_icloud.__wrapped__(file_path="/nonexistent/file.pdf")
@patch("app.tasks.upload_to_icloud.log_task_progress")
@patch("app.tasks.upload_to_icloud.settings")
def test_raises_when_credentials_not_configured(self, mock_settings, mock_log, tmp_path):
"""Task raises ValueError when iCloud credentials are absent."""
from app.tasks.upload_to_icloud import upload_to_icloud
mock_settings.icloud_username = None
mock_settings.icloud_password = None
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
upload_to_icloud.request.id = TASK_ID
with pytest.raises(ValueError, match="iCloud credentials are not configured"):
upload_to_icloud.__wrapped__(file_path=fp)
@patch("app.tasks.upload_to_icloud.log_task_progress")
@patch("app.tasks.upload_to_icloud.settings")
def test_raises_when_password_not_configured(self, mock_settings, mock_log, tmp_path):
"""Task raises ValueError when iCloud password is absent."""
from app.tasks.upload_to_icloud import upload_to_icloud
mock_settings.icloud_username = "user@example.com"
mock_settings.icloud_password = None
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
upload_to_icloud.request.id = TASK_ID
with pytest.raises(ValueError, match="iCloud credentials are not configured"):
upload_to_icloud.__wrapped__(file_path=fp)
@patch("app.tasks.upload_to_icloud.log_task_progress")
@patch("app.tasks.upload_to_icloud.settings")
def test_successful_upload_with_folder(self, mock_settings, mock_log, tmp_path):
"""Task uploads to the configured folder and returns success dict."""
from app.tasks.upload_to_icloud import upload_to_icloud
mock_settings.icloud_username = "user@example.com"
mock_settings.icloud_password = "secret" # noqa: S105
mock_settings.icloud_folder = "Documents"
mock_settings.icloud_cookie_directory = None
fp = str(tmp_path / "report.pdf")
_write_file(fp)
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = False
mock_folder = MagicMock()
mock_api.drive.dir.return_value = []
mock_api.drive.mkdir.return_value = mock_folder
upload_to_icloud.request.id = TASK_ID
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
result = upload_to_icloud.__wrapped__(file_path=fp, file_id=42)
assert result["status"] == "Completed"
assert result["file"] == fp
assert result["icloud_folder"] == "Documents"
mock_folder.upload.assert_called_once()
@patch("app.tasks.upload_to_icloud.log_task_progress")
@patch("app.tasks.upload_to_icloud.settings")
def test_successful_upload_to_root_when_no_folder_configured(self, mock_settings, mock_log, tmp_path):
"""Task uploads to iCloud Drive root when ICLOUD_FOLDER is empty."""
from app.tasks.upload_to_icloud import upload_to_icloud
mock_settings.icloud_username = "user@example.com"
mock_settings.icloud_password = "secret" # noqa: S105
mock_settings.icloud_folder = ""
mock_settings.icloud_cookie_directory = None
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = False
upload_to_icloud.request.id = TASK_ID
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
result = upload_to_icloud.__wrapped__(file_path=fp)
assert result["status"] == "Completed"
assert result["icloud_folder"] == "/"
mock_api.drive.upload.assert_called_once()
@patch("app.tasks.upload_to_icloud.log_task_progress")
@patch("app.tasks.upload_to_icloud.settings")
def test_folder_override_takes_precedence_over_settings(self, mock_settings, mock_log, tmp_path):
"""folder_override replaces the value from settings.icloud_folder."""
from app.tasks.upload_to_icloud import upload_to_icloud
mock_settings.icloud_username = "user@example.com"
mock_settings.icloud_password = "secret" # noqa: S105
mock_settings.icloud_folder = "DefaultFolder"
mock_settings.icloud_cookie_directory = None
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = False
mock_folder = MagicMock()
mock_api.drive.dir.return_value = []
mock_api.drive.mkdir.return_value = mock_folder
upload_to_icloud.request.id = TASK_ID
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
result = upload_to_icloud.__wrapped__(file_path=fp, folder_override="OverrideFolder")
assert result["icloud_folder"] == "OverrideFolder"
@patch("app.tasks.upload_to_icloud.log_task_progress")
@patch("app.tasks.upload_to_icloud.settings")
def test_exception_during_upload_raises_runtime_error(self, mock_settings, mock_log, tmp_path):
"""Any exception from pyicloud is wrapped in RuntimeError."""
from app.tasks.upload_to_icloud import upload_to_icloud
mock_settings.icloud_username = "user@example.com"
mock_settings.icloud_password = "secret" # noqa: S105
mock_settings.icloud_folder = ""
mock_settings.icloud_cookie_directory = None
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = False
mock_api.drive.upload.side_effect = OSError("disk full")
upload_to_icloud.request.id = TASK_ID
with patch.dict("sys.modules", {"pyicloud": _mock_pyicloud_module(mock_api)}):
with pytest.raises(RuntimeError, match="Error uploading"):
upload_to_icloud.__wrapped__(file_path=fp)
@patch("app.tasks.upload_to_icloud.log_task_progress")
@patch("app.tasks.upload_to_icloud.settings")
def test_cookie_directory_passed_to_api(self, mock_settings, mock_log, tmp_path):
"""Task forwards icloud_cookie_directory to _get_icloud_api."""
from app.tasks.upload_to_icloud import upload_to_icloud
mock_settings.icloud_username = "user@example.com"
mock_settings.icloud_password = "secret" # noqa: S105
mock_settings.icloud_folder = ""
mock_settings.icloud_cookie_directory = "/tmp/icloud_cookies"
fp = str(tmp_path / "doc.pdf")
_write_file(fp)
mock_api = MagicMock()
mock_api.requires_2sa = False
mock_api.requires_2fa = False
mock_mod = _mock_pyicloud_module(mock_api)
upload_to_icloud.request.id = TASK_ID
with patch.dict("sys.modules", {"pyicloud": mock_mod}):
upload_to_icloud.__wrapped__(file_path=fp)
mock_mod.PyiCloudService.assert_called_once_with(
"user@example.com",
"secret",
cookie_directory="/tmp/icloud_cookies",
)
+554
View File
@@ -0,0 +1,554 @@
"""
Tests for app/tasks/upload_to_sharepoint.py module.
Covers get_sharepoint_token, resolve_sharepoint_drive,
create_sharepoint_upload_session, upload_large_file_sharepoint,
and upload_to_sharepoint Celery task.
"""
from unittest.mock import Mock, patch
import pytest
@pytest.mark.unit
class TestGetSharepointToken:
"""Tests for get_sharepoint_token function."""
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_refresh_token_flow(self, mock_settings, mock_msal):
"""Test token acquisition using refresh token."""
from app.tasks.upload_to_sharepoint import get_sharepoint_token
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_client_secret = "client-secret"
mock_settings.sharepoint_refresh_token = "refresh-token"
mock_settings.sharepoint_tenant_id = "common"
mock_app = Mock()
mock_app.acquire_token_by_refresh_token.return_value = {
"access_token": "new-access-token",
}
mock_msal.return_value = mock_app
token = get_sharepoint_token()
assert token == "new-access-token"
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_refresh_token_updates_new_token(self, mock_settings, mock_msal):
"""Test that a new refresh token updates settings."""
from app.tasks.upload_to_sharepoint import get_sharepoint_token
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_client_secret = "client-secret"
mock_settings.sharepoint_refresh_token = "old-refresh-token"
mock_settings.sharepoint_tenant_id = "common"
mock_app = Mock()
mock_app.acquire_token_by_refresh_token.return_value = {
"access_token": "access-token",
"refresh_token": "new-refresh-token",
}
mock_msal.return_value = mock_app
get_sharepoint_token()
assert mock_settings.sharepoint_refresh_token == "new-refresh-token"
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_refresh_token_failure(self, mock_settings, mock_msal):
"""Test error handling when refresh token fails."""
from app.tasks.upload_to_sharepoint import get_sharepoint_token
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_client_secret = "client-secret"
mock_settings.sharepoint_refresh_token = "expired-token"
mock_settings.sharepoint_tenant_id = "common"
mock_app = Mock()
mock_app.acquire_token_by_refresh_token.return_value = {
"error": "invalid_grant",
"error_description": "Token expired",
}
mock_msal.return_value = mock_app
with pytest.raises(ValueError, match="Failed to get SharePoint access token"):
get_sharepoint_token()
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_client_credentials_flow(self, mock_settings, mock_msal):
"""Test token acquisition using client credentials (org accounts)."""
from app.tasks.upload_to_sharepoint import get_sharepoint_token
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_client_secret = "client-secret"
mock_settings.sharepoint_refresh_token = ""
mock_settings.sharepoint_tenant_id = "org-tenant-id"
mock_app = Mock()
mock_app.acquire_token_for_client.return_value = {
"access_token": "client-cred-token",
}
mock_msal.return_value = mock_app
token = get_sharepoint_token()
assert token == "client-cred-token"
@patch("app.tasks.upload_to_sharepoint.msal.ConfidentialClientApplication")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_client_credentials_failure(self, mock_settings, mock_msal):
"""Test error handling when client credentials flow fails."""
from app.tasks.upload_to_sharepoint import get_sharepoint_token
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_client_secret = "client-secret"
mock_settings.sharepoint_refresh_token = ""
mock_settings.sharepoint_tenant_id = "org-tenant-id"
mock_app = Mock()
mock_app.acquire_token_for_client.return_value = {
"error": "unauthorized_client",
"error_description": "Not authorized",
}
mock_msal.return_value = mock_app
with pytest.raises(ValueError, match="Failed to get SharePoint access token"):
get_sharepoint_token()
@patch("app.tasks.upload_to_sharepoint.settings")
def test_missing_client_id(self, mock_settings):
"""Test error when client ID is missing."""
from app.tasks.upload_to_sharepoint import get_sharepoint_token
mock_settings.sharepoint_client_id = ""
mock_settings.sharepoint_client_secret = "secret"
with pytest.raises(ValueError, match="client ID and client secret"):
get_sharepoint_token()
@patch("app.tasks.upload_to_sharepoint.settings")
def test_no_refresh_token_common_tenant(self, mock_settings):
"""Test error for common tenant without refresh token."""
from app.tasks.upload_to_sharepoint import get_sharepoint_token
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_client_secret = "secret"
mock_settings.sharepoint_refresh_token = ""
mock_settings.sharepoint_tenant_id = "common"
with pytest.raises(ValueError, match="either a refresh token or a non-'common' tenant ID"):
get_sharepoint_token()
@pytest.mark.unit
class TestResolveSharepointDrive:
"""Tests for resolve_sharepoint_drive function."""
@patch("app.tasks.upload_to_sharepoint.requests.get")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_successful_resolution(self, mock_settings, mock_get):
"""Test successful site and drive resolution."""
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
mock_settings.http_request_timeout = 30
site_resp = Mock()
site_resp.status_code = 200
site_resp.json.return_value = {"id": "site-id-123"}
drives_resp = Mock()
drives_resp.status_code = 200
drives_resp.json.return_value = {
"value": [
{"id": "drive-1", "name": "Documents"},
{"id": "drive-2", "name": "Site Assets"},
]
}
mock_get.side_effect = [site_resp, drives_resp]
site_id, drive_id = resolve_sharepoint_drive(
"access-token", "https://tenant.sharepoint.com/sites/mysite", "Documents"
)
assert site_id == "site-id-123"
assert drive_id == "drive-1"
@patch("app.tasks.upload_to_sharepoint.requests.get")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_library_not_found(self, mock_settings, mock_get):
"""Test error when document library is not found."""
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
mock_settings.http_request_timeout = 30
site_resp = Mock()
site_resp.status_code = 200
site_resp.json.return_value = {"id": "site-id-123"}
drives_resp = Mock()
drives_resp.status_code = 200
drives_resp.json.return_value = {
"value": [
{"id": "drive-1", "name": "Documents"},
]
}
mock_get.side_effect = [site_resp, drives_resp]
with pytest.raises(RuntimeError, match="not found on site"):
resolve_sharepoint_drive("access-token", "https://tenant.sharepoint.com/sites/mysite", "NonExistentLibrary")
@patch("app.tasks.upload_to_sharepoint.requests.get")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_site_resolution_failure(self, mock_settings, mock_get):
"""Test error when site resolution fails."""
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
mock_settings.http_request_timeout = 30
site_resp = Mock()
site_resp.status_code = 404
site_resp.text = "Site not found"
mock_get.return_value = site_resp
with pytest.raises(RuntimeError, match="Failed to resolve SharePoint site"):
resolve_sharepoint_drive("access-token", "https://tenant.sharepoint.com/sites/nonexistent", "Documents")
def test_invalid_site_url(self):
"""Test error with invalid site URL."""
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
with pytest.raises(ValueError, match="Invalid SharePoint site URL"):
resolve_sharepoint_drive("access-token", "not-a-url", "Documents")
@patch("app.tasks.upload_to_sharepoint.requests.get")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_case_insensitive_library_match(self, mock_settings, mock_get):
"""Test that library name matching is case-insensitive."""
from app.tasks.upload_to_sharepoint import resolve_sharepoint_drive
mock_settings.http_request_timeout = 30
site_resp = Mock()
site_resp.status_code = 200
site_resp.json.return_value = {"id": "site-id"}
drives_resp = Mock()
drives_resp.status_code = 200
drives_resp.json.return_value = {
"value": [
{"id": "drive-1", "name": "Shared Documents"},
]
}
mock_get.side_effect = [site_resp, drives_resp]
site_id, drive_id = resolve_sharepoint_drive(
"access-token", "https://tenant.sharepoint.com/sites/mysite", "shared documents"
)
assert drive_id == "drive-1"
@pytest.mark.unit
class TestCreateSharepointUploadSession:
"""Tests for create_sharepoint_upload_session function."""
@patch("app.tasks.upload_to_sharepoint.requests.post")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_successful_session_creation(self, mock_settings, mock_post):
"""Test successful upload session creation."""
from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"uploadUrl": "https://upload.url/session123"}
mock_post.return_value = mock_response
url = create_sharepoint_upload_session("test.pdf", "Uploads", "drive-id", "site-id", "access-token")
assert url == "https://upload.url/session123"
@patch("app.tasks.upload_to_sharepoint.requests.post")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_session_without_folder(self, mock_settings, mock_post):
"""Test upload session creation without folder path."""
from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"uploadUrl": "https://upload.url/session456"}
mock_post.return_value = mock_response
url = create_sharepoint_upload_session("test.pdf", None, "drive-id", "site-id", "access-token")
assert url == "https://upload.url/session456"
@patch("app.tasks.upload_to_sharepoint.requests.post")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_session_creation_failure(self, mock_settings, mock_post):
"""Test error handling when session creation fails."""
from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 403
mock_response.text = "Access denied"
mock_post.return_value = mock_response
with pytest.raises(RuntimeError, match="Failed to create SharePoint upload session"):
create_sharepoint_upload_session("test.pdf", "Uploads", "drive-id", "site-id", "access-token")
@patch("app.tasks.upload_to_sharepoint.requests.post")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_url_encoding_special_characters(self, mock_settings, mock_post):
"""Test that special characters in folder path are URL-encoded."""
from app.tasks.upload_to_sharepoint import create_sharepoint_upload_session
mock_settings.http_request_timeout = 30
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"uploadUrl": "https://upload.url/session"}
mock_post.return_value = mock_response
create_sharepoint_upload_session("file with spaces.pdf", "My Documents/Uploads", "drive-id", "site-id", "token")
call_url = mock_post.call_args[0][0]
assert "My%20Documents" in call_url
assert "file%20with%20spaces.pdf" in call_url
@pytest.mark.unit
class TestUploadLargeFileSharepoint:
"""Tests for upload_large_file_sharepoint function."""
@patch("app.tasks.upload_to_sharepoint.requests.put")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_small_single_chunk_upload(self, mock_settings, mock_put, tmp_path):
"""Test uploading a file that fits in a single chunk."""
from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
mock_settings.http_request_timeout = 30
test_file = tmp_path / "small.pdf"
test_file.write_bytes(b"small content")
mock_response = Mock()
mock_response.status_code = 201
mock_response.json.return_value = {"id": "file123", "name": "small.pdf"}
mock_put.return_value = mock_response
result = upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
assert result["id"] == "file123"
@patch("app.tasks.upload_to_sharepoint.time.sleep")
@patch("app.tasks.upload_to_sharepoint.requests.put")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_chunk_upload_retry_on_failure(self, mock_settings, mock_put, mock_sleep, tmp_path):
"""Test retry logic when a chunk upload fails."""
from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_fail = Mock()
mock_fail.status_code = 500
mock_success = Mock()
mock_success.status_code = 201
mock_success.json.return_value = {"id": "file123"}
mock_put.side_effect = [mock_fail, mock_success]
result = upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
assert result["id"] == "file123"
@patch("app.tasks.upload_to_sharepoint.time.sleep")
@patch("app.tasks.upload_to_sharepoint.requests.put")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_chunk_upload_retry_on_exception(self, mock_settings, mock_put, mock_sleep, tmp_path):
"""Test retry logic when an exception occurs during upload."""
from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_success = Mock()
mock_success.status_code = 201
mock_success.json.return_value = {"id": "file123"}
mock_put.side_effect = [Exception("Network error"), mock_success]
result = upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
assert result["id"] == "file123"
@patch("app.tasks.upload_to_sharepoint.time.sleep")
@patch("app.tasks.upload_to_sharepoint.requests.put")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_all_retries_exhausted(self, mock_settings, mock_put, mock_sleep, tmp_path):
"""Test that exhausting all retries raises an exception."""
from app.tasks.upload_to_sharepoint import upload_large_file_sharepoint
mock_settings.http_request_timeout = 30
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_fail = Mock()
mock_fail.status_code = 500
mock_fail.text = "Server Error"
mock_put.return_value = mock_fail
with pytest.raises(RuntimeError, match="Failed to upload chunk"):
upload_large_file_sharepoint(str(test_file), "https://upload.url/session")
@pytest.mark.unit
class TestUploadToSharepoint:
"""Tests for upload_to_sharepoint Celery task."""
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
def test_file_not_found(self, mock_log):
"""Test that missing file raises FileNotFoundError."""
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
with pytest.raises(FileNotFoundError):
upload_to_sharepoint.__wrapped__("/nonexistent/file.pdf", file_id=1)
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_missing_client_id(self, mock_settings, mock_log, tmp_path):
"""Test error when SharePoint client ID is not configured."""
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
mock_settings.sharepoint_client_id = ""
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
with pytest.raises(ValueError, match="client ID is not configured"):
upload_to_sharepoint.__wrapped__(str(test_file), file_id=1)
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_missing_site_url(self, mock_settings, mock_log, tmp_path):
"""Test error when SharePoint site URL is not configured."""
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_site_url = ""
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
with pytest.raises(ValueError, match="site URL is not configured"):
upload_to_sharepoint.__wrapped__(str(test_file), file_id=1)
@patch("app.tasks.upload_to_sharepoint.upload_large_file_sharepoint")
@patch("app.tasks.upload_to_sharepoint.create_sharepoint_upload_session")
@patch("app.tasks.upload_to_sharepoint.resolve_sharepoint_drive")
@patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_successful_upload(
self, mock_settings, mock_log, mock_token, mock_resolve, mock_session, mock_upload, tmp_path
):
"""Test successful SharePoint upload."""
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_client_secret = "secret"
mock_settings.sharepoint_refresh_token = "token"
mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
mock_settings.sharepoint_document_library = "Documents"
mock_settings.sharepoint_folder_path = "Uploads"
mock_settings.sharepoint_tenant_id = "common"
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_token.return_value = "access-token"
mock_resolve.return_value = ("site-id", "drive-id")
mock_session.return_value = "https://upload.url/session"
mock_upload.return_value = {"webUrl": "https://tenant.sharepoint.com/sites/mysite/test.pdf"}
result = upload_to_sharepoint.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
assert result["status"] == "Completed"
assert "Uploads" in result["sharepoint_path"]
assert result["web_url"] == "https://tenant.sharepoint.com/sites/mysite/test.pdf"
@patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_upload_exception_handling(self, mock_settings, mock_log, mock_token, tmp_path):
"""Test that upload errors are properly handled."""
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_client_secret = "secret"
mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
mock_settings.sharepoint_folder_path = "Uploads"
mock_settings.sharepoint_document_library = "Documents"
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_token.side_effect = ValueError("Token error")
with pytest.raises(RuntimeError, match="Failed to upload"):
upload_to_sharepoint.apply(args=[str(test_file)], kwargs={"file_id": 1}).get()
@patch("app.tasks.upload_to_sharepoint.upload_large_file_sharepoint")
@patch("app.tasks.upload_to_sharepoint.create_sharepoint_upload_session")
@patch("app.tasks.upload_to_sharepoint.resolve_sharepoint_drive")
@patch("app.tasks.upload_to_sharepoint.get_sharepoint_token")
@patch("app.tasks.upload_to_sharepoint.log_task_progress")
@patch("app.tasks.upload_to_sharepoint.settings")
def test_folder_override(
self, mock_settings, mock_log, mock_token, mock_resolve, mock_session, mock_upload, tmp_path
):
"""Test that folder_override is used instead of settings."""
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
mock_settings.sharepoint_client_id = "client-id"
mock_settings.sharepoint_client_secret = "secret"
mock_settings.sharepoint_refresh_token = "token"
mock_settings.sharepoint_site_url = "https://tenant.sharepoint.com/sites/mysite"
mock_settings.sharepoint_document_library = "Documents"
mock_settings.sharepoint_folder_path = "DefaultFolder"
mock_settings.sharepoint_tenant_id = "common"
test_file = tmp_path / "test.pdf"
test_file.write_bytes(b"test content")
mock_token.return_value = "access-token"
mock_resolve.return_value = ("site-id", "drive-id")
mock_session.return_value = "https://upload.url/session"
mock_upload.return_value = {"webUrl": "https://example.com/test.pdf"}
result = upload_to_sharepoint.apply(
args=[str(test_file)], kwargs={"file_id": 1, "folder_override": "CustomFolder"}
).get()
# Verify the session was created with the override folder
mock_session.assert_called_once_with("test.pdf", "CustomFolder", "drive-id", "site-id", "access-token")
assert result["status"] == "Completed"
+220 -105
View File
@@ -2,10 +2,10 @@
Tests for URL-based file upload functionality
"""
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
import requests
@pytest.mark.unit
@@ -165,17 +165,24 @@ class TestURLUploadValidation:
class TestURLUploadEndpoint:
"""Integration tests for URL upload endpoint"""
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_requires_authentication(self, mock_process_document, mock_requests_get, client, monkeypatch):
def test_process_url_requires_authentication(self, mock_process_document, mock_stream, client, monkeypatch):
"""Test that endpoint requires authentication when auth is enabled"""
# Mock successful download to prevent actual HTTP requests
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -192,17 +199,24 @@ class TestURLUploadEndpoint:
# (like no mocking). We're just checking the endpoint exists and is reachable.
assert response.status_code != 404 # Endpoint should exist
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_success(self, mock_process_document, mock_requests_get, client, tmp_path):
def test_process_url_success(self, mock_process_document, mock_stream, client, tmp_path):
"""Test successful URL processing"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content here"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content here"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -219,8 +233,8 @@ class TestURLUploadEndpoint:
assert "filename" in data
assert "size" in data
@patch("app.api.url_upload.requests.get")
def test_process_url_blocks_private_ip(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_blocks_private_ip(self, mock_stream, client):
"""Test that private IPs are blocked"""
response = client.post("/api/process-url", json={"url": "http://192.168.1.1/file.pdf"})
@@ -229,10 +243,10 @@ class TestURLUploadEndpoint:
assert "private/internal" in data["detail"]
# Should not make HTTP request
mock_requests_get.assert_not_called()
mock_stream.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_blocks_localhost(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_blocks_localhost(self, mock_stream, client):
"""Test that localhost is blocked"""
response = client.post("/api/process-url", json={"url": "http://localhost/file.pdf"})
@@ -241,10 +255,10 @@ class TestURLUploadEndpoint:
assert "private/internal" in data["detail"]
# Should not make HTTP request
mock_requests_get.assert_not_called()
mock_stream.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_blocks_metadata_endpoint(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_blocks_metadata_endpoint(self, mock_stream, client):
"""Test that cloud metadata endpoints are blocked"""
response = client.post("/api/process-url", json={"url": "http://169.254.169.254/latest/meta-data/"})
@@ -254,17 +268,20 @@ class TestURLUploadEndpoint:
assert "metadata" in data["detail"] or "private" in data["detail"]
# Should not make HTTP request
mock_requests_get.assert_not_called()
mock_stream.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_invalid_file_type(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_invalid_file_type(self, mock_stream, client):
"""Test that invalid file types are rejected"""
# Mock response with executable content-type
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/x-executable"}
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/malware.exe"})
@@ -272,21 +289,24 @@ class TestURLUploadEndpoint:
data = response.json()
assert "Unsupported file type" in data["detail"]
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_file_too_large_by_header(self, mock_process_document, mock_requests_get, client):
def test_process_url_file_too_large_by_header(self, mock_process_document, mock_stream, client):
"""Test that files too large are rejected based on Content-Length header"""
from app.config import settings
# Mock response with large content-length
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {
"Content-Type": "application/pdf",
"Content-Length": str(settings.max_upload_size + 1000),
}
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/huge.pdf"})
@@ -297,10 +317,10 @@ class TestURLUploadEndpoint:
# Should not process document
mock_process_document.delay.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_timeout_error(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_timeout_error(self, mock_stream, client):
"""Test handling of timeout errors"""
mock_requests_get.side_effect = requests.exceptions.Timeout("Request timed out")
mock_stream.side_effect = httpx.TimeoutException("Request timed out")
response = client.post("/api/process-url", json={"url": "https://example.com/slow.pdf"})
@@ -308,10 +328,10 @@ class TestURLUploadEndpoint:
data = response.json()
assert "timeout" in data["detail"].lower()
@patch("app.api.url_upload.requests.get")
def test_process_url_connection_error(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_connection_error(self, mock_stream, client):
"""Test handling of connection errors"""
mock_requests_get.side_effect = requests.exceptions.ConnectionError("Failed to connect")
mock_stream.side_effect = httpx.ConnectError("Failed to connect")
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
@@ -319,15 +339,16 @@ class TestURLUploadEndpoint:
data = response.json()
assert "connect" in data["detail"].lower()
@patch("app.api.url_upload.requests.get")
def test_process_url_http_error_404(self, mock_requests_get, client):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_http_error_404(self, mock_stream, client):
"""Test handling of HTTP 404 errors"""
mock_response = Mock()
# When raising HTTPStatusError, httpx requires request and response arguments
# For our code, we just need it to hit the exception handler and check status code
mock_request = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
"404 Not Found", response=mock_response
)
mock_requests_get.return_value = mock_response
mock_stream.side_effect = httpx.HTTPStatusError("404 Not Found", request=mock_request, response=mock_response)
response = client.post("/api/process-url", json={"url": "https://example.com/notfound.pdf"})
@@ -335,17 +356,24 @@ class TestURLUploadEndpoint:
data = response.json()
assert "HTTP error" in data["detail"]
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_with_custom_filename(self, mock_process_document, mock_requests_get, client, tmp_path):
def test_process_url_with_custom_filename(self, mock_process_document, mock_stream, client, tmp_path):
"""Test URL upload with custom filename"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -361,17 +389,24 @@ class TestURLUploadEndpoint:
data = response.json()
assert data["filename"] == "my-document.pdf"
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_extracts_filename_from_url(self, mock_process_document, mock_requests_get, client, tmp_path):
def test_process_url_extracts_filename_from_url(self, mock_process_document, mock_stream, client, tmp_path):
"""Test that filename is extracted from URL when not provided"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
mock_response.iter_content = Mock(return_value=[b"PDF content"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF content"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -386,9 +421,9 @@ class TestURLUploadEndpoint:
# Should extract "annual-report.pdf" from URL
assert "annual-report" in data["filename"]
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_file_size_during_download(self, mock_process_document, mock_requests_get, client):
def test_process_url_file_size_during_download(self, mock_process_document, mock_stream, client):
"""Test that file size is checked during download"""
from app.config import settings
@@ -396,12 +431,19 @@ class TestURLUploadEndpoint:
large_chunk = b"x" * (settings.max_upload_size + 1000)
# Mock response without Content-Length header
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf"} # No Content-Length
mock_response.iter_content = Mock(return_value=[large_chunk])
async def mock_aiter_bytes(chunk_size=None):
yield large_chunk
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
response = client.post("/api/process-url", json={"url": "https://example.com/big.pdf"})
@@ -412,10 +454,10 @@ class TestURLUploadEndpoint:
# Should not process document
mock_process_document.delay.assert_not_called()
@patch("app.api.url_upload.requests.get")
def test_process_url_request_exception(self, mock_requests_get, client):
"""Test handling of generic RequestException"""
mock_requests_get.side_effect = requests.exceptions.RequestException("Generic request error")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_request_exception(self, mock_stream, client):
"""Test handling of generic RequestError"""
mock_stream.side_effect = httpx.RequestError("Generic request error")
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
@@ -423,16 +465,23 @@ class TestURLUploadEndpoint:
data = response.json()
assert "Failed to download file" in data["detail"]
@patch("app.api.url_upload.requests.get")
def test_process_url_oserror_during_save(self, mock_requests_get, client, tmp_path, monkeypatch):
@patch("app.api.url_upload.httpx.AsyncClient.stream")
def test_process_url_oserror_during_save(self, mock_stream, client, tmp_path, monkeypatch):
"""Test handling of OSError when saving file"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock workdir to a non-existent path to trigger OSError
from app.config import settings
@@ -450,17 +499,24 @@ class TestURLUploadEndpoint:
# Restore original workdir
monkeypatch.setattr(settings, "workdir", original_workdir)
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_unexpected_exception(self, mock_process_document, mock_requests_get, client):
def test_process_url_unexpected_exception(self, mock_process_document, mock_stream, client):
"""Test handling of unexpected exceptions"""
# Mock successful download but process_document.delay raises unexpected error
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock process_document.delay to raise an unexpected exception
mock_process_document.delay.side_effect = RuntimeError("Unexpected processing error")
@@ -471,17 +527,24 @@ class TestURLUploadEndpoint:
data = response.json()
assert "Unexpected error" in data["detail"]
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_filename_without_extension(self, mock_process_document, mock_requests_get, client):
def test_process_url_filename_without_extension(self, mock_process_document, mock_stream, client):
"""Test that files without extensions are handled correctly"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -496,17 +559,24 @@ class TestURLUploadEndpoint:
# Should still work, just without extension
assert data["task_id"] == "test-task-id"
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_empty_path_uses_download(self, mock_process_document, mock_requests_get, client):
def test_process_url_empty_path_uses_download(self, mock_process_document, mock_stream, client):
"""Test that empty URL path defaults to 'download' filename"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -560,17 +630,24 @@ class TestURLUploadEndpoint:
# Link-local address
assert is_private_ip("169.254.1.1") is True
@patch("app.api.url_upload.requests.get")
@patch("app.api.url_upload.httpx.AsyncClient.stream")
@patch("app.api.url_upload.process_document")
def test_process_url_sanitizes_dangerous_filename(self, mock_process_document, mock_requests_get, client):
def test_process_url_sanitizes_dangerous_filename(self, mock_process_document, mock_stream, client):
"""Test that dangerous filenames are sanitized"""
# Mock successful download
mock_response = Mock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "100"}
mock_response.iter_content = Mock(return_value=[b"PDF"])
async def mock_aiter_bytes(chunk_size=None):
yield b"PDF"
mock_response.aiter_bytes = mock_aiter_bytes
mock_response.raise_for_status = Mock()
mock_requests_get.return_value = mock_response
mock_context = AsyncMock()
mock_context.__aenter__.return_value = mock_response
mock_stream.return_value = mock_context
# Mock Celery task
mock_task = Mock()
@@ -621,6 +698,18 @@ class TestURLUploadCoverageGaps:
assert result is False
mock_getaddrinfo.assert_called_once()
@patch("app.utils.network.socket.getaddrinfo")
def test_is_private_ip_unresolvable_hostname_fails_securely(self, mock_getaddrinfo):
"""Test that unresolvable hostnames fail securely by blocking access."""
import socket
from app.utils.network import is_private_ip
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
result = is_private_ip("unresolvable.example.internal")
assert result is True # Fails securely
@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)"""
@@ -671,18 +760,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 +791,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 +824,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 +840,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 +862,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"})
+34
View File
@@ -144,3 +144,37 @@ class TestDropboxViews:
assert response.status_code == 200
assert b"/Documents/Uploads" in response.content
assert b"Back to Integrations" in response.content
@pytest.mark.integration
class TestDropboxCallbackUrl:
"""Tests that the callback_url is correctly passed to templates."""
def test_setup_page_includes_callback_url(self, client):
"""Setup page should include the callback_url variable in its response."""
response = client.get("/dropbox-setup")
assert response.status_code == 200
# callback_url is embedded in the JS as the dropboxCallbackUrl constant
assert b"dropboxCallbackUrl" in response.content
def test_callback_page_includes_callback_url(self, client):
"""Callback page should embed the server-side callback URL."""
response = client.get("/dropbox-callback?code=testcode")
assert response.status_code == 200
# callback_url is used as the redirectUri
assert b"redirectUri" in response.content
def test_setup_page_uses_public_base_url_when_set(self, client):
"""When PUBLIC_BASE_URL is configured, it should appear in the redirect URI hint."""
with patch("app.views.dropbox.settings") as mock_settings:
mock_settings.public_base_url = "https://configured.example.com"
mock_settings.dropbox_app_key = ""
mock_settings.dropbox_app_secret = ""
mock_settings.dropbox_refresh_token = ""
mock_settings.dropbox_folder = ""
mock_settings.dropbox_allow_global_credentials_for_integrations = False
response = client.get("/dropbox-setup")
assert response.status_code == 200
# The configured public_base_url hostname must appear in the page (redirect URI display)
page_text = response.text
assert "configured.example.com/dropbox-callback" in page_text
+142 -16
View File
@@ -6,6 +6,7 @@ Target: Bring coverage from 8.77% to 70%+
"""
import json
import uuid
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
@@ -206,12 +207,12 @@ class TestFileDetailPage:
db_session.add(file)
db_session.commit()
response = client.get(f"/files/{file.id}/detail")
response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200
def test_file_detail_page_not_found(self, client: TestClient, db_session):
"""Test file detail page for non-existent file."""
response = client.get("/files/99999/detail")
response = client.get("/files/99999/process")
assert response.status_code == 200 # Still renders template with error
def test_file_detail_page_with_processing_logs(self, client: TestClient, db_session, tmp_path):
@@ -244,7 +245,7 @@ class TestFileDetailPage:
db_session.add(log2)
db_session.commit()
response = client.get(f"/files/{file.id}/detail")
response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200
def test_file_detail_page_with_metadata_json(self, client: TestClient, db_session, tmp_path):
@@ -272,7 +273,7 @@ class TestFileDetailPage:
db_session.add(file)
db_session.commit()
response = client.get(f"/files/{file.id}/detail")
response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200
def test_file_detail_checks_original_file_exists(self, client: TestClient, db_session, tmp_path):
@@ -289,7 +290,7 @@ class TestFileDetailPage:
db_session.add(file)
db_session.commit()
response = client.get(f"/files/{file.id}/detail")
response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200
def test_file_detail_error_handling(self, client: TestClient, db_session):
@@ -1113,7 +1114,7 @@ class TestFileDetailPageAdditional:
db_session.add(file)
db_session.commit()
response = client.get(f"/files/{file.id}/detail")
response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200
def test_file_detail_step_summary_fallback(self, client: TestClient, db_session, tmp_path):
@@ -1144,7 +1145,7 @@ class TestFileDetailPageAdditional:
db_session.commit()
with patch("app.utils.step_manager.get_step_summary", side_effect=Exception("Table not found")):
response = client.get(f"/files/{file.id}/detail")
response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200
def test_file_detail_error_handling(self, client: TestClient, db_session):
@@ -1155,7 +1156,7 @@ class TestFileDetailPageAdditional:
Mock(status_code=200),
]
try:
response = client.get("/files/1/detail")
response = client.get("/files/1/process")
assert response.status_code in (200, 500)
except Exception:
pass
@@ -1638,7 +1639,7 @@ class TestFileDetailNoJsonSidecar:
db_session.add(file)
db_session.commit()
response = client.get(f"/files/{file.id}/detail")
response = client.get(f"/files/{file.id}/process")
assert response.status_code == 200
@@ -1936,7 +1937,7 @@ class TestPipelineInfoInViews:
pipeline = self._make_system_pipeline(db_session)
file_rec = self._make_file(db_session, pipeline_id=None)
response = client.get(f"/files/{file_rec.id}/detail")
response = client.get(f"/files/{file_rec.id}/process")
assert response.status_code == 200
assert b"Standard Processing Pipeline" in response.content
@@ -1946,7 +1947,7 @@ class TestPipelineInfoInViews:
self._make_system_pipeline(db_session)
file_rec = self._make_file(db_session, pipeline_id=None)
response = client.get(f"/files/{file_rec.id}/detail")
response = client.get(f"/files/{file_rec.id}/process")
assert response.status_code == 200
assert b"System Default" in response.content
@@ -1956,28 +1957,153 @@ class TestPipelineInfoInViews:
pipeline = self._make_custom_pipeline(db_session)
file_rec = self._make_file(db_session, pipeline_id=pipeline.id)
response = client.get(f"/files/{file_rec.id}/detail")
response = client.get(f"/files/{file_rec.id}/process")
assert response.status_code == 200
assert b"My Custom Pipeline" in response.content
assert b"Custom" in response.content
def test_file_view_page_includes_pipeline_name(self, client, db_session):
"""GET /files/{id} response body contains the pipeline name in the sidebar."""
"""GET /files/{id}/detail response body contains the pipeline name in the sidebar."""
pipeline = self._make_system_pipeline(db_session)
file_rec = self._make_file(db_session, pipeline_id=None)
response = client.get(f"/files/{file_rec.id}")
response = client.get(f"/files/{file_rec.id}/detail")
assert response.status_code == 200
assert b"Standard Processing Pipeline" in response.content
def test_file_view_page_no_pipeline_shows_standard(self, client, db_session):
"""When no pipeline exists, file view shows 'Standard' fallback text."""
"""When no pipeline exists, file detail view shows 'Standard' fallback text."""
# No pipeline in DB
file_rec = self._make_file(db_session, pipeline_id=None)
response = client.get(f"/files/{file_rec.id}")
response = client.get(f"/files/{file_rec.id}/detail")
assert response.status_code == 200
assert b"Standard" in response.content
# ---------------------------------------------------------------------------
# Owner display and claim ownership tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOwnerDisplayAndClaim:
"""Tests that owner info and claim button appear correctly on file views."""
def _make_file(self, db_session, owner_id=None) -> FileRecord:
file_rec = FileRecord(
filehash=uuid.uuid4().hex,
original_filename="doc.pdf",
local_filename="/tmp/doc.pdf",
file_size=512,
mime_type="application/pdf",
owner_id=owner_id,
)
db_session.add(file_rec)
db_session.commit()
db_session.refresh(file_rec)
return file_rec
# ── /files/{id} (file_summary.html) ──────────────────────────────────
def test_summary_shows_owner_when_multi_user_enabled(self, client, db_session):
"""Owner ID is rendered in file summary when multi-user mode is on."""
file_rec = self._make_file(db_session, owner_id="alice@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"alice@example.com" in response.content
def test_summary_shows_unowned_label_for_unowned_file(self, client, db_session):
"""'Unowned' label is rendered in file summary for files without an owner."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"Unowned" in response.content
def test_summary_shows_claim_button_for_unowned_file(self, client, db_session):
"""Claim Ownership button appears on file summary for an unowned file."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"Claim Ownership" in response.content
def test_summary_no_claim_button_when_owned(self, client, db_session):
"""No Claim Ownership button when the file already has an owner."""
file_rec = self._make_file(db_session, owner_id="bob@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"Claim Ownership" not in response.content
def test_summary_no_owner_row_in_single_user_mode(self, client, db_session):
"""Owner row is hidden in single-user mode."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", False):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
# Claim button and Unowned label should not appear in single-user mode
assert b"Claim Ownership" not in response.content
# ── /files/{id}/detail (file_view.html) ──────────────────────────────
def test_detail_shows_owner_when_multi_user_enabled(self, client, db_session):
"""Owner ID is rendered in file detail view when multi-user mode is on."""
file_rec = self._make_file(db_session, owner_id="charlie@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/detail")
assert response.status_code == 200
assert b"charlie@example.com" in response.content
def test_detail_shows_claim_button_for_unowned_file(self, client, db_session):
"""Claim Ownership button appears in file detail view for an unowned file."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/detail")
assert response.status_code == 200
assert b"Claim Ownership" in response.content
# ── /files/{id}/annotations (file_annotations.html) ──────────────────
def test_annotations_shows_owner_info(self, client, db_session):
"""Owner info is rendered on the annotations page in multi-user mode."""
file_rec = self._make_file(db_session, owner_id="dave@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/annotations")
assert response.status_code == 200
assert b"dave@example.com" in response.content
def test_annotations_shows_claim_button_for_unowned_file(self, client, db_session):
"""Claim Ownership button appears on annotations page for unowned file."""
file_rec = self._make_file(db_session, owner_id=None)
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/annotations")
assert response.status_code == 200
assert b"Claim Ownership" in response.content
def test_annotations_no_claim_button_when_owned(self, client, db_session):
"""No Claim Ownership button on annotations page when file has an owner."""
file_rec = self._make_file(db_session, owner_id="eve@example.com")
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}/annotations")
assert response.status_code == 200
assert b"Claim Ownership" not in response.content
def test_display_name_used_when_profile_exists(self, client, db_session):
"""UserProfile.display_name overrides raw user_id in the owner display."""
from app.models import UserProfile
file_rec = self._make_file(db_session, owner_id="frank@example.com")
profile = UserProfile(user_id="frank@example.com", display_name="Frank Lastname")
db_session.add(profile)
db_session.commit()
with patch("app.config.settings.multi_user_enabled", True):
response = client.get(f"/files/{file_rec.id}")
assert response.status_code == 200
assert b"Frank Lastname" in response.content