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:
@@ -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
|
||||
|
||||
@@ -83,6 +83,8 @@ class TestGotenbergCoverageDocuments:
|
||||
".tif",
|
||||
".webp",
|
||||
".svg",
|
||||
".heic",
|
||||
".heif",
|
||||
}
|
||||
_html_extensions = {".html", ".htm"}
|
||||
_markdown_extensions = {".md", ".markdown"}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -7,9 +7,9 @@ Targets the remaining uncovered branches from the 97.03% baseline:
|
||||
- 214 : test_google_drive_token — generic connection error (not token-related)
|
||||
- 302->306: get_google_drive_token_info — credentials already valid (no refresh)
|
||||
- 307->318: get_google_drive_token_info — credentials have no expiry
|
||||
- 395->397: save_dropbox_settings — refresh_token falsy inside use_oauth block
|
||||
- 449->451: save_dropbox_settings — refresh_token falsy in in-memory update
|
||||
- 468->470: save_dropbox_settings — folder_id falsy in db-persist block
|
||||
- 395->397: save_google_drive_settings — refresh_token falsy inside use_oauth block
|
||||
- 449->451: save_google_drive_settings — refresh_token falsy in in-memory update
|
||||
- 468->470: save_google_drive_settings — folder_id falsy in db-persist block
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@@ -152,10 +152,10 @@ class TestGetTokenInfoCredentialsBranches:
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSaveGoogleDriveSettingsFalsyFields:
|
||||
"""Cover branches 395->397, 449->451, 468->470 in save_dropbox_settings.
|
||||
"""Cover branches 395->397, 449->451, 468->470 in save_google_drive_settings.
|
||||
|
||||
Note: the Google Drive save endpoint is named save_dropbox_settings in the
|
||||
source (app/api/google_drive.py) due to an existing naming inconsistency.
|
||||
Note: the Google Drive save endpoint is named save_google_drive_settings in the
|
||||
source (app/api/google_drive.py).
|
||||
"""
|
||||
|
||||
@patch("app.api.google_drive.settings")
|
||||
@@ -167,7 +167,7 @@ class TestSaveGoogleDriveSettingsFalsyFields:
|
||||
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
|
||||
from app.api.google_drive import save_dropbox_settings
|
||||
from app.api.google_drive import save_google_drive_settings
|
||||
|
||||
mock_request = MagicMock(spec=StarletteRequest)
|
||||
mock_request.session = {}
|
||||
@@ -175,7 +175,7 @@ class TestSaveGoogleDriveSettingsFalsyFields:
|
||||
|
||||
with patch("app.api.google_drive.save_setting_to_db"):
|
||||
with patch("app.api.google_drive.notify_settings_updated"):
|
||||
result = await save_dropbox_settings(
|
||||
result = await save_google_drive_settings(
|
||||
request=mock_request,
|
||||
refresh_token="", # falsy → branches 395->397 and 449->451
|
||||
client_id="cid",
|
||||
|
||||
@@ -1,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,7 +5,7 @@ Focuses on uncovered lines: 98-99, 121-143, 160-161, 170-171,
|
||||
324-326, 400-402, 436-438.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -15,7 +15,7 @@ from fastapi.testclient import TestClient
|
||||
class TestTestTokenRefreshFailed:
|
||||
"""Cover lines 98-99: token refresh returns non-200."""
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_test_token_refresh_returns_non_200(self, mock_post, client: TestClient):
|
||||
"""Test token refresh returning a failure status hits the error branch."""
|
||||
from app.config import settings
|
||||
@@ -42,8 +42,8 @@ class TestTestTokenRefreshFailed:
|
||||
class TestTestTokenRotation:
|
||||
"""Cover lines 121-143, 160-161: token rotation with .env and DB persist."""
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_env_file_exists(self, mock_post, mock_get, client: TestClient, tmp_path):
|
||||
"""When a new refresh token is received and .env file exists, it should be updated."""
|
||||
from app.config import settings
|
||||
@@ -75,8 +75,8 @@ class TestTestTokenRotation:
|
||||
patch.object(settings, "onedrive_refresh_token", "old_token"),
|
||||
patch.object(settings, "onedrive_client_id", "cid"),
|
||||
patch.object(settings, "onedrive_client_secret", "sec"),
|
||||
patch("app.api.onedrive.os.path.join", return_value=str(env_file)),
|
||||
patch("app.api.onedrive.os.path.exists", return_value=True),
|
||||
patch("app.utils.env_utils.os.path.join", return_value=str(env_file)),
|
||||
patch("app.utils.env_utils.os.path.exists", return_value=True),
|
||||
patch("app.database.SessionLocal") as mock_session_local,
|
||||
patch("app.api.onedrive.save_setting_to_db"),
|
||||
patch("app.api.onedrive.notify_settings_updated"),
|
||||
@@ -90,8 +90,8 @@ class TestTestTokenRotation:
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_env_not_existing(self, mock_post, mock_get, client: TestClient):
|
||||
"""Token rotation when .env doesn't exist still succeeds."""
|
||||
from app.config import settings
|
||||
@@ -117,7 +117,7 @@ class TestTestTokenRotation:
|
||||
patch.object(settings, "onedrive_refresh_token", "old_token"),
|
||||
patch.object(settings, "onedrive_client_id", "cid"),
|
||||
patch.object(settings, "onedrive_client_secret", "sec"),
|
||||
patch("app.api.onedrive.os.path.exists", return_value=False),
|
||||
patch("app.utils.env_utils.os.path.exists", return_value=False),
|
||||
patch("app.database.SessionLocal") as mock_session_local,
|
||||
patch("app.api.onedrive.save_setting_to_db"),
|
||||
patch("app.api.onedrive.notify_settings_updated"),
|
||||
@@ -130,8 +130,8 @@ class TestTestTokenRotation:
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_env_write_failure(self, mock_post, mock_get, client: TestClient):
|
||||
"""Token rotation when .env write fails (lines 142-143) still continues."""
|
||||
from app.config import settings
|
||||
@@ -157,7 +157,7 @@ class TestTestTokenRotation:
|
||||
patch.object(settings, "onedrive_refresh_token", "old_token"),
|
||||
patch.object(settings, "onedrive_client_id", "cid"),
|
||||
patch.object(settings, "onedrive_client_secret", "sec"),
|
||||
patch("app.api.onedrive.os.path.exists", return_value=True),
|
||||
patch("app.utils.env_utils.os.path.exists", return_value=True),
|
||||
patch("builtins.open", side_effect=PermissionError("Permission denied")),
|
||||
patch("app.database.SessionLocal") as mock_session_local,
|
||||
patch("app.api.onedrive.save_setting_to_db"),
|
||||
@@ -171,8 +171,8 @@ class TestTestTokenRotation:
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_db_persist_failure(self, mock_post, mock_get, client: TestClient):
|
||||
"""Token rotation when DB persist fails (lines 160-161) still continues."""
|
||||
from app.config import settings
|
||||
@@ -198,7 +198,7 @@ class TestTestTokenRotation:
|
||||
patch.object(settings, "onedrive_refresh_token", "old_token"),
|
||||
patch.object(settings, "onedrive_client_id", "cid"),
|
||||
patch.object(settings, "onedrive_client_secret", "sec"),
|
||||
patch("app.api.onedrive.os.path.exists", return_value=False),
|
||||
patch("app.utils.env_utils.os.path.exists", return_value=False),
|
||||
patch("app.database.SessionLocal", side_effect=Exception("DB error")),
|
||||
):
|
||||
response = client.get("/api/onedrive/test-token")
|
||||
@@ -211,8 +211,8 @@ class TestTestTokenRotation:
|
||||
class TestTestTokenUserInfoFailed:
|
||||
"""Cover lines 170-171: user info request fails."""
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_user_info_returns_non_200(self, mock_post, mock_get, client: TestClient):
|
||||
"""Test when user info request fails after successful token refresh."""
|
||||
from app.config import settings
|
||||
@@ -247,8 +247,8 @@ class TestTestTokenUserInfoFailed:
|
||||
class TestTokenRotationEnvAppendLine:
|
||||
"""Cover the branch at line 134 where token line is not found in .env and must be appended."""
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_token_rotation_appends_to_env(self, mock_post, mock_get, client: TestClient, tmp_path):
|
||||
"""When .env exists but doesn't have ONEDRIVE_REFRESH_TOKEN, it should append."""
|
||||
from app.config import settings
|
||||
@@ -277,8 +277,8 @@ class TestTokenRotationEnvAppendLine:
|
||||
patch.object(settings, "onedrive_refresh_token", "old_token"),
|
||||
patch.object(settings, "onedrive_client_id", "cid"),
|
||||
patch.object(settings, "onedrive_client_secret", "sec"),
|
||||
patch("app.api.onedrive.os.path.join", return_value=str(env_file)),
|
||||
patch("app.api.onedrive.os.path.exists", return_value=True),
|
||||
patch("app.utils.env_utils.os.path.join", return_value=str(env_file)),
|
||||
patch("app.utils.env_utils.os.path.exists", return_value=True),
|
||||
patch("app.database.SessionLocal") as mock_sl,
|
||||
patch("app.api.onedrive.save_setting_to_db"),
|
||||
patch("app.api.onedrive.notify_settings_updated"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Comprehensive unit tests for app/api/onedrive.py module."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -48,8 +48,8 @@ class TestExchangeOneDriveToken:
|
||||
class TestTestOneDriveToken:
|
||||
"""Tests for GET /onedrive/test-token endpoint."""
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
def test_test_token_success(self, mock_get, mock_post):
|
||||
"""Test successful token validation."""
|
||||
from app.config import settings
|
||||
@@ -79,7 +79,7 @@ class TestTestOneDriveToken:
|
||||
# Should return success
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_test_token_not_configured(self, mock_post):
|
||||
"""Test when credentials are not configured."""
|
||||
from app.config import settings
|
||||
@@ -88,7 +88,7 @@ class TestTestOneDriveToken:
|
||||
# Should return error
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_test_token_refresh_failed(self, mock_post):
|
||||
"""Test when token refresh fails."""
|
||||
from app.config import settings
|
||||
@@ -104,8 +104,8 @@ class TestTestOneDriveToken:
|
||||
# Should return error with needs_reauth
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
def test_test_token_user_info_failed(self, mock_get, mock_post):
|
||||
"""Test when user info request fails."""
|
||||
from app.config import settings
|
||||
@@ -128,8 +128,8 @@ class TestTestOneDriveToken:
|
||||
# Should return error
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
@patch("builtins.open", create=True)
|
||||
@patch("os.path.exists")
|
||||
def test_test_token_updates_refresh_token(self, mock_exists, mock_open, mock_get, mock_post):
|
||||
@@ -167,8 +167,8 @@ class TestTestOneDriveToken:
|
||||
# Should update refresh token in memory and file
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
@patch("httpx.AsyncClient.get", new_callable=AsyncMock)
|
||||
def test_test_token_expiration_info(self, mock_get, mock_post):
|
||||
"""Test that expiration info is included."""
|
||||
from app.config import settings
|
||||
@@ -195,7 +195,7 @@ class TestTestOneDriveToken:
|
||||
# token_info should include expiration details
|
||||
pass
|
||||
|
||||
@patch("app.api.onedrive.requests.post")
|
||||
@patch("httpx.AsyncClient.post", new_callable=AsyncMock)
|
||||
def test_test_token_exception_handling(self, mock_post):
|
||||
"""Test handling of exceptions."""
|
||||
from app.config import settings
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -224,3 +224,142 @@ class TestTaskFailureHandler:
|
||||
# Simply verify that importing the handler doesn't cause errors
|
||||
# The actual signal connection is tested implicitly by the other tests
|
||||
assert callable(task_failure_handler)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDispatchUserFailureNotification:
|
||||
"""Tests for _dispatch_user_failure_notification helper."""
|
||||
|
||||
@patch("app.celery_app._dispatch_user_failure_notification")
|
||||
@patch("app.celery_app.settings")
|
||||
@patch("app.utils.notification.notify_celery_failure")
|
||||
def test_task_failure_handler_calls_user_failure_dispatch(self, mock_notify_sys, mock_settings, mock_dispatch):
|
||||
"""task_failure_handler also calls _dispatch_user_failure_notification."""
|
||||
mock_settings.notify_on_task_failure = True
|
||||
|
||||
from app.celery_app import task_failure_handler
|
||||
|
||||
mock_sender = MagicMock()
|
||||
mock_sender.name = "app.tasks.process_document.process_document"
|
||||
exc = ValueError("OCR timeout")
|
||||
|
||||
task_failure_handler(
|
||||
sender=mock_sender,
|
||||
task_id="tid",
|
||||
exception=exc,
|
||||
args=["/tmp/f.pdf"],
|
||||
kwargs={"file_id": 42},
|
||||
)
|
||||
|
||||
mock_dispatch.assert_called_once_with(mock_sender, exc, ["/tmp/f.pdf"], {"file_id": 42})
|
||||
|
||||
def test_dispatch_ignores_non_document_tasks(self):
|
||||
"""Non app.tasks.* tasks should be silently ignored."""
|
||||
from app.celery_app import _dispatch_user_failure_notification
|
||||
|
||||
sender = MagicMock()
|
||||
sender.name = "celery.backend_cleanup"
|
||||
|
||||
# Should complete without error or DB access
|
||||
_dispatch_user_failure_notification(sender, ValueError("x"), [], {})
|
||||
|
||||
def test_dispatch_ignores_when_no_file_id(self):
|
||||
"""If file_id is not in args or kwargs, nothing happens."""
|
||||
from app.celery_app import _dispatch_user_failure_notification
|
||||
|
||||
sender = MagicMock()
|
||||
sender.name = "app.tasks.process_document.process_document"
|
||||
|
||||
# No file_id anywhere
|
||||
_dispatch_user_failure_notification(sender, ValueError("x"), ["/tmp/f.pdf"], {})
|
||||
|
||||
@patch("app.database.SessionLocal")
|
||||
def test_dispatch_extracts_file_id_from_kwargs(self, mock_session):
|
||||
"""file_id should be extracted from kwargs when present."""
|
||||
from app.celery_app import _dispatch_user_failure_notification
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_record = MagicMock()
|
||||
mock_record.owner_id = "alice@example.com"
|
||||
mock_record.original_filename = "invoice.pdf"
|
||||
mock_record.local_filename = "/tmp/invoice.pdf"
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
|
||||
mock_session.return_value.__enter__.return_value = mock_db
|
||||
|
||||
sender = MagicMock()
|
||||
sender.name = "app.tasks.finalize_document_storage.finalize_document_storage"
|
||||
exc = RuntimeError("Upload failed")
|
||||
|
||||
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
|
||||
_dispatch_user_failure_notification(sender, exc, ["/tmp/f.pdf"], {"file_id": 10})
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
owner_id="alice@example.com",
|
||||
filename="invoice.pdf",
|
||||
error="RuntimeError: Upload failed",
|
||||
file_id=10,
|
||||
)
|
||||
|
||||
@patch("app.database.SessionLocal")
|
||||
def test_dispatch_extracts_file_id_from_positional_args(self, mock_session):
|
||||
"""file_id should be extracted from positional args for known tasks."""
|
||||
from app.celery_app import _dispatch_user_failure_notification
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_record = MagicMock()
|
||||
mock_record.owner_id = "bob@test.com"
|
||||
mock_record.original_filename = "scan.pdf"
|
||||
mock_record.local_filename = "/tmp/scan.pdf"
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
|
||||
mock_session.return_value.__enter__.return_value = mock_db
|
||||
|
||||
sender = MagicMock()
|
||||
sender.name = "app.tasks.process_with_ocr.process_with_ocr"
|
||||
exc = ValueError("OCR error")
|
||||
|
||||
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
|
||||
# process_with_ocr: file_id is args[1]
|
||||
_dispatch_user_failure_notification(sender, exc, ["filename.pdf", 77], {})
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
owner_id="bob@test.com",
|
||||
filename="scan.pdf",
|
||||
error="ValueError: OCR error",
|
||||
file_id=77,
|
||||
)
|
||||
|
||||
@patch("app.database.SessionLocal")
|
||||
def test_dispatch_skips_when_no_owner(self, mock_session):
|
||||
"""When file record has no owner_id, no notification is sent."""
|
||||
from app.celery_app import _dispatch_user_failure_notification
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_record = MagicMock()
|
||||
mock_record.owner_id = None
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_record
|
||||
mock_session.return_value.__enter__.return_value = mock_db
|
||||
|
||||
sender = MagicMock()
|
||||
sender.name = "app.tasks.process_document.process_document"
|
||||
|
||||
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
|
||||
_dispatch_user_failure_notification(sender, ValueError("x"), [], {"file_id": 5})
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
@patch("app.database.SessionLocal")
|
||||
def test_dispatch_skips_when_record_not_found(self, mock_session):
|
||||
"""When file record doesn't exist, no notification is sent."""
|
||||
from app.celery_app import _dispatch_user_failure_notification
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
mock_session.return_value.__enter__.return_value = mock_db
|
||||
|
||||
sender = MagicMock()
|
||||
sender.name = "app.tasks.process_document.process_document"
|
||||
|
||||
with patch("app.utils.user_notification.notify_user_document_failed") as mock_notify:
|
||||
_dispatch_user_failure_notification(sender, ValueError("x"), [], {"file_id": 999})
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Tests for scripts/check_alembic_migrations.py."""
|
||||
|
||||
# The script lives outside of the ``app`` package, so we import it by path.
|
||||
import importlib.util
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "check_alembic_migrations.py"
|
||||
_spec = importlib.util.spec_from_file_location("check_alembic_migrations", _SCRIPT)
|
||||
assert _spec and _spec.loader
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod) # type: ignore[union-attr]
|
||||
|
||||
check_migrations = _mod.check_migrations
|
||||
main = _mod.main
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_migration(
|
||||
directory: Path, filename: str, revision: str, down_revision: str | tuple[str, ...] | None
|
||||
) -> Path:
|
||||
"""Helper to create a minimal migration file."""
|
||||
if down_revision is None:
|
||||
down_rev_str = "None"
|
||||
elif isinstance(down_revision, tuple):
|
||||
down_rev_str = repr(down_revision)
|
||||
else:
|
||||
down_rev_str = f'"{down_revision}"'
|
||||
|
||||
content = textwrap.dedent(f'''\
|
||||
"""Test migration."""
|
||||
from typing import Union
|
||||
revision: str = "{revision}"
|
||||
down_revision: Union[str, None] = {down_rev_str}
|
||||
depends_on: Union[str, None] = None
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
''')
|
||||
path = directory / filename
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def versions_dir(tmp_path: Path) -> Path:
|
||||
"""Return a temporary versions directory."""
|
||||
d = tmp_path / "versions"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestCheckMigrations:
|
||||
"""Tests for the check_migrations function."""
|
||||
|
||||
def test_valid_linear_chain(self, versions_dir: Path) -> None:
|
||||
"""A simple linear chain should pass with no errors."""
|
||||
_write_migration(versions_dir, "001_initial.py", "001_initial", None)
|
||||
_write_migration(versions_dir, "002_add_col.py", "002_add_col", "001_initial")
|
||||
_write_migration(versions_dir, "003_add_table.py", "003_add_table", "002_add_col")
|
||||
|
||||
errors = check_migrations(versions_dir)
|
||||
assert errors == []
|
||||
|
||||
def test_valid_merge_migration(self, versions_dir: Path) -> None:
|
||||
"""A chain with a merge point should pass."""
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
|
||||
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
|
||||
|
||||
# Merge file with tuple down_revision
|
||||
content = textwrap.dedent('''\
|
||||
"""Merge."""
|
||||
from typing import Union
|
||||
revision: str = "003_merge"
|
||||
down_revision: Union[str, tuple] = ("002_a", "002_b")
|
||||
depends_on: Union[str, None] = None
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
''')
|
||||
(versions_dir / "003_merge.py").write_text(content)
|
||||
|
||||
errors = check_migrations(versions_dir)
|
||||
assert errors == []
|
||||
|
||||
def test_multiple_heads_detected(self, versions_dir: Path) -> None:
|
||||
"""Two unmerged branches should report multiple heads."""
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
|
||||
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
|
||||
|
||||
errors = check_migrations(versions_dir)
|
||||
assert len(errors) == 1
|
||||
assert "Multiple migration heads" in errors[0]
|
||||
assert "002_a" in errors[0]
|
||||
assert "002_b" in errors[0]
|
||||
|
||||
def test_broken_down_revision(self, versions_dir: Path) -> None:
|
||||
"""A migration pointing to a non-existent parent should be flagged."""
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
_write_migration(versions_dir, "002_orphan.py", "002_orphan", "NONEXISTENT")
|
||||
|
||||
errors = check_migrations(versions_dir)
|
||||
assert any("Broken chain" in e for e in errors)
|
||||
assert any("NONEXISTENT" in e for e in errors)
|
||||
|
||||
def test_duplicate_revision(self, versions_dir: Path) -> None:
|
||||
"""Two files declaring the same revision should be flagged."""
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
_write_migration(versions_dir, "002_first.py", "002_dup", "001_base")
|
||||
_write_migration(versions_dir, "002_second.py", "002_dup", "001_base")
|
||||
|
||||
errors = check_migrations(versions_dir)
|
||||
assert any("Duplicate revision" in e for e in errors)
|
||||
|
||||
def test_filename_mismatch(self, versions_dir: Path) -> None:
|
||||
"""A file whose revision doesn't match its filename should be flagged."""
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
# filename stem is "002_wrong_name" but revision says "002_correct_name"
|
||||
_write_migration(versions_dir, "002_wrong_name.py", "002_correct_name", "001_base")
|
||||
|
||||
errors = check_migrations(versions_dir)
|
||||
assert any("Filename mismatch" in e for e in errors)
|
||||
|
||||
def test_empty_directory(self, versions_dir: Path) -> None:
|
||||
"""An empty versions directory should report an error."""
|
||||
errors = check_migrations(versions_dir)
|
||||
assert len(errors) == 1
|
||||
assert "No migration files found" in errors[0]
|
||||
|
||||
def test_init_py_is_skipped(self, versions_dir: Path) -> None:
|
||||
"""__init__.py files should be ignored."""
|
||||
(versions_dir / "__init__.py").write_text("")
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
|
||||
errors = check_migrations(versions_dir)
|
||||
assert errors == []
|
||||
|
||||
def test_non_migration_file_skipped(self, versions_dir: Path) -> None:
|
||||
"""A .py file without a revision variable should be silently skipped."""
|
||||
(versions_dir / "helper.py").write_text("# just a helper\nx = 1\n")
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
|
||||
errors = check_migrations(versions_dir)
|
||||
assert errors == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMainCLI:
|
||||
"""Tests for the CLI entry-point."""
|
||||
|
||||
def test_success_returns_zero(self, versions_dir: Path) -> None:
|
||||
"""Valid chain should exit 0."""
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
rc = main(["--versions-dir", str(versions_dir)])
|
||||
assert rc == 0
|
||||
|
||||
def test_failure_returns_one(self, versions_dir: Path) -> None:
|
||||
"""Invalid chain should exit 1."""
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
_write_migration(versions_dir, "002_a.py", "002_a", "001_base")
|
||||
_write_migration(versions_dir, "002_b.py", "002_b", "001_base")
|
||||
|
||||
rc = main(["--versions-dir", str(versions_dir)])
|
||||
assert rc == 1
|
||||
|
||||
def test_missing_directory_returns_two(self, tmp_path: Path) -> None:
|
||||
"""Non-existent versions directory should exit 2."""
|
||||
rc = main(["--versions-dir", str(tmp_path / "does_not_exist")])
|
||||
assert rc == 2
|
||||
|
||||
def test_verbose_flag(self, versions_dir: Path) -> None:
|
||||
"""The --verbose flag should not crash."""
|
||||
_write_migration(versions_dir, "001_base.py", "001_base", None)
|
||||
rc = main(["--versions-dir", str(versions_dir), "--verbose"])
|
||||
assert rc == 0
|
||||
|
||||
def test_real_migrations(self) -> None:
|
||||
"""Smoke test against the actual project migrations."""
|
||||
real_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
|
||||
if not real_dir.is_dir():
|
||||
pytest.skip("migrations/versions directory not found in working tree")
|
||||
rc = main(["--versions-dir", str(real_dir)])
|
||||
assert rc == 0
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
):
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for the Devices page and mobile token filtering (app/api/api_tokens.py mobile endpoint).
|
||||
|
||||
These tests validate:
|
||||
- ``GET /api/api-tokens/mobile`` returns only mobile tokens
|
||||
- ``GET /api/api-tokens/`` excludes mobile tokens
|
||||
- ``GET /devices`` renders the devices page
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.models import ApiToken
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OWNER = "devices_user@example.com"
|
||||
_OTHER_OWNER = "other_devices@example.com"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dev_engine():
|
||||
"""In-memory SQLite engine."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def dev_session(dev_engine):
|
||||
"""DB session scoped to one test."""
|
||||
Session = sessionmaker(bind=dev_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def _make_client(dev_engine, owner_id: str = _OWNER) -> TestClient:
|
||||
"""Return a TestClient with *owner_id* injected as the authenticated user."""
|
||||
from app.api.api_tokens import _get_owner_id
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=dev_engine)
|
||||
|
||||
def _override_get_db():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def _override_owner():
|
||||
return owner_id
|
||||
|
||||
app.dependency_overrides[get_db] = _override_get_db
|
||||
app.dependency_overrides[_get_owner_id] = _override_owner
|
||||
|
||||
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
|
||||
return client
|
||||
|
||||
|
||||
def _cleanup(app):
|
||||
"""Remove dependency overrides after test."""
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _seed_tokens(session, owner_id: str = _OWNER):
|
||||
"""Create a mix of regular and mobile tokens for testing."""
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
|
||||
tokens = []
|
||||
# Regular API tokens
|
||||
for name in ["CI Pipeline", "Webhook Upload"]:
|
||||
pt = generate_api_token()
|
||||
t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
|
||||
session.add(t)
|
||||
tokens.append(t)
|
||||
|
||||
# Mobile tokens (various naming patterns)
|
||||
for name in [
|
||||
"Mobile App – iPhone 15 Pro",
|
||||
"Mobile App (QR) – Christian's iPad",
|
||||
"Mobile App",
|
||||
]:
|
||||
pt = generate_api_token()
|
||||
t = ApiToken(owner_id=owner_id, name=name, token_hash=hash_token(pt), token_prefix=pt[:12])
|
||||
session.add(t)
|
||||
tokens.append(t)
|
||||
|
||||
session.commit()
|
||||
return tokens
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Mobile Token Filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMobileTokenFiltering:
|
||||
"""Tests for GET /api/api-tokens/mobile and filtering from GET /api/api-tokens/."""
|
||||
|
||||
def test_list_mobile_tokens_returns_only_mobile(self, dev_engine, dev_session):
|
||||
"""GET /api/api-tokens/mobile should only return tokens starting with 'Mobile App'."""
|
||||
_seed_tokens(dev_session)
|
||||
client = _make_client(dev_engine)
|
||||
try:
|
||||
res = client.get("/api/api-tokens/mobile")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert len(data) == 3
|
||||
for t in data:
|
||||
assert t["name"].startswith("Mobile App")
|
||||
finally:
|
||||
_cleanup(client.app)
|
||||
|
||||
def test_list_regular_tokens_excludes_mobile(self, dev_engine, dev_session):
|
||||
"""GET /api/api-tokens/ should NOT return tokens starting with 'Mobile App'."""
|
||||
_seed_tokens(dev_session)
|
||||
client = _make_client(dev_engine)
|
||||
try:
|
||||
res = client.get("/api/api-tokens/")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert len(data) == 2
|
||||
for t in data:
|
||||
assert not t["name"].startswith("Mobile App")
|
||||
finally:
|
||||
_cleanup(client.app)
|
||||
|
||||
def test_list_mobile_tokens_empty(self, dev_engine):
|
||||
"""GET /api/api-tokens/mobile returns [] when no mobile tokens exist."""
|
||||
client = _make_client(dev_engine)
|
||||
try:
|
||||
res = client.get("/api/api-tokens/mobile")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
finally:
|
||||
_cleanup(client.app)
|
||||
|
||||
def test_list_mobile_tokens_isolation(self, dev_engine, dev_session):
|
||||
"""Mobile tokens for other users should not appear."""
|
||||
_seed_tokens(dev_session, owner_id=_OTHER_OWNER)
|
||||
client = _make_client(dev_engine, owner_id=_OWNER)
|
||||
try:
|
||||
res = client.get("/api/api-tokens/mobile")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
finally:
|
||||
_cleanup(client.app)
|
||||
|
||||
def test_mobile_token_revoke_via_api_tokens_endpoint(self, dev_engine, dev_session):
|
||||
"""Mobile tokens can still be revoked via DELETE /api/api-tokens/{id}."""
|
||||
tokens = _seed_tokens(dev_session)
|
||||
mobile_token = next(t for t in tokens if t.name.startswith("Mobile App"))
|
||||
client = _make_client(dev_engine)
|
||||
try:
|
||||
res = client.delete(f"/api/api-tokens/{mobile_token.id}")
|
||||
assert res.status_code == 200
|
||||
# Verify it's gone from mobile list
|
||||
res2 = client.get("/api/api-tokens/mobile")
|
||||
active_names = [t["name"] for t in res2.json() if t["is_active"]]
|
||||
assert mobile_token.name not in active_names
|
||||
finally:
|
||||
_cleanup(client.app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests – Devices Page View
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDevicesPageView:
|
||||
"""Tests for GET /devices page rendering."""
|
||||
|
||||
def test_devices_page_renders(self, dev_engine):
|
||||
"""GET /devices should return 200 with the devices template."""
|
||||
from app.views.devices import router as _ # noqa: F401 – ensures route is registered
|
||||
|
||||
client = _make_client(dev_engine)
|
||||
try:
|
||||
res = client.get("/devices")
|
||||
assert res.status_code == 200
|
||||
assert "devices.heading" in res.text or "Mobile Devices" in res.text
|
||||
finally:
|
||||
_cleanup(client.app)
|
||||
@@ -5,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."""
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@ This test module serves as a regression prevention mechanism to ensure
|
||||
that endpoints remain accessible after code refactoring or reorganization.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -17,17 +17,24 @@ TEST_URL = "https://example.com/test.pdf"
|
||||
class TestEndpointRegistration:
|
||||
"""Verify that critical API endpoints are registered in the FastAPI app"""
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_endpoint_exists(self, mock_process_document, mock_requests_get, client):
|
||||
def test_process_url_endpoint_exists(self, mock_process_document, mock_stream, client):
|
||||
"""Verify that /api/process-url endpoint is registered and accessible"""
|
||||
# Mock successful download to prevent actual HTTP requests
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF content"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
@@ -46,17 +53,24 @@ class TestEndpointRegistration:
|
||||
"Verify that url_upload_router is included in app/api/__init__.py"
|
||||
)
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_process_url_endpoint_accepts_post(self, mock_process_document, mock_requests_get, client):
|
||||
def test_process_url_endpoint_accepts_post(self, mock_process_document, mock_stream, client):
|
||||
"""Verify that /api/process-url accepts POST requests"""
|
||||
# Mock successful download to prevent actual HTTP requests
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF content"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
@@ -72,17 +86,24 @@ class TestEndpointRegistration:
|
||||
"Verify the endpoint is decorated with @router.post()"
|
||||
)
|
||||
|
||||
@patch("app.api.url_upload.requests.get")
|
||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||
@patch("app.api.url_upload.process_document")
|
||||
def test_api_router_included_in_app(self, mock_process_document, mock_requests_get, client):
|
||||
def test_api_router_included_in_app(self, mock_process_document, mock_stream, client):
|
||||
"""Verify that the main API router is included in the FastAPI app"""
|
||||
# Mock successful download for /api/process-url test
|
||||
mock_response = Mock()
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"Content-Type": "application/pdf", "Content-Length": "1024"}
|
||||
mock_response.iter_content = Mock(return_value=[b"PDF content"])
|
||||
|
||||
async def mock_aiter_bytes(chunk_size=None):
|
||||
yield b"PDF content"
|
||||
|
||||
mock_response.aiter_bytes = mock_aiter_bytes
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_requests_get.return_value = mock_response
|
||||
|
||||
mock_context = AsyncMock()
|
||||
mock_context.__aenter__.return_value = mock_response
|
||||
mock_stream.return_value = mock_context
|
||||
|
||||
# Mock Celery task
|
||||
mock_task = Mock()
|
||||
|
||||
@@ -417,14 +417,20 @@ class TestOneDriveIntegration:
|
||||
|
||||
def test_onedrive_token_refresh_and_user_info(self, original_env: dict) -> None:
|
||||
"""Validate token refresh and user info retrieval."""
|
||||
import requests
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
token = self._get_access_token(original_env)
|
||||
resp = requests.get(
|
||||
"https://graph.microsoft.com/v1.0/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
async def _test():
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
return await client.get(
|
||||
"https://graph.microsoft.com/v1.0/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
resp = asyncio.run(_test())
|
||||
assert resp.status_code == 200, f"OneDrive user info failed: {resp.text}"
|
||||
|
||||
def test_onedrive_upload_download_delete(self, original_env: dict) -> None:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -632,3 +632,137 @@ class TestFinalizeDocumentStorageUserRouting:
|
||||
mock_send_all.delay.assert_called_once_with("/workdir/processed/file.pdf", True, 400)
|
||||
mock_send_user.delay.assert_not_called()
|
||||
assert result["status"] == "Completed"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestFinalizeDocumentStorageUserNotification:
|
||||
"""Tests for per-user notification dispatch in finalize_document_storage."""
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_user_document_processed")
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=2)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
def test_dispatches_per_user_notification_when_owner_is_set(
|
||||
self,
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify_system,
|
||||
mock_notify_user,
|
||||
):
|
||||
"""notify_user_document_processed is called when owner_id is available."""
|
||||
mock_get_services.return_value = {"dropbox": True}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_file_record = _make_file_record(100, owner_id="alice@example.com")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=1024):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="doc.pdf"):
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
|
||||
finalize_document_storage.__wrapped__(
|
||||
original_file="/tmp/original.pdf",
|
||||
processed_file="/workdir/processed/doc.pdf",
|
||||
metadata={"filename": "doc.pdf"},
|
||||
file_id=100,
|
||||
)
|
||||
|
||||
mock_notify_user.assert_called_once_with(
|
||||
owner_id="alice@example.com",
|
||||
filename="doc.pdf",
|
||||
file_id=100,
|
||||
)
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_user_document_processed")
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
def test_skips_per_user_notification_when_no_owner(
|
||||
self,
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify_system,
|
||||
mock_notify_user,
|
||||
):
|
||||
"""notify_user_document_processed is NOT called when owner_id is None."""
|
||||
mock_get_services.return_value = {"dropbox": True}
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_file_record = _make_file_record(200, owner_id=None)
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=2048):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="file.pdf"):
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
|
||||
finalize_document_storage.__wrapped__(
|
||||
original_file="/tmp/original.pdf",
|
||||
processed_file="/workdir/processed/file.pdf",
|
||||
metadata={"filename": "file.pdf"},
|
||||
file_id=200,
|
||||
)
|
||||
|
||||
mock_notify_user.assert_not_called()
|
||||
|
||||
@patch("app.tasks.finalize_document_storage.notify_user_document_processed")
|
||||
@patch("app.tasks.finalize_document_storage.notify_file_processed")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_user_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
|
||||
@patch("app.tasks.finalize_document_storage.get_user_destination_count", return_value=0)
|
||||
@patch("app.tasks.finalize_document_storage.get_configured_services_from_validator")
|
||||
@patch("app.tasks.finalize_document_storage.log_task_progress")
|
||||
@patch("app.tasks.finalize_document_storage.SessionLocal")
|
||||
def test_per_user_notification_failure_does_not_break_task(
|
||||
self,
|
||||
mock_session_local,
|
||||
mock_log_progress,
|
||||
mock_get_services,
|
||||
mock_get_dest_count,
|
||||
mock_send_all,
|
||||
mock_send_user,
|
||||
mock_notify_system,
|
||||
mock_notify_user,
|
||||
):
|
||||
"""Even if notify_user_document_processed raises, finalize returns success."""
|
||||
mock_get_services.return_value = {"dropbox": True}
|
||||
mock_notify_user.side_effect = RuntimeError("SMTP down")
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_local.return_value.__enter__.return_value = mock_db
|
||||
mock_file_record = _make_file_record(300, owner_id="bob@example.com")
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_file_record
|
||||
|
||||
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=True):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.getsize", return_value=512):
|
||||
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="a.pdf"):
|
||||
finalize_document_storage.request.id = "test-task-id"
|
||||
|
||||
result = finalize_document_storage.__wrapped__(
|
||||
original_file="/tmp/original.pdf",
|
||||
processed_file="/workdir/processed/a.pdf",
|
||||
metadata={"filename": "a.pdf"},
|
||||
file_id=300,
|
||||
)
|
||||
|
||||
assert result["status"] == "Completed"
|
||||
mock_notify_user.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Tests for application logging configuration.
|
||||
|
||||
Validates that the LOG_LEVEL and DEBUG settings correctly control the
|
||||
Python root-logger level and that the standard precedence rules are respected:
|
||||
1. Explicit LOG_LEVEL always wins.
|
||||
2. DEBUG=True without LOG_LEVEL → effective DEBUG.
|
||||
3. Neither set → default INFO.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLogLevelSetting:
|
||||
"""Tests for the log_level config field."""
|
||||
|
||||
_BASE_KWARGS = {
|
||||
"database_url": "sqlite:///test.db",
|
||||
"redis_url": "redis://localhost:6379",
|
||||
"openai_api_key": "test",
|
||||
"azure_ai_key": "test",
|
||||
"azure_region": "test",
|
||||
"azure_endpoint": "https://test.example.com",
|
||||
"gotenberg_url": "http://localhost:3000",
|
||||
"workdir": "/tmp",
|
||||
"auth_enabled": False,
|
||||
"session_secret": None,
|
||||
}
|
||||
|
||||
def test_log_level_default_is_info(self):
|
||||
"""Test that log_level defaults to INFO."""
|
||||
config = Settings(**self._BASE_KWARGS)
|
||||
assert config.log_level.upper() == "INFO"
|
||||
|
||||
def test_log_level_accepts_debug(self):
|
||||
"""Test that log_level accepts DEBUG."""
|
||||
config = Settings(**self._BASE_KWARGS, log_level="DEBUG")
|
||||
assert config.log_level.upper() == "DEBUG"
|
||||
|
||||
def test_log_level_accepts_warning(self):
|
||||
"""Test that log_level accepts WARNING."""
|
||||
config = Settings(**self._BASE_KWARGS, log_level="WARNING")
|
||||
assert config.log_level.upper() == "WARNING"
|
||||
|
||||
def test_log_level_accepts_error(self):
|
||||
"""Test that log_level accepts ERROR."""
|
||||
config = Settings(**self._BASE_KWARGS, log_level="ERROR")
|
||||
assert config.log_level.upper() == "ERROR"
|
||||
|
||||
def test_log_level_case_insensitive(self):
|
||||
"""Test that log_level is case-insensitive in usage."""
|
||||
config = Settings(**self._BASE_KWARGS, log_level="debug")
|
||||
assert config.log_level.upper() == "DEBUG"
|
||||
|
||||
def test_debug_flag_defaults_to_false(self):
|
||||
"""Test that debug defaults to False."""
|
||||
config = Settings(**self._BASE_KWARGS)
|
||||
assert config.debug is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEffectiveLogLevel:
|
||||
"""Tests for the effective log-level resolution logic in main.py."""
|
||||
|
||||
def test_debug_true_without_log_level_gives_debug(self):
|
||||
"""When DEBUG=True and LOG_LEVEL is not set, effective level is DEBUG."""
|
||||
with patch.dict(os.environ, {"DEBUG": "true"}, clear=False):
|
||||
# Remove LOG_LEVEL from env if present
|
||||
env = os.environ.copy()
|
||||
env.pop("LOG_LEVEL", None)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
s = Settings(
|
||||
database_url="sqlite:///test.db",
|
||||
redis_url="redis://localhost:6379",
|
||||
openai_api_key="test",
|
||||
azure_ai_key="test",
|
||||
azure_region="test",
|
||||
azure_endpoint="https://test.example.com",
|
||||
gotenberg_url="http://localhost:3000",
|
||||
workdir="/tmp",
|
||||
auth_enabled=False,
|
||||
session_secret=None,
|
||||
debug=True,
|
||||
)
|
||||
explicit = os.environ.get("LOG_LEVEL")
|
||||
if s.debug and explicit is None:
|
||||
effective = "DEBUG"
|
||||
else:
|
||||
effective = s.log_level.upper()
|
||||
assert effective == "DEBUG"
|
||||
|
||||
def test_explicit_log_level_overrides_debug(self):
|
||||
"""When LOG_LEVEL is explicitly set, it takes precedence over DEBUG=True."""
|
||||
with patch.dict(os.environ, {"LOG_LEVEL": "WARNING", "DEBUG": "true"}, clear=False):
|
||||
s = Settings(
|
||||
database_url="sqlite:///test.db",
|
||||
redis_url="redis://localhost:6379",
|
||||
openai_api_key="test",
|
||||
azure_ai_key="test",
|
||||
azure_region="test",
|
||||
azure_endpoint="https://test.example.com",
|
||||
gotenberg_url="http://localhost:3000",
|
||||
workdir="/tmp",
|
||||
auth_enabled=False,
|
||||
session_secret=None,
|
||||
debug=True,
|
||||
log_level="WARNING",
|
||||
)
|
||||
explicit = os.environ.get("LOG_LEVEL")
|
||||
if s.debug and explicit is None:
|
||||
effective = "DEBUG"
|
||||
else:
|
||||
effective = s.log_level.upper()
|
||||
assert effective == "WARNING"
|
||||
|
||||
def test_default_no_flags_gives_info(self):
|
||||
"""When neither DEBUG nor LOG_LEVEL is set, effective level is INFO."""
|
||||
env = os.environ.copy()
|
||||
env.pop("LOG_LEVEL", None)
|
||||
env.pop("DEBUG", None)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
s = Settings(
|
||||
database_url="sqlite:///test.db",
|
||||
redis_url="redis://localhost:6379",
|
||||
openai_api_key="test",
|
||||
azure_ai_key="test",
|
||||
azure_region="test",
|
||||
azure_endpoint="https://test.example.com",
|
||||
gotenberg_url="http://localhost:3000",
|
||||
workdir="/tmp",
|
||||
auth_enabled=False,
|
||||
session_secret=None,
|
||||
)
|
||||
explicit = os.environ.get("LOG_LEVEL")
|
||||
if s.debug and explicit is None:
|
||||
effective = "DEBUG"
|
||||
else:
|
||||
effective = s.log_level.upper()
|
||||
assert effective == "INFO"
|
||||
|
||||
def test_effective_level_maps_to_logging_constant(self):
|
||||
"""The effective level string maps to a valid logging constant."""
|
||||
for level_name in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
|
||||
assert getattr(logging, level_name) is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLoggingConfiguredAtStartup:
|
||||
"""Tests that the main module configures the root logger on import."""
|
||||
|
||||
def test_root_logger_has_handler(self):
|
||||
"""Root logger should have at least one handler after app import."""
|
||||
root = logging.getLogger()
|
||||
assert len(root.handlers) > 0, "Root logger has no handlers after app startup"
|
||||
|
||||
def test_root_logger_level_is_not_warning_default(self):
|
||||
"""Root logger should not be at the unconfigured WARNING default.
|
||||
|
||||
Our basicConfig(force=True) should have set it to at least INFO.
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
# The test env doesn't set DEBUG=True, so the level should be INFO (20)
|
||||
assert root.level <= logging.INFO
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestJsonFormatter:
|
||||
"""Tests for the _JsonFormatter used when LOG_FORMAT=json."""
|
||||
|
||||
def _make_formatter(self):
|
||||
"""Lazily import the JSON formatter from main module."""
|
||||
from app.main import _JsonFormatter
|
||||
|
||||
return _JsonFormatter()
|
||||
|
||||
def test_output_is_valid_json(self):
|
||||
"""JSON formatter output should be parseable JSON."""
|
||||
import json
|
||||
|
||||
fmt = self._make_formatter()
|
||||
record = logging.LogRecord(
|
||||
name="test.logger",
|
||||
level=logging.INFO,
|
||||
pathname="test.py",
|
||||
lineno=42,
|
||||
msg="Hello %s",
|
||||
args=("world",),
|
||||
exc_info=None,
|
||||
)
|
||||
result = fmt.format(record)
|
||||
parsed = json.loads(result)
|
||||
assert parsed["level"] == "INFO"
|
||||
assert parsed["logger"] == "test.logger"
|
||||
assert parsed["message"] == "Hello world"
|
||||
assert parsed["lineno"] == 42
|
||||
|
||||
def test_includes_timestamp_iso8601(self):
|
||||
"""JSON output should contain an ISO 8601 timestamp."""
|
||||
import json
|
||||
|
||||
fmt = self._make_formatter()
|
||||
record = logging.LogRecord(
|
||||
name="x",
|
||||
level=logging.DEBUG,
|
||||
pathname="x.py",
|
||||
lineno=1,
|
||||
msg="test",
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
parsed = json.loads(fmt.format(record))
|
||||
assert "timestamp" in parsed
|
||||
# ISO 8601 timestamps contain "T" and "+00:00" (UTC)
|
||||
assert "T" in parsed["timestamp"]
|
||||
|
||||
def test_includes_exc_info_when_present(self):
|
||||
"""JSON output should include exc_info when an exception is logged."""
|
||||
import json
|
||||
|
||||
fmt = self._make_formatter()
|
||||
try:
|
||||
raise ValueError("boom") # noqa: TRY301
|
||||
except ValueError:
|
||||
import sys
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="x",
|
||||
level=logging.ERROR,
|
||||
pathname="x.py",
|
||||
lineno=1,
|
||||
msg="error",
|
||||
args=(),
|
||||
exc_info=sys.exc_info(),
|
||||
)
|
||||
parsed = json.loads(fmt.format(record))
|
||||
assert "exc_info" in parsed
|
||||
assert "ValueError" in parsed["exc_info"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLogFormatSetting:
|
||||
"""Tests for the log_format and log_syslog_* config fields."""
|
||||
|
||||
_BASE_KWARGS = {
|
||||
"database_url": "sqlite:///test.db",
|
||||
"redis_url": "redis://localhost:6379",
|
||||
"openai_api_key": "test",
|
||||
"azure_ai_key": "test",
|
||||
"azure_region": "test",
|
||||
"azure_endpoint": "https://test.example.com",
|
||||
"gotenberg_url": "http://localhost:3000",
|
||||
"workdir": "/tmp",
|
||||
"auth_enabled": False,
|
||||
"session_secret": None,
|
||||
}
|
||||
|
||||
def test_log_format_default_is_text(self):
|
||||
"""Test that log_format defaults to 'text'."""
|
||||
config = Settings(**self._BASE_KWARGS)
|
||||
assert config.log_format == "text"
|
||||
|
||||
def test_log_format_accepts_json(self):
|
||||
"""Test that log_format accepts 'json'."""
|
||||
config = Settings(**self._BASE_KWARGS, log_format="json")
|
||||
assert config.log_format == "json"
|
||||
|
||||
def test_log_syslog_defaults(self):
|
||||
"""Test syslog forwarding defaults."""
|
||||
config = Settings(**self._BASE_KWARGS)
|
||||
assert config.log_syslog_enabled is False
|
||||
assert config.log_syslog_host == "localhost"
|
||||
assert config.log_syslog_port == 514
|
||||
assert config.log_syslog_protocol == "udp"
|
||||
|
||||
def test_log_syslog_can_be_enabled(self):
|
||||
"""Test that syslog forwarding can be enabled."""
|
||||
config = Settings(**self._BASE_KWARGS, log_syslog_enabled=True, log_syslog_host="syslog.example.com")
|
||||
assert config.log_syslog_enabled is True
|
||||
assert config.log_syslog_host == "syslog.example.com"
|
||||
+117
-1
@@ -18,7 +18,7 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base
|
||||
from app.models import FileRecord
|
||||
from app.models import ApiToken, FileRecord
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
@@ -162,6 +162,122 @@ class TestGetCurrentOwnerId:
|
||||
request.session = {}
|
||||
assert get_current_owner_id(request) is None
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_resolves_from_api_token_user_state(self):
|
||||
"""get_current_owner_id should resolve from request.state.api_token_user."""
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
request = MagicMock()
|
||||
request.session = {}
|
||||
request.state.api_token_user = {
|
||||
"id": "tok-owner",
|
||||
"preferred_username": "tok-owner",
|
||||
"email": "tok-owner",
|
||||
}
|
||||
assert get_current_owner_id(request) == "tok-owner"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_session_takes_precedence_over_api_token_user(self):
|
||||
"""Session auth should take precedence over api_token_user in state."""
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
request = MagicMock()
|
||||
request.session = {"user": {"sub": "session-sub", "email": "session@example.com"}}
|
||||
request.state.api_token_user = {"id": "tok-owner"}
|
||||
assert get_current_owner_id(request) == "session-sub"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_resolves_bearer_token_directly(self, mu_engine, mu_session):
|
||||
"""get_current_owner_id should resolve a Bearer token when no session exists."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.api.api_tokens import generate_api_token, hash_token
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
# Create a token in the DB
|
||||
plaintext = generate_api_token()
|
||||
token_hash = hash_token(plaintext)
|
||||
db_token = ApiToken(
|
||||
owner_id="bearer-owner",
|
||||
name="Test Bearer",
|
||||
token_hash=token_hash,
|
||||
token_prefix=plaintext[:12],
|
||||
is_active=True,
|
||||
)
|
||||
mu_session.add(db_token)
|
||||
mu_session.commit()
|
||||
|
||||
# Build a mock request with Bearer header but no session.
|
||||
# SimpleNamespace starts with no attributes so getattr(..., None) works.
|
||||
request = MagicMock()
|
||||
request.session = {}
|
||||
request.state = SimpleNamespace()
|
||||
request.headers = {"authorization": f"Bearer {plaintext}"}
|
||||
request.client.host = "127.0.0.1"
|
||||
|
||||
# Provide the test session and make close() a no-op so the shared
|
||||
# session is not torn down prematurely.
|
||||
noop_close = MagicMock()
|
||||
with patch("app.database.SessionLocal", return_value=mu_session), patch.object(mu_session, "close", noop_close):
|
||||
result = get_current_owner_id(request)
|
||||
|
||||
assert result == "bearer-owner"
|
||||
# Verify the resolved user was cached in request.state
|
||||
assert request.state.api_token_user["id"] == "bearer-owner"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_returns_none_for_invalid_bearer_token(self, mu_engine, mu_session):
|
||||
"""get_current_owner_id should return None for an invalid Bearer token."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
request = MagicMock()
|
||||
request.session = {}
|
||||
request.state = SimpleNamespace()
|
||||
request.headers = {"authorization": "Bearer de_invalid_token_value"}
|
||||
request.client.host = "127.0.0.1"
|
||||
|
||||
noop_close = MagicMock()
|
||||
with patch("app.database.SessionLocal", return_value=mu_session), patch.object(mu_session, "close", noop_close):
|
||||
result = get_current_owner_id(request)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestOwnerIdFromUser:
|
||||
"""Tests for the _owner_id_from_user helper."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_prefers_sub(self):
|
||||
from app.utils.user_scope import _owner_id_from_user
|
||||
|
||||
assert _owner_id_from_user({"sub": "s", "preferred_username": "u", "email": "e"}) == "s"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_falls_back_to_preferred_username(self):
|
||||
from app.utils.user_scope import _owner_id_from_user
|
||||
|
||||
assert _owner_id_from_user({"preferred_username": "u", "email": "e"}) == "u"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_falls_back_to_email(self):
|
||||
from app.utils.user_scope import _owner_id_from_user
|
||||
|
||||
assert _owner_id_from_user({"email": "e"}) == "e"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_falls_back_to_id(self):
|
||||
from app.utils.user_scope import _owner_id_from_user
|
||||
|
||||
assert _owner_id_from_user({"id": "i"}) == "i"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_returns_none_for_empty_dict(self):
|
||||
from app.utils.user_scope import _owner_id_from_user
|
||||
|
||||
assert _owner_id_from_user({}) is None
|
||||
|
||||
|
||||
class TestApplyOwnerFilter:
|
||||
"""Tests for apply_owner_filter()."""
|
||||
|
||||
@@ -830,3 +830,58 @@ class TestUserNotificationService:
|
||||
|
||||
result = _send_email_notification({"smtp_host": "smtp.example.com"}, "Title", "Body")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestBenchmark:
|
||||
@pytest.mark.unit
|
||||
def test_update_preferences_benchmark(self, notif_engine, notif_session):
|
||||
import statistics
|
||||
import time
|
||||
|
||||
from app.main import app
|
||||
|
||||
target = UserNotificationTarget(
|
||||
owner_id=_OWNER,
|
||||
channel_type="webhook",
|
||||
name="My Webhook",
|
||||
config=json.dumps({"url": "https://x.com"}),
|
||||
)
|
||||
notif_session.add(target)
|
||||
notif_session.commit()
|
||||
notif_session.refresh(target)
|
||||
|
||||
client = _make_client(notif_engine, _OWNER)
|
||||
try:
|
||||
items_count = 100
|
||||
preferences = []
|
||||
for i in range(items_count):
|
||||
preferences.append(
|
||||
{
|
||||
"event_type": f"event.type.{i}",
|
||||
"channel_type": "webhook",
|
||||
"is_enabled": True,
|
||||
"target_id": target.id,
|
||||
}
|
||||
)
|
||||
|
||||
payload = {"preferences": preferences}
|
||||
|
||||
# Warm up
|
||||
client.put("/api/user-notifications/preferences", json=payload)
|
||||
|
||||
times = []
|
||||
for _ in range(5):
|
||||
# Alter the values a bit so it's a real update
|
||||
for p in payload["preferences"]:
|
||||
p["is_enabled"] = not p["is_enabled"]
|
||||
|
||||
start = time.time()
|
||||
resp = client.put("/api/user-notifications/preferences", json=payload)
|
||||
end = time.time()
|
||||
|
||||
assert resp.status_code == 200
|
||||
times.append(end - start)
|
||||
|
||||
print(f"\nAverage time: {statistics.mean(times):.4f}s")
|
||||
finally:
|
||||
_cleanup(app)
|
||||
|
||||
@@ -360,6 +360,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
@@ -368,6 +369,7 @@ class TestSendToAllDestinations:
|
||||
mock_upload,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -397,6 +399,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
@@ -410,6 +413,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all.settings")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_dropbox")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_nextcloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_paperless")
|
||||
@@ -434,6 +438,7 @@ class TestSendToAllDestinations:
|
||||
mock_paperless,
|
||||
mock_nextcloud,
|
||||
mock_should_s3,
|
||||
mock_sharepoint,
|
||||
mock_icloud,
|
||||
mock_should_dropbox,
|
||||
mock_settings,
|
||||
@@ -456,6 +461,7 @@ class TestSendToAllDestinations:
|
||||
mock_sftp.return_value = False
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_dropbox_upload.delay.return_value = MagicMock(id="dropbox-task")
|
||||
mock_s3_upload.delay.return_value = MagicMock(id="s3-task")
|
||||
@@ -478,12 +484,14 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
def test_skips_unconfigured_services(
|
||||
self,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -513,6 +521,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
result = send_to_all_destinations.apply(args=[str(test_file), False, 1])
|
||||
@@ -534,6 +543,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
@@ -542,6 +552,7 @@ class TestSendToAllDestinations:
|
||||
mock_upload,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -571,6 +582,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
@@ -593,6 +605,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||
@@ -603,6 +616,7 @@ class TestSendToAllDestinations:
|
||||
mock_validator,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -633,6 +647,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.return_value = MagicMock(id="task-123")
|
||||
|
||||
@@ -653,6 +668,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.get_configured_services_from_validator")
|
||||
@@ -661,6 +677,7 @@ class TestSendToAllDestinations:
|
||||
mock_validator,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -691,6 +708,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
# Should not raise, should fall back to individual checks
|
||||
@@ -710,6 +728,7 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all.upload_to_dropbox")
|
||||
@@ -718,6 +737,7 @@ class TestSendToAllDestinations:
|
||||
mock_upload,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -747,6 +767,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
mock_upload.delay.side_effect = Exception("Queue error")
|
||||
|
||||
@@ -758,6 +779,7 @@ class TestSendToAllDestinations:
|
||||
assert "dropbox_error" in result.result["tasks"]
|
||||
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@@ -786,6 +808,7 @@ class TestSendToAllDestinations:
|
||||
mock_email,
|
||||
mock_onedrive,
|
||||
mock_s3,
|
||||
mock_sharepoint,
|
||||
mock_icloud,
|
||||
tmp_path,
|
||||
):
|
||||
@@ -808,6 +831,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
# Mock database session
|
||||
@@ -836,12 +860,14 @@ class TestSendToAllDestinations:
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sftp")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_email")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_onedrive")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_sharepoint")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_icloud")
|
||||
@patch("app.tasks.send_to_all._should_upload_to_s3")
|
||||
def test_should_upload_check_exception_handling(
|
||||
self,
|
||||
mock_s3,
|
||||
mock_icloud,
|
||||
mock_sharepoint,
|
||||
mock_onedrive,
|
||||
mock_email,
|
||||
mock_sftp,
|
||||
@@ -871,6 +897,7 @@ class TestSendToAllDestinations:
|
||||
mock_email.return_value = False
|
||||
mock_onedrive.return_value = False
|
||||
mock_s3.return_value = False
|
||||
mock_sharepoint.return_value = False
|
||||
mock_icloud.return_value = False
|
||||
|
||||
# Should not raise, should treat as not configured
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
"""Tests for server-side session management and QR code login.
|
||||
|
||||
Covers:
|
||||
* Session creation, validation, revocation, and cleanup
|
||||
* "Log off everywhere" (revoke all sessions)
|
||||
* QR login challenge creation, validation, claiming, and status polling
|
||||
* Session management API endpoints (list, revoke, revoke-all)
|
||||
* QR auth API endpoints (challenge, status, claim)
|
||||
* Device info parsing from User-Agent strings
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.database import Base
|
||||
from app.models import ApiToken, QRLoginChallenge, UserSession
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session():
|
||||
"""Provide an in-memory SQLite session with all tables created."""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
TestSession = sessionmaker(bind=engine)
|
||||
session = TestSession()
|
||||
yield session
|
||||
session.close()
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_user_id():
|
||||
return "user@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestUserSessionModel:
|
||||
"""Tests for the UserSession ORM model."""
|
||||
|
||||
def test_create_user_session(self, db_session: Session, sample_user_id: str):
|
||||
"""Test creating a UserSession record."""
|
||||
now = datetime.now(timezone.utc)
|
||||
session = UserSession(
|
||||
session_token=secrets.token_urlsafe(64),
|
||||
user_id=sample_user_id,
|
||||
ip_address="192.168.1.1",
|
||||
user_agent="Mozilla/5.0",
|
||||
device_info="Chrome on macOS",
|
||||
expires_at=now + timedelta(days=30),
|
||||
)
|
||||
db_session.add(session)
|
||||
db_session.commit()
|
||||
|
||||
assert session.id is not None
|
||||
assert session.user_id == sample_user_id
|
||||
assert session.is_revoked is False
|
||||
assert session.device_info == "Chrome on macOS"
|
||||
|
||||
def test_session_default_values(self, db_session: Session, sample_user_id: str):
|
||||
"""Test that default values are set correctly."""
|
||||
session = UserSession(
|
||||
session_token="test_token_123",
|
||||
user_id=sample_user_id,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=30),
|
||||
)
|
||||
db_session.add(session)
|
||||
db_session.commit()
|
||||
|
||||
assert session.is_revoked is False
|
||||
assert session.revoked_at is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestQRLoginChallengeModel:
|
||||
"""Tests for the QRLoginChallenge ORM model."""
|
||||
|
||||
def test_create_challenge(self, db_session: Session, sample_user_id: str):
|
||||
"""Test creating a QRLoginChallenge record."""
|
||||
challenge = QRLoginChallenge(
|
||||
challenge_token=secrets.token_urlsafe(64),
|
||||
user_id=sample_user_id,
|
||||
created_by_ip="10.0.0.1",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
|
||||
)
|
||||
db_session.add(challenge)
|
||||
db_session.commit()
|
||||
|
||||
assert challenge.id is not None
|
||||
assert challenge.is_claimed is False
|
||||
assert challenge.is_cancelled is False
|
||||
|
||||
def test_challenge_default_values(self, db_session: Session, sample_user_id: str):
|
||||
"""Test that QRLoginChallenge defaults are correct."""
|
||||
challenge = QRLoginChallenge(
|
||||
challenge_token="challenge_test_123",
|
||||
user_id=sample_user_id,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(seconds=120),
|
||||
)
|
||||
db_session.add(challenge)
|
||||
db_session.commit()
|
||||
|
||||
assert challenge.is_claimed is False
|
||||
assert challenge.is_cancelled is False
|
||||
assert challenge.claimed_at is None
|
||||
assert challenge.device_name is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session Manager Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSessionManager:
|
||||
"""Tests for app/utils/session_manager.py functions."""
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_get_session_lifetime_days_default(self, mock_settings):
|
||||
"""Test default session lifetime."""
|
||||
from app.utils.session_manager import get_session_lifetime_days
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
assert get_session_lifetime_days() == 30
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_get_session_lifetime_days_custom(self, mock_settings):
|
||||
"""Test custom session lifetime overrides default."""
|
||||
from app.utils.session_manager import get_session_lifetime_days
|
||||
|
||||
mock_settings.session_lifetime_custom_days = 90
|
||||
mock_settings.session_lifetime_days = 30
|
||||
assert get_session_lifetime_days() == 90
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_get_session_lifetime_days_minimum(self, mock_settings):
|
||||
"""Test session lifetime has a minimum of 1 day."""
|
||||
from app.utils.session_manager import get_session_lifetime_days
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 0
|
||||
assert get_session_lifetime_days() == 1
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_get_session_max_age_seconds(self, mock_settings):
|
||||
"""Test session max age in seconds."""
|
||||
from app.utils.session_manager import get_session_max_age_seconds
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
assert get_session_max_age_seconds() == 30 * 86400
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_create_session(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test creating a server-side session."""
|
||||
from app.utils.session_manager import create_session
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
user_session = create_session(
|
||||
db_session,
|
||||
user_id=sample_user_id,
|
||||
ip_address="10.0.0.1",
|
||||
user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0",
|
||||
)
|
||||
|
||||
assert user_session.id is not None
|
||||
assert user_session.user_id == sample_user_id
|
||||
assert user_session.ip_address == "10.0.0.1"
|
||||
assert user_session.session_token is not None
|
||||
assert len(user_session.session_token) > 32
|
||||
assert user_session.is_revoked is False
|
||||
assert user_session.device_info is not None
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_validate_session_valid(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test validating a valid session."""
|
||||
from app.utils.session_manager import create_session, validate_session
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
user_session = create_session(db_session, user_id=sample_user_id)
|
||||
result = validate_session(db_session, user_session.session_token)
|
||||
assert result is not None
|
||||
assert result.id == user_session.id
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_validate_session_revoked(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that revoked sessions are rejected."""
|
||||
from app.utils.session_manager import create_session, validate_session
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
user_session = create_session(db_session, user_id=sample_user_id)
|
||||
user_session.is_revoked = True
|
||||
db_session.commit()
|
||||
|
||||
result = validate_session(db_session, user_session.session_token)
|
||||
assert result is None
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_validate_session_expired(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that expired sessions are rejected."""
|
||||
from app.utils.session_manager import create_session, validate_session
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
user_session = create_session(db_session, user_id=sample_user_id)
|
||||
user_session.expires_at = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
db_session.commit()
|
||||
|
||||
result = validate_session(db_session, user_session.session_token)
|
||||
assert result is None
|
||||
|
||||
def test_validate_session_empty_token(self, db_session: Session):
|
||||
"""Test that empty token returns None."""
|
||||
from app.utils.session_manager import validate_session
|
||||
|
||||
assert validate_session(db_session, "") is None
|
||||
assert validate_session(db_session, None) is None
|
||||
|
||||
def test_validate_session_nonexistent_token(self, db_session: Session):
|
||||
"""Test that nonexistent token returns None."""
|
||||
from app.utils.session_manager import validate_session
|
||||
|
||||
assert validate_session(db_session, "nonexistent_token_xyz") is None
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_revoke_session(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test revoking a single session."""
|
||||
from app.utils.session_manager import create_session, revoke_session, validate_session
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
user_session = create_session(db_session, user_id=sample_user_id)
|
||||
assert revoke_session(db_session, user_session.id, sample_user_id) is True
|
||||
|
||||
# Session should now be invalid
|
||||
assert validate_session(db_session, user_session.session_token) is None
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_revoke_session_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that a user cannot revoke another user's session."""
|
||||
from app.utils.session_manager import create_session, revoke_session
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
user_session = create_session(db_session, user_id=sample_user_id)
|
||||
assert revoke_session(db_session, user_session.id, "other_user@example.com") is False
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_revoke_all_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test revoking all sessions for a user."""
|
||||
from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
s1 = create_session(db_session, user_id=sample_user_id)
|
||||
s2 = create_session(db_session, user_id=sample_user_id)
|
||||
s3 = create_session(db_session, user_id=sample_user_id)
|
||||
|
||||
count = revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=False)
|
||||
assert count == 3
|
||||
|
||||
# All sessions should be revoked
|
||||
active = list_user_sessions(db_session, sample_user_id)
|
||||
assert len(active) == 0
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_revoke_all_except_current(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test revoking all sessions except the current one."""
|
||||
from app.utils.session_manager import create_session, list_user_sessions, revoke_all_sessions
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
s1 = create_session(db_session, user_id=sample_user_id)
|
||||
s2 = create_session(db_session, user_id=sample_user_id)
|
||||
s3 = create_session(db_session, user_id=sample_user_id)
|
||||
|
||||
count = revoke_all_sessions(
|
||||
db_session,
|
||||
sample_user_id,
|
||||
except_session_id=s1.id,
|
||||
revoke_api_tokens=False,
|
||||
)
|
||||
assert count == 2
|
||||
|
||||
active = list_user_sessions(db_session, sample_user_id)
|
||||
assert len(active) == 1
|
||||
assert active[0].id == s1.id
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_revoke_all_includes_api_tokens(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that revoke-all also revokes API tokens."""
|
||||
from app.utils.session_manager import create_session, revoke_all_sessions
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
create_session(db_session, user_id=sample_user_id)
|
||||
|
||||
# Create an API token
|
||||
token = ApiToken(
|
||||
owner_id=sample_user_id,
|
||||
name="Test Token",
|
||||
token_hash="abc123hash",
|
||||
token_prefix="de_abc12345",
|
||||
)
|
||||
db_session.add(token)
|
||||
db_session.commit()
|
||||
|
||||
revoke_all_sessions(db_session, sample_user_id, revoke_api_tokens=True)
|
||||
|
||||
db_session.refresh(token)
|
||||
assert token.is_active is False
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_list_user_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test listing active sessions for a user."""
|
||||
from app.utils.session_manager import create_session, list_user_sessions
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
create_session(db_session, user_id=sample_user_id)
|
||||
create_session(db_session, user_id=sample_user_id)
|
||||
create_session(db_session, user_id="other@example.com")
|
||||
|
||||
sessions = list_user_sessions(db_session, sample_user_id)
|
||||
assert len(sessions) == 2
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_cleanup_expired_sessions(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test cleaning up expired sessions."""
|
||||
from app.utils.session_manager import cleanup_expired_sessions, create_session
|
||||
|
||||
mock_settings.session_lifetime_custom_days = None
|
||||
mock_settings.session_lifetime_days = 30
|
||||
|
||||
# Create a session that expired 10 days ago
|
||||
session = create_session(db_session, user_id=sample_user_id)
|
||||
session.expires_at = datetime.now(timezone.utc) - timedelta(days=10)
|
||||
db_session.commit()
|
||||
|
||||
count = cleanup_expired_sessions(db_session)
|
||||
assert count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR Login Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestQRLogin:
|
||||
"""Tests for QR login challenge/claim flow."""
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_create_qr_challenge(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test creating a QR login challenge."""
|
||||
from app.utils.session_manager import create_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id, ip_address="10.0.0.1")
|
||||
|
||||
assert challenge.id is not None
|
||||
assert challenge.user_id == sample_user_id
|
||||
assert challenge.challenge_token is not None
|
||||
assert len(challenge.challenge_token) > 32
|
||||
assert challenge.is_claimed is False
|
||||
assert challenge.created_by_ip == "10.0.0.1"
|
||||
# SQLite returns naive datetimes; normalise before comparison
|
||||
expires = challenge.expires_at
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
assert expires > datetime.now(timezone.utc)
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_create_qr_challenge_ttl_seconds(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that ttl_seconds can be derived from created_at and expires_at.
|
||||
|
||||
The API endpoint computes ttl_seconds = (expires_at - created_at) to
|
||||
allow the client to run a countdown timer without comparing absolute
|
||||
timestamps (avoiding clock-skew issues).
|
||||
"""
|
||||
from app.utils.session_manager import create_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
|
||||
ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
|
||||
assert ttl_seconds == 120
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_create_qr_challenge_custom_ttl(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that a custom TTL is correctly reflected in the challenge timestamps."""
|
||||
from app.utils.session_manager import create_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 300
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
|
||||
ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
|
||||
assert ttl_seconds == 300
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test validating a valid QR challenge."""
|
||||
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
result = validate_qr_challenge(db_session, challenge.challenge_token)
|
||||
assert result is not None
|
||||
assert result.id == challenge.id
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_validate_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that expired challenges are rejected."""
|
||||
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
db_session.commit()
|
||||
|
||||
result = validate_qr_challenge(db_session, challenge.challenge_token)
|
||||
assert result is None
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_validate_qr_challenge_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that claimed challenges are rejected (replay protection)."""
|
||||
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
challenge.is_claimed = True
|
||||
db_session.commit()
|
||||
|
||||
result = validate_qr_challenge(db_session, challenge.challenge_token)
|
||||
assert result is None
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_validate_qr_challenge_cancelled(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that cancelled challenges are rejected."""
|
||||
from app.utils.session_manager import create_qr_challenge, validate_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
challenge.is_cancelled = True
|
||||
db_session.commit()
|
||||
|
||||
result = validate_qr_challenge(db_session, challenge.challenge_token)
|
||||
assert result is None
|
||||
|
||||
def test_validate_qr_challenge_empty(self, db_session: Session):
|
||||
"""Test that empty challenge token returns None."""
|
||||
from app.utils.session_manager import validate_qr_challenge
|
||||
|
||||
assert validate_qr_challenge(db_session, "") is None
|
||||
assert validate_qr_challenge(db_session, None) is None
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_claim_qr_challenge_success(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test successfully claiming a QR challenge."""
|
||||
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
result = claim_qr_challenge(
|
||||
db_session,
|
||||
challenge.challenge_token,
|
||||
device_name="Christian's iPhone 15 Pro",
|
||||
ip_address="192.168.1.100",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["token"].startswith("de_")
|
||||
assert result["token_id"] is not None
|
||||
assert result["owner_id"] == sample_user_id
|
||||
assert "QR" in result["name"]
|
||||
|
||||
# Challenge should now be claimed
|
||||
db_session.refresh(challenge)
|
||||
assert challenge.is_claimed is True
|
||||
assert challenge.claimed_by_ip == "192.168.1.100"
|
||||
assert challenge.device_name == "Christian's iPhone 15 Pro"
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_claim_qr_challenge_replay_protection(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that a claimed challenge cannot be claimed again."""
|
||||
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
|
||||
# First claim succeeds
|
||||
result1 = claim_qr_challenge(db_session, challenge.challenge_token)
|
||||
assert result1 is not None
|
||||
|
||||
# Second claim fails (replay protection)
|
||||
result2 = claim_qr_challenge(db_session, challenge.challenge_token)
|
||||
assert result2 is None
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_claim_qr_challenge_expired(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that expired challenges cannot be claimed."""
|
||||
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
db_session.commit()
|
||||
|
||||
result = claim_qr_challenge(db_session, challenge.challenge_token)
|
||||
assert result is None
|
||||
|
||||
def test_claim_qr_challenge_invalid_token(self, db_session: Session):
|
||||
"""Test claiming with an invalid token."""
|
||||
from app.utils.session_manager import claim_qr_challenge
|
||||
|
||||
result = claim_qr_challenge(db_session, "nonexistent_token_xyz")
|
||||
assert result is None
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_get_challenge_status_pending(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test getting status of a pending challenge."""
|
||||
from app.utils.session_manager import create_qr_challenge, get_challenge_status
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
status = get_challenge_status(db_session, challenge.id, sample_user_id)
|
||||
|
||||
assert status is not None
|
||||
assert status["status"] == "pending"
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_get_challenge_status_claimed(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test getting status of a claimed challenge."""
|
||||
from app.utils.session_manager import claim_qr_challenge, create_qr_challenge, get_challenge_status
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
claim_qr_challenge(db_session, challenge.challenge_token, device_name="Test Device")
|
||||
|
||||
status = get_challenge_status(db_session, challenge.id, sample_user_id)
|
||||
assert status is not None
|
||||
assert status["status"] == "claimed"
|
||||
assert status["device_name"] == "Test Device"
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_get_challenge_status_expired(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test getting status of an expired challenge."""
|
||||
from app.utils.session_manager import create_qr_challenge, get_challenge_status
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
challenge.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
db_session.commit()
|
||||
|
||||
status = get_challenge_status(db_session, challenge.id, sample_user_id)
|
||||
assert status["status"] == "expired"
|
||||
|
||||
@patch("app.utils.session_manager.settings")
|
||||
def test_get_challenge_status_wrong_user(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||
"""Test that a user cannot see another user's challenge status."""
|
||||
from app.utils.session_manager import create_qr_challenge, get_challenge_status
|
||||
|
||||
mock_settings.qr_login_challenge_ttl_seconds = 120
|
||||
|
||||
challenge = create_qr_challenge(db_session, sample_user_id)
|
||||
status = get_challenge_status(db_session, challenge.id, "other@example.com")
|
||||
assert status is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device Info Parsing Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDeviceInfoParsing:
|
||||
"""Tests for User-Agent parsing."""
|
||||
|
||||
def test_chrome_macos(self):
|
||||
from app.utils.session_manager import _parse_device_info
|
||||
|
||||
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
result = _parse_device_info(ua)
|
||||
assert "Chrome" in result
|
||||
assert "macOS" in result
|
||||
|
||||
def test_safari_iphone(self):
|
||||
from app.utils.session_manager import _parse_device_info
|
||||
|
||||
ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
|
||||
result = _parse_device_info(ua)
|
||||
assert "Safari" in result
|
||||
assert "iPhone" in result
|
||||
|
||||
def test_firefox_windows(self):
|
||||
from app.utils.session_manager import _parse_device_info
|
||||
|
||||
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
||||
result = _parse_device_info(ua)
|
||||
assert "Firefox" in result
|
||||
assert "Windows" in result
|
||||
|
||||
def test_edge_windows(self):
|
||||
from app.utils.session_manager import _parse_device_info
|
||||
|
||||
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
|
||||
result = _parse_device_info(ua)
|
||||
assert "Edge" in result
|
||||
assert "Windows" in result
|
||||
|
||||
def test_android_chrome(self):
|
||||
from app.utils.session_manager import _parse_device_info
|
||||
|
||||
ua = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.210 Mobile Safari/537.36"
|
||||
result = _parse_device_info(ua)
|
||||
assert "Chrome" in result
|
||||
assert "Android" in result
|
||||
|
||||
def test_none_user_agent(self):
|
||||
from app.utils.session_manager import _parse_device_info
|
||||
|
||||
assert _parse_device_info(None) is None
|
||||
|
||||
def test_empty_user_agent(self):
|
||||
from app.utils.session_manager import _parse_device_info
|
||||
|
||||
assert _parse_device_info("") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSessionConfig:
|
||||
"""Tests for session-related configuration fields."""
|
||||
|
||||
def test_session_lifetime_days_field_exists(self):
|
||||
"""Verify session_lifetime_days field is defined in Settings."""
|
||||
from app.config import Settings
|
||||
|
||||
# Check the field exists in the model
|
||||
assert "session_lifetime_days" in Settings.model_fields
|
||||
|
||||
def test_session_lifetime_custom_days_field_exists(self):
|
||||
"""Verify session_lifetime_custom_days field is defined in Settings."""
|
||||
from app.config import Settings
|
||||
|
||||
assert "session_lifetime_custom_days" in Settings.model_fields
|
||||
|
||||
def test_qr_login_challenge_ttl_field_exists(self):
|
||||
"""Verify qr_login_challenge_ttl_seconds field is defined in Settings."""
|
||||
from app.config import Settings
|
||||
|
||||
assert "qr_login_challenge_ttl_seconds" in Settings.model_fields
|
||||
@@ -289,7 +289,8 @@ class TestNotifySettingsUpdated:
|
||||
call_args = mock_redis_instance.set.call_args[0]
|
||||
assert call_args[0] == SETTINGS_VERSION_KEY
|
||||
|
||||
def test_does_not_raise_on_redis_failure(self):
|
||||
@patch("app.utils.settings_sync.logger")
|
||||
def test_does_not_raise_on_redis_failure(self, mock_logger):
|
||||
"""notify_settings_updated must not propagate Redis errors."""
|
||||
from app.utils.settings_sync import notify_settings_updated
|
||||
|
||||
@@ -297,6 +298,35 @@ class TestNotifySettingsUpdated:
|
||||
mock_redis_module.from_url.side_effect = Exception("Redis down")
|
||||
# Should not raise
|
||||
notify_settings_updated()
|
||||
mock_logger.warning.assert_any_call("Could not publish settings update to Redis: Redis down")
|
||||
|
||||
@patch("app.utils.settings_sync.logger")
|
||||
def test_does_not_raise_on_reload_failure(self, mock_logger):
|
||||
"""notify_settings_updated must not propagate settings reload errors."""
|
||||
from app.utils.settings_sync import notify_settings_updated
|
||||
|
||||
with patch("app.utils.config_loader.reload_settings_from_db") as mock_reload:
|
||||
mock_reload.side_effect = Exception("Reload error")
|
||||
# We mock redis so that we skip over the redis block, and mock ensure_ocr_languages_async to prevent its side effects.
|
||||
with patch("app.utils.settings_sync.redis"):
|
||||
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async"):
|
||||
# Should not raise
|
||||
notify_settings_updated()
|
||||
mock_logger.warning.assert_any_call("Could not reload in-process settings: Reload error")
|
||||
|
||||
@patch("app.utils.settings_sync.logger")
|
||||
def test_does_not_raise_on_ocr_language_check_failure(self, mock_logger):
|
||||
"""notify_settings_updated must not propagate OCR language check errors."""
|
||||
from app.utils.settings_sync import notify_settings_updated
|
||||
|
||||
with patch("app.utils.ocr_language_manager.ensure_ocr_languages_async") as mock_ensure:
|
||||
mock_ensure.side_effect = Exception("OCR error")
|
||||
# We mock redis and reload_settings_from_db so we only test the OCR block failure.
|
||||
with patch("app.utils.settings_sync.redis"):
|
||||
with patch("app.utils.config_loader.reload_settings_from_db"):
|
||||
# Should not raise
|
||||
notify_settings_updated()
|
||||
mock_logger.warning.assert_any_call("Could not schedule OCR language check: OCR error")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import app.utils.settings_sync
|
||||
from app.utils.settings_sync import (
|
||||
SETTINGS_VERSION_KEY,
|
||||
notify_settings_updated,
|
||||
register_settings_reload_signal,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_last_seen_version():
|
||||
"""Reset the global variable before and after tests."""
|
||||
app.utils.settings_sync._last_seen_version = ""
|
||||
yield
|
||||
app.utils.settings_sync._last_seen_version = ""
|
||||
|
||||
|
||||
@patch("app.utils.settings_sync.redis.from_url")
|
||||
@patch("app.utils.config_loader.reload_settings_from_db")
|
||||
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
|
||||
@patch("app.utils.settings_sync.time.time", return_value=12345.0)
|
||||
def test_notify_settings_updated_success(mock_time, mock_ensure_ocr, mock_reload, mock_redis):
|
||||
# Setup mock redis instance
|
||||
mock_redis_instance = MagicMock()
|
||||
mock_redis.return_value = mock_redis_instance
|
||||
|
||||
notify_settings_updated()
|
||||
|
||||
# Verify redis calls
|
||||
mock_redis.assert_called_once()
|
||||
mock_redis_instance.set.assert_called_once_with(SETTINGS_VERSION_KEY, "12345.0")
|
||||
|
||||
# Verify other calls
|
||||
mock_reload.assert_called_once()
|
||||
mock_ensure_ocr.assert_called_once()
|
||||
|
||||
|
||||
@patch("app.utils.settings_sync.redis.from_url")
|
||||
@patch("app.utils.config_loader.reload_settings_from_db")
|
||||
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
|
||||
def test_notify_settings_updated_redis_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
|
||||
# Setup mock redis to fail
|
||||
mock_redis.side_effect = Exception("Redis connection failed")
|
||||
|
||||
notify_settings_updated()
|
||||
|
||||
# Verification: should continue and call reload and ocr despite redis failure
|
||||
mock_reload.assert_called_once()
|
||||
mock_ensure_ocr.assert_called_once()
|
||||
assert "Could not publish settings update to Redis: Redis connection failed" in caplog.text
|
||||
|
||||
|
||||
@patch("app.utils.settings_sync.redis.from_url")
|
||||
@patch("app.utils.config_loader.reload_settings_from_db")
|
||||
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
|
||||
def test_notify_settings_updated_reload_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
|
||||
# Setup reload to fail
|
||||
mock_reload.side_effect = Exception("Reload failed")
|
||||
|
||||
mock_redis_instance = MagicMock()
|
||||
mock_redis.return_value = mock_redis_instance
|
||||
|
||||
notify_settings_updated()
|
||||
|
||||
# Verification: redis should be called, reload fails, ocr should still be called
|
||||
mock_redis_instance.set.assert_called_once()
|
||||
mock_ensure_ocr.assert_called_once()
|
||||
assert "Could not reload in-process settings: Reload failed" in caplog.text
|
||||
|
||||
|
||||
@patch("app.utils.settings_sync.redis.from_url")
|
||||
@patch("app.utils.config_loader.reload_settings_from_db")
|
||||
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
|
||||
def test_notify_settings_updated_ocr_failure(mock_ensure_ocr, mock_reload, mock_redis, caplog):
|
||||
# Setup ocr check to fail
|
||||
mock_ensure_ocr.side_effect = Exception("OCR check failed")
|
||||
|
||||
mock_redis_instance = MagicMock()
|
||||
mock_redis.return_value = mock_redis_instance
|
||||
|
||||
notify_settings_updated()
|
||||
|
||||
# Verification: all should be called, ocr failure logged
|
||||
mock_redis_instance.set.assert_called_once()
|
||||
mock_reload.assert_called_once()
|
||||
assert "Could not schedule OCR language check: OCR check failed" in caplog.text
|
||||
|
||||
|
||||
@patch("app.utils.settings_sync.task_prerun.connect")
|
||||
def test_register_settings_reload_signal(mock_connect):
|
||||
register_settings_reload_signal()
|
||||
# It should register a signal with task_prerun
|
||||
mock_connect.assert_called_once_with(weak=False)
|
||||
|
||||
|
||||
@patch("app.utils.settings_sync.task_prerun.connect")
|
||||
@patch("app.utils.settings_sync.redis.from_url")
|
||||
@patch("app.utils.config_loader.reload_settings_from_db")
|
||||
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
|
||||
def test_reload_if_stale_new_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
|
||||
# Capture the registered callback
|
||||
mock_decorator = MagicMock()
|
||||
mock_connect.return_value = mock_decorator
|
||||
|
||||
register_settings_reload_signal()
|
||||
|
||||
mock_connect.assert_called_once_with(weak=False)
|
||||
# Get the callback function
|
||||
callback = mock_decorator.call_args[0][0]
|
||||
|
||||
# Setup redis to return a new version
|
||||
mock_redis_instance = MagicMock()
|
||||
mock_redis_instance.get.return_value = b"new_version"
|
||||
mock_redis.return_value = mock_redis_instance
|
||||
|
||||
# Initial state check
|
||||
assert app.utils.settings_sync._last_seen_version == ""
|
||||
|
||||
# Call the callback
|
||||
callback(sender="test")
|
||||
|
||||
# Verification
|
||||
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
|
||||
mock_reload.assert_called_once()
|
||||
mock_ensure_ocr.assert_called_once()
|
||||
assert app.utils.settings_sync._last_seen_version == "new_version"
|
||||
|
||||
|
||||
@patch("app.utils.settings_sync.task_prerun.connect")
|
||||
@patch("app.utils.settings_sync.redis.from_url")
|
||||
@patch("app.utils.config_loader.reload_settings_from_db")
|
||||
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
|
||||
def test_reload_if_stale_same_version(mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version):
|
||||
# Set initial state
|
||||
app.utils.settings_sync._last_seen_version = "existing_version"
|
||||
|
||||
mock_decorator = MagicMock()
|
||||
mock_connect.return_value = mock_decorator
|
||||
register_settings_reload_signal()
|
||||
callback = mock_decorator.call_args[0][0]
|
||||
|
||||
# Setup redis to return the SAME version
|
||||
mock_redis_instance = MagicMock()
|
||||
mock_redis_instance.get.return_value = b"existing_version"
|
||||
mock_redis.return_value = mock_redis_instance
|
||||
|
||||
# Call the callback
|
||||
callback(sender="test")
|
||||
|
||||
# Verification
|
||||
mock_redis_instance.get.assert_called_once_with(SETTINGS_VERSION_KEY)
|
||||
# Should NOT reload or check OCR
|
||||
mock_reload.assert_not_called()
|
||||
mock_ensure_ocr.assert_not_called()
|
||||
assert app.utils.settings_sync._last_seen_version == "existing_version"
|
||||
|
||||
|
||||
@patch("app.utils.settings_sync.task_prerun.connect")
|
||||
@patch("app.utils.settings_sync.redis.from_url")
|
||||
@patch("app.utils.config_loader.reload_settings_from_db")
|
||||
def test_reload_if_stale_redis_error(mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog):
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
mock_decorator = MagicMock()
|
||||
mock_connect.return_value = mock_decorator
|
||||
register_settings_reload_signal()
|
||||
callback = mock_decorator.call_args[0][0]
|
||||
|
||||
# Setup redis to fail
|
||||
mock_redis.side_effect = Exception("Redis error")
|
||||
|
||||
# Call the callback
|
||||
callback(sender="test")
|
||||
|
||||
# Verification
|
||||
mock_reload.assert_not_called()
|
||||
assert "Settings version check skipped: Redis error" in caplog.text
|
||||
|
||||
|
||||
@patch("app.utils.settings_sync.task_prerun.connect")
|
||||
@patch("app.utils.settings_sync.redis.from_url")
|
||||
@patch("app.utils.config_loader.reload_settings_from_db")
|
||||
@patch("app.utils.ocr_language_manager.ensure_ocr_languages_async")
|
||||
def test_reload_if_stale_ocr_error(
|
||||
mock_ensure_ocr, mock_reload, mock_redis, mock_connect, reset_last_seen_version, caplog
|
||||
):
|
||||
mock_decorator = MagicMock()
|
||||
mock_connect.return_value = mock_decorator
|
||||
register_settings_reload_signal()
|
||||
callback = mock_decorator.call_args[0][0]
|
||||
|
||||
# Setup redis to return a new version
|
||||
mock_redis_instance = MagicMock()
|
||||
mock_redis_instance.get.return_value = b"new_version"
|
||||
mock_redis.return_value = mock_redis_instance
|
||||
|
||||
# Setup OCR check to fail
|
||||
mock_ensure_ocr.side_effect = Exception("OCR error")
|
||||
|
||||
# Call the callback
|
||||
callback(sender="test")
|
||||
|
||||
# Verification
|
||||
mock_reload.assert_called_once()
|
||||
mock_ensure_ocr.assert_called_once()
|
||||
assert "Could not schedule OCR language check on worker: OCR error" in caplog.text
|
||||
assert app.utils.settings_sync._last_seen_version == "new_version"
|
||||
+229
-14
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"] == {}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Tests for the system reset feature (app/api/system_reset.py, app/utils/system_reset.py, app/views/system_reset.py)."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.models import (
|
||||
DocumentMetadata,
|
||||
FileProcessingStep,
|
||||
FileRecord,
|
||||
ProcessingLog,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_workdir():
|
||||
"""Create a temporary workdir populated with sample user data."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create data subdirectories with dummy files
|
||||
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
|
||||
d = Path(tmpdir) / subdir
|
||||
d.mkdir()
|
||||
(d / "sample.pdf").write_bytes(b"%PDF-1.4 fake")
|
||||
|
||||
# Create cache files
|
||||
for cache in ("watch_folder_processed.json", "ftp_ingest_processed.json"):
|
||||
(Path(tmpdir) / cache).write_text("{}")
|
||||
|
||||
# Create a per-user watch folder cache
|
||||
(Path(tmpdir) / "user_wf_42.json").write_text("{}")
|
||||
|
||||
# Create a loose PDF in workdir root
|
||||
(Path(tmpdir) / "abc123.pdf").write_bytes(b"%PDF-1.4 loose")
|
||||
|
||||
yield tmpdir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_db_session():
|
||||
"""Fresh in-memory database with sample user data rows."""
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
# Seed with sample data
|
||||
fr = FileRecord(
|
||||
filehash="abc123",
|
||||
original_filename="test.pdf",
|
||||
local_filename="uuid.pdf",
|
||||
file_size=1024,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
session.add(fr)
|
||||
session.flush()
|
||||
|
||||
session.add(ProcessingLog(file_id=fr.id, task_id="t1", step_name="hash_file", status="success"))
|
||||
session.add(FileProcessingStep(file_id=fr.id, step_name="hash_file", status="success"))
|
||||
session.add(DocumentMetadata(filename="test.pdf", sender="Alice", recipient="Bob"))
|
||||
session.commit()
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for app/utils/system_reset.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWipeWorkdirData:
|
||||
"""Tests for _wipe_workdir_data()."""
|
||||
|
||||
def test_removes_data_subdirs(self, reset_workdir):
|
||||
from app.utils.system_reset import _wipe_workdir_data
|
||||
|
||||
result = _wipe_workdir_data(reset_workdir)
|
||||
|
||||
# All data subdirectories should be gone
|
||||
for subdir in ("original", "processed", "tmp", "pdfa", "backups"):
|
||||
assert not (Path(reset_workdir) / subdir).exists()
|
||||
|
||||
assert result["deleted_dirs"] == 5
|
||||
|
||||
def test_removes_cache_files(self, reset_workdir):
|
||||
from app.utils.system_reset import _wipe_workdir_data
|
||||
|
||||
result = _wipe_workdir_data(reset_workdir)
|
||||
|
||||
assert not (Path(reset_workdir) / "watch_folder_processed.json").exists()
|
||||
assert not (Path(reset_workdir) / "ftp_ingest_processed.json").exists()
|
||||
assert not (Path(reset_workdir) / "user_wf_42.json").exists()
|
||||
assert result["deleted_files"] >= 3
|
||||
|
||||
def test_removes_loose_document_files(self, reset_workdir):
|
||||
from app.utils.system_reset import _wipe_workdir_data
|
||||
|
||||
_wipe_workdir_data(reset_workdir)
|
||||
assert not (Path(reset_workdir) / "abc123.pdf").exists()
|
||||
|
||||
def test_preserves_workdir_directory(self, reset_workdir):
|
||||
from app.utils.system_reset import _wipe_workdir_data
|
||||
|
||||
_wipe_workdir_data(reset_workdir)
|
||||
assert Path(reset_workdir).is_dir()
|
||||
|
||||
def test_handles_empty_workdir(self):
|
||||
"""No errors when workdir has no data dirs or caches."""
|
||||
from app.utils.system_reset import _wipe_workdir_data
|
||||
|
||||
with tempfile.TemporaryDirectory() as empty_dir:
|
||||
result = _wipe_workdir_data(empty_dir)
|
||||
assert result["deleted_dirs"] == 0
|
||||
assert result["deleted_files"] == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestWipeDatabase:
|
||||
"""Tests for _wipe_database()."""
|
||||
|
||||
def test_deletes_all_user_data(self, reset_db_session):
|
||||
from app.utils.system_reset import _wipe_database
|
||||
|
||||
result = _wipe_database(reset_db_session)
|
||||
|
||||
assert result.get("files", 0) >= 1
|
||||
assert result.get("processing_logs", 0) >= 1
|
||||
assert result.get("file_processing_steps", 0) >= 1
|
||||
assert result.get("document_metadata", 0) >= 1
|
||||
|
||||
def test_tables_are_empty_after_wipe(self, reset_db_session):
|
||||
from app.utils.system_reset import _wipe_database
|
||||
|
||||
_wipe_database(reset_db_session)
|
||||
|
||||
assert reset_db_session.query(FileRecord).count() == 0
|
||||
assert reset_db_session.query(ProcessingLog).count() == 0
|
||||
assert reset_db_session.query(FileProcessingStep).count() == 0
|
||||
assert reset_db_session.query(DocumentMetadata).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPerformFullReset:
|
||||
"""Tests for perform_full_reset()."""
|
||||
|
||||
def test_wipes_db_and_filesystem(self, reset_db_session, reset_workdir):
|
||||
from app.utils.system_reset import perform_full_reset
|
||||
|
||||
with patch("app.utils.system_reset.settings") as mock_settings:
|
||||
mock_settings.workdir = reset_workdir
|
||||
result = perform_full_reset(reset_db_session)
|
||||
|
||||
assert "database" in result
|
||||
assert "filesystem" in result
|
||||
assert reset_db_session.query(FileRecord).count() == 0
|
||||
assert not (Path(reset_workdir) / "original").exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPerformResetAndReimport:
|
||||
"""Tests for perform_reset_and_reimport()."""
|
||||
|
||||
def test_copies_originals_to_reimport_then_wipes(self, reset_db_session, reset_workdir):
|
||||
from app.utils.system_reset import perform_reset_and_reimport
|
||||
|
||||
with patch("app.utils.system_reset.settings") as mock_settings:
|
||||
mock_settings.workdir = reset_workdir
|
||||
mock_settings.watch_folders = ""
|
||||
mock_settings.watch_folder_delete_after_process = False
|
||||
result = perform_reset_and_reimport(reset_db_session)
|
||||
|
||||
reimport_dir = Path(reset_workdir) / "reimport"
|
||||
assert reimport_dir.is_dir()
|
||||
assert result["reimport"]["files_moved"] >= 1
|
||||
|
||||
# DB should be wiped
|
||||
assert reset_db_session.query(FileRecord).count() == 0
|
||||
|
||||
# Reimport folder should contain the original file
|
||||
reimport_files = list(reimport_dir.iterdir())
|
||||
assert len(reimport_files) >= 1
|
||||
|
||||
def test_configures_watch_folder(self, reset_db_session, reset_workdir):
|
||||
from app.utils.system_reset import perform_reset_and_reimport
|
||||
|
||||
with patch("app.utils.system_reset.settings") as mock_settings:
|
||||
mock_settings.workdir = reset_workdir
|
||||
mock_settings.watch_folders = "/some/other/folder"
|
||||
mock_settings.watch_folder_delete_after_process = False
|
||||
perform_reset_and_reimport(reset_db_session)
|
||||
|
||||
reimport_path = str(Path(reset_workdir) / "reimport")
|
||||
# watch_folders should now include the reimport path
|
||||
assert reimport_path in mock_settings.watch_folders
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestStartupReset:
|
||||
"""Tests for perform_startup_reset()."""
|
||||
|
||||
def test_startup_reset_calls_full_reset(self):
|
||||
from app.utils.system_reset import perform_startup_reset
|
||||
|
||||
with patch("app.utils.system_reset.perform_full_reset") as mock_reset:
|
||||
with patch("app.database.SessionLocal") as mock_sl:
|
||||
mock_db = mock_sl.return_value
|
||||
perform_startup_reset()
|
||||
|
||||
mock_reset.assert_called_once_with(mock_db)
|
||||
mock_db.close.assert_called_once()
|
||||
|
||||
def test_startup_reset_handles_errors(self):
|
||||
from app.utils.system_reset import perform_startup_reset
|
||||
|
||||
with patch("app.utils.system_reset.perform_full_reset", side_effect=RuntimeError("boom")):
|
||||
with patch("app.database.SessionLocal") as mock_sl:
|
||||
mock_db = mock_sl.return_value
|
||||
# Should not raise
|
||||
perform_startup_reset()
|
||||
mock_db.rollback.assert_called_once()
|
||||
mock_db.close.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests for API endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSystemResetApi:
|
||||
"""Tests for the /api/admin/system-reset/ endpoints."""
|
||||
|
||||
def test_full_reset_requires_admin(self, client):
|
||||
"""Non-admin users get 403."""
|
||||
response = client.post(
|
||||
"/api/admin/system-reset/full",
|
||||
json={"confirmation": "DELETE"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_full_reset_requires_feature_flag(self, client):
|
||||
"""Returns 404 when ENABLE_FACTORY_RESET is false."""
|
||||
from app.api.system_reset import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||
try:
|
||||
with patch("app.api.system_reset.settings") as mock_s:
|
||||
mock_s.enable_factory_reset = False
|
||||
response = client.post(
|
||||
"/api/admin/system-reset/full",
|
||||
json={"confirmation": "DELETE"},
|
||||
)
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_full_reset_requires_confirmation(self, client):
|
||||
"""Wrong confirmation string gets 400."""
|
||||
from app.api.system_reset import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||
try:
|
||||
with patch("app.api.system_reset.settings") as mock_s:
|
||||
mock_s.enable_factory_reset = True
|
||||
response = client.post(
|
||||
"/api/admin/system-reset/full",
|
||||
json={"confirmation": "WRONG"},
|
||||
)
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_reimport_requires_confirmation(self, client):
|
||||
"""Wrong confirmation string gets 400."""
|
||||
from app.api.system_reset import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||
try:
|
||||
with patch("app.api.system_reset.settings") as mock_s:
|
||||
mock_s.enable_factory_reset = True
|
||||
response = client.post(
|
||||
"/api/admin/system-reset/reimport",
|
||||
json={"confirmation": "WRONG"},
|
||||
)
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_status_endpoint(self, client):
|
||||
"""The status endpoint returns feature-flag state."""
|
||||
from app.api.system_reset import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||
try:
|
||||
response = client.get("/api/admin/system-reset/status")
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "enabled" in data
|
||||
assert "factory_reset_on_startup" in data
|
||||
|
||||
def test_full_reset_success(self, client):
|
||||
"""Full reset succeeds with correct confirmation and feature flag."""
|
||||
from app.api.system_reset import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||
try:
|
||||
with patch("app.api.system_reset.settings") as mock_s:
|
||||
mock_s.enable_factory_reset = True
|
||||
with patch(
|
||||
"app.utils.system_reset.perform_full_reset", return_value={"database": {}, "filesystem": {}}
|
||||
):
|
||||
response = client.post(
|
||||
"/api/admin/system-reset/full",
|
||||
json={"confirmation": "DELETE"},
|
||||
)
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
def test_reimport_success(self, client):
|
||||
"""Reimport succeeds with correct confirmation."""
|
||||
from app.api.system_reset import _require_admin
|
||||
|
||||
client.app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||
try:
|
||||
with patch("app.api.system_reset.settings") as mock_s:
|
||||
mock_s.enable_factory_reset = True
|
||||
with patch(
|
||||
"app.utils.system_reset.perform_reset_and_reimport",
|
||||
return_value={"database": {}, "filesystem": {}, "reimport": {"files_moved": 3}},
|
||||
):
|
||||
response = client.post(
|
||||
"/api/admin/system-reset/reimport",
|
||||
json={"confirmation": "REIMPORT"},
|
||||
)
|
||||
finally:
|
||||
client.app.dependency_overrides.pop(_require_admin, None)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests for the view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestSystemResetView:
|
||||
"""Tests for the /admin/system-reset view."""
|
||||
|
||||
def test_view_redirects_when_disabled(self, client):
|
||||
"""When ENABLE_FACTORY_RESET=False, accessing the page redirects away."""
|
||||
with client:
|
||||
client.cookies.set("session", "test")
|
||||
with patch("app.views.system_reset.settings") as mock_s:
|
||||
mock_s.enable_factory_reset = False
|
||||
response = client.get("/admin/system-reset", follow_redirects=False)
|
||||
# Redirect to /settings (302) when disabled, or to login (302/307) when unauthenticated
|
||||
assert response.status_code in (302, 307)
|
||||
|
||||
def test_view_requires_auth(self, client):
|
||||
"""Unauthenticated users are redirected away from the page."""
|
||||
with patch("app.views.system_reset.settings") as mock_s:
|
||||
mock_s.enable_factory_reset = True
|
||||
mock_s.factory_reset_on_startup = False
|
||||
response = client.get("/admin/system-reset", follow_redirects=False)
|
||||
# Should redirect to login since there's no active session
|
||||
assert response.status_code in (302, 307)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Tests for document translation feature.
|
||||
|
||||
Covers:
|
||||
- translate_to_default_language Celery task
|
||||
- /api/files/{id}/translate on-the-fly translation endpoint
|
||||
- /api/files/{id}/translation/default stored translation endpoint
|
||||
- /files/{id}/text/default-language view endpoint
|
||||
- _resolve_default_language helper
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.models import FileRecord, UserProfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_with_ocr(db_session):
|
||||
"""Create a FileRecord with OCR text and detected language."""
|
||||
record = FileRecord(
|
||||
filehash="abc123translationtest",
|
||||
local_filename="/tmp/test_translate.pdf",
|
||||
file_size=1024,
|
||||
mime_type="application/pdf",
|
||||
original_filename="test_translate.pdf",
|
||||
ocr_text="Dies ist ein Testdokument in deutscher Sprache.",
|
||||
detected_language="de",
|
||||
)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
db_session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_with_translation(db_session):
|
||||
"""Create a FileRecord with a persisted default-language translation."""
|
||||
record = FileRecord(
|
||||
filehash="def456translationtest",
|
||||
local_filename="/tmp/test_translated.pdf",
|
||||
file_size=2048,
|
||||
mime_type="application/pdf",
|
||||
original_filename="test_translated.pdf",
|
||||
ocr_text="Ceci est un document de test en français.",
|
||||
detected_language="fr",
|
||||
default_language_text="This is a test document in French.",
|
||||
default_language_code="en",
|
||||
)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
db_session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_without_ocr(db_session):
|
||||
"""Create a FileRecord without OCR text."""
|
||||
record = FileRecord(
|
||||
filehash="ghi789translationtest",
|
||||
local_filename="/tmp/test_no_ocr.pdf",
|
||||
file_size=512,
|
||||
mime_type="application/pdf",
|
||||
original_filename="test_no_ocr.pdf",
|
||||
)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
db_session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_profile_with_language(db_session):
|
||||
"""Create a UserProfile with a custom default_document_language."""
|
||||
profile = UserProfile(
|
||||
user_id="test-user-lang",
|
||||
default_document_language="de",
|
||||
)
|
||||
db_session.add(profile)
|
||||
db_session.commit()
|
||||
db_session.refresh(profile)
|
||||
return profile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFileRecordTranslationFields:
|
||||
"""Verify that the new translation columns exist on FileRecord."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_detected_language_column(self, file_with_ocr):
|
||||
assert file_with_ocr.detected_language == "de"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_language_text_column(self, file_with_translation):
|
||||
assert file_with_translation.default_language_text == "This is a test document in French."
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_language_code_column(self, file_with_translation):
|
||||
assert file_with_translation.default_language_code == "en"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_translation_columns_nullable(self, file_with_ocr):
|
||||
"""Translation columns should be NULL when no translation exists."""
|
||||
assert file_with_ocr.default_language_text is None
|
||||
assert file_with_ocr.default_language_code is None
|
||||
|
||||
|
||||
class TestUserProfileDefaultLanguage:
|
||||
"""Verify UserProfile.default_document_language column."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_document_language_set(self, user_profile_with_language):
|
||||
assert user_profile_with_language.default_document_language == "de"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_document_language_nullable(self, db_session):
|
||||
profile = UserProfile(user_id="test-user-no-lang")
|
||||
db_session.add(profile)
|
||||
db_session.commit()
|
||||
db_session.refresh(profile)
|
||||
assert profile.default_document_language is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Celery task tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTranslateToDefaultLanguageTask:
|
||||
"""Tests for the translate_to_default_language Celery task."""
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("app.tasks.translate_to_default_language.get_ai_provider")
|
||||
def test_translate_stores_result(self, mock_provider_fn, db_session, file_with_ocr):
|
||||
"""Successful translation is persisted to the FileRecord."""
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.chat_completion.return_value = "This is a test document in German."
|
||||
mock_provider_fn.return_value = mock_provider
|
||||
|
||||
from app.tasks.translate_to_default_language import translate_to_default_language
|
||||
|
||||
# Patch SessionLocal to use our test session
|
||||
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.__enter__ = MagicMock(return_value=db_session)
|
||||
mock_ctx.__exit__ = MagicMock(return_value=False)
|
||||
mock_session_cls.return_value = mock_ctx
|
||||
|
||||
task = translate_to_default_language
|
||||
# Call the underlying function (not .delay) for synchronous testing
|
||||
result = task.apply(
|
||||
args=[file_with_ocr.id, file_with_ocr.ocr_text, "de"],
|
||||
kwargs={"owner_id": None},
|
||||
).get()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["target_language"] == "en"
|
||||
|
||||
# Verify it was stored
|
||||
db_session.refresh(file_with_ocr)
|
||||
assert file_with_ocr.default_language_text == "This is a test document in German."
|
||||
assert file_with_ocr.default_language_code == "en"
|
||||
assert file_with_ocr.detected_language == "de"
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("app.tasks.translate_to_default_language.get_ai_provider")
|
||||
def test_skip_when_already_in_target_language(self, mock_provider_fn, db_session, file_with_ocr):
|
||||
"""No translation when document language matches default target."""
|
||||
file_with_ocr.detected_language = "en"
|
||||
db_session.commit()
|
||||
|
||||
from app.tasks.translate_to_default_language import translate_to_default_language
|
||||
|
||||
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.__enter__ = MagicMock(return_value=db_session)
|
||||
mock_ctx.__exit__ = MagicMock(return_value=False)
|
||||
mock_session_cls.return_value = mock_ctx
|
||||
|
||||
result = translate_to_default_language.apply(
|
||||
args=[file_with_ocr.id, file_with_ocr.ocr_text, "en"],
|
||||
).get()
|
||||
|
||||
assert result["status"] == "skipped"
|
||||
mock_provider_fn.assert_not_called()
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_resolve_default_language_global(self):
|
||||
"""Falls back to the global setting when no user profile override."""
|
||||
from app.tasks.translate_to_default_language import _resolve_default_language
|
||||
|
||||
with patch("app.tasks.translate_to_default_language.settings") as mock_settings:
|
||||
mock_settings.default_document_language = "en"
|
||||
assert _resolve_default_language(None) == "en"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_resolve_default_language_user_override(self, db_session, user_profile_with_language):
|
||||
"""Per-user override is used when available."""
|
||||
from app.tasks.translate_to_default_language import _resolve_default_language
|
||||
|
||||
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.__enter__ = MagicMock(return_value=db_session)
|
||||
mock_ctx.__exit__ = MagicMock(return_value=False)
|
||||
mock_session_cls.return_value = mock_ctx
|
||||
|
||||
result = _resolve_default_language("test-user-lang")
|
||||
|
||||
assert result == "de"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefaultTranslationEndpoint:
|
||||
"""Tests for GET /api/files/{id}/translation/default."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_returns_default_translation(self, client: TestClient, file_with_translation):
|
||||
response = client.get(f"/api/files/{file_with_translation.id}/translation/default")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["text"] == "This is a test document in French."
|
||||
assert data["default_language_code"] == "en"
|
||||
assert data["detected_language"] == "fr"
|
||||
assert data["file_id"] == file_with_translation.id
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_404_when_no_translation(self, client: TestClient, file_with_ocr):
|
||||
response = client.get(f"/api/files/{file_with_ocr.id}/translation/default")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_404_for_nonexistent_file(self, client: TestClient):
|
||||
response = client.get("/api/files/999999/translation/default")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestOnTheFlyTranslateEndpoint:
|
||||
"""Tests for GET /api/files/{id}/translate?lang=xx."""
|
||||
|
||||
@pytest.mark.integration
|
||||
@patch("app.api.translation.get_ai_provider")
|
||||
def test_translate_on_the_fly(self, mock_provider_fn, client: TestClient, file_with_ocr):
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.chat_completion.return_value = "This is a test document in German language."
|
||||
mock_provider_fn.return_value = mock_provider
|
||||
|
||||
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=en")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["text"] == "This is a test document in German language."
|
||||
assert data["target_language"] == "en"
|
||||
assert data["cached"] is False
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_returns_cached_default_language(self, client: TestClient, file_with_translation):
|
||||
"""If the requested language matches the stored default, return cached text."""
|
||||
response = client.get(f"/api/files/{file_with_translation.id}/translate?lang=en")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["text"] == "This is a test document in French."
|
||||
assert data["cached"] is True
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_returns_original_when_same_language(self, client: TestClient, file_with_ocr):
|
||||
"""Return the original text when target matches detected language."""
|
||||
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=de")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["text"] == file_with_ocr.ocr_text
|
||||
assert data["cached"] is True
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_400_when_no_ocr_text(self, client: TestClient, file_without_ocr):
|
||||
response = client.get(f"/api/files/{file_without_ocr.id}/translate?lang=en")
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_missing_lang_param(self, client: TestClient, file_with_ocr):
|
||||
response = client.get(f"/api/files/{file_with_ocr.id}/translate")
|
||||
assert response.status_code == 422 # validation error
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_404_for_nonexistent_file(self, client: TestClient):
|
||||
response = client.get("/api/files/999999/translate?lang=en")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.integration
|
||||
@patch("app.api.translation.get_ai_provider")
|
||||
def test_502_on_provider_error(self, mock_provider_fn, client: TestClient, file_with_ocr):
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.chat_completion.side_effect = RuntimeError("AI error")
|
||||
mock_provider_fn.return_value = mock_provider
|
||||
|
||||
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=fr")
|
||||
assert response.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# View endpoint tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefaultLanguageTextView:
|
||||
"""Tests for GET /files/{id}/text/default-language."""
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_returns_default_language_text(self, client: TestClient, file_with_translation):
|
||||
response = client.get(f"/files/{file_with_translation.id}/text/default-language")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["text"] == "This is a test document in French."
|
||||
assert data["language_code"] == "en"
|
||||
assert data["detected_language"] == "fr"
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_404_when_no_default_text(self, client: TestClient, file_with_ocr):
|
||||
response = client.get(f"/files/{file_with_ocr.id}/text/default-language")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_404_for_nonexistent_file(self, client: TestClient):
|
||||
response = client.get("/files/999999/text/default-language")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefaultDocumentLanguageConfig:
|
||||
"""Verify the DEFAULT_DOCUMENT_LANGUAGE setting."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_default_value_is_english(self):
|
||||
from app.config import settings
|
||||
|
||||
assert settings.default_document_language == "en"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile API integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProfileDefaultDocumentLanguage:
|
||||
"""Tests for default_document_language in the profile API."""
|
||||
|
||||
@pytest.fixture
|
||||
def prof_engine(self):
|
||||
"""In-memory SQLite engine for profile tests."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield engine
|
||||
|
||||
@pytest.fixture
|
||||
def prof_session(self, prof_engine):
|
||||
"""DB session for profile tests."""
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
Session = sessionmaker(bind=self.prof_engine if hasattr(self, "prof_engine") else prof_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_get_profile_includes_default_document_language(self, prof_engine):
|
||||
"""GET handler returns default_document_language in response."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.api.profile import get_profile
|
||||
|
||||
Session = sessionmaker(bind=prof_engine)
|
||||
session = Session()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "languser", "email": "lang@test.com"}}
|
||||
|
||||
result = await get_profile(req, session)
|
||||
assert hasattr(result, "default_document_language")
|
||||
session.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_update_default_document_language(self, prof_engine):
|
||||
"""PATCH handler updates default_document_language."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
Session = sessionmaker(bind=prof_engine)
|
||||
session = Session()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "languser2", "email": "lang2@test.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(default_document_language="de")
|
||||
result = await update_profile(body, req, resp, session)
|
||||
assert result.default_document_language == "de"
|
||||
session.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_clear_default_document_language(self, prof_engine):
|
||||
"""Setting default_document_language to empty string clears it."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
Session = sessionmaker(bind=prof_engine)
|
||||
session = Session()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "languser3", "email": "lang3@test.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
# Set
|
||||
body = ProfileUpdateRequest(default_document_language="fr")
|
||||
await update_profile(body, req, resp, session)
|
||||
# Clear
|
||||
body = ProfileUpdateRequest(default_document_language="")
|
||||
result = await update_profile(body, req, resp, session)
|
||||
assert result.default_document_language is None
|
||||
session.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.unit
|
||||
async def test_reject_invalid_default_document_language(self, prof_engine):
|
||||
"""Invalid language codes are rejected with 422."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||
|
||||
Session = sessionmaker(bind=prof_engine)
|
||||
session = Session()
|
||||
|
||||
req = MagicMock()
|
||||
req.session = {"user": {"preferred_username": "languser4", "email": "lang4@test.com"}}
|
||||
resp = MagicMock()
|
||||
|
||||
body = ProfileUpdateRequest(default_document_language="xx_invalid")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_profile(body, req, resp, session)
|
||||
assert exc_info.value.status_code == 422
|
||||
session.close()
|
||||
@@ -121,6 +121,95 @@ class TestExtractMetadataFromFile:
|
||||
|
||||
assert result == {}
|
||||
|
||||
def test_extract_metadata_from_pdf(self, tmp_path):
|
||||
"""Test extracting metadata from a PDF file using pypdf when JSON is missing."""
|
||||
import pypdf
|
||||
|
||||
file_path = tmp_path / "test.pdf"
|
||||
|
||||
# Create a test PDF with metadata
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.add_blank_page(width=100, height=100)
|
||||
writer.add_metadata(
|
||||
{
|
||||
"/Title": "Test Title",
|
||||
"/Author": "Test Author",
|
||||
"/Subject": "Test Document",
|
||||
"/Keywords": "test, metadata, pypdf",
|
||||
}
|
||||
)
|
||||
with open(file_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
result = extract_metadata_from_file(str(file_path))
|
||||
|
||||
# Keys are mapped to application-specific names
|
||||
assert result.get("filename") == "Test Title"
|
||||
assert result.get("absender") == "Test Author"
|
||||
assert result.get("document_type") == "Test Document"
|
||||
assert result.get("tags") == "test, metadata, pypdf"
|
||||
|
||||
def test_extracts_embedded_metadata_from_pdf(self, tmp_path):
|
||||
"""Test that embedded PDF metadata is mapped to application-specific keys."""
|
||||
import pypdf
|
||||
|
||||
file_path = tmp_path / "mapped.pdf"
|
||||
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.add_blank_page(width=100, height=100)
|
||||
writer.add_metadata(
|
||||
{
|
||||
"/Title": "Invoice 2024",
|
||||
"/Author": "Acme Corp",
|
||||
"/Subject": "invoice",
|
||||
"/Keywords": "finance, billing",
|
||||
}
|
||||
)
|
||||
with open(file_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
result = extract_metadata_from_file(str(file_path))
|
||||
|
||||
# Verify the PDF-to-app key mapping
|
||||
assert result["filename"] == "Invoice 2024"
|
||||
assert result["absender"] == "Acme Corp"
|
||||
assert result["document_type"] == "invoice"
|
||||
assert result["tags"] == "finance, billing"
|
||||
|
||||
def test_pdf_metadata_does_not_overwrite_json(self, tmp_path):
|
||||
"""Test that JSON metadata takes precedence over embedded PDF metadata."""
|
||||
import pypdf
|
||||
|
||||
file_path = tmp_path / "dual.pdf"
|
||||
|
||||
# Create a PDF with embedded metadata
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.add_blank_page(width=100, height=100)
|
||||
writer.add_metadata(
|
||||
{
|
||||
"/Title": "PDF Title",
|
||||
"/Author": "PDF Author",
|
||||
"/Subject": "PDF Subject",
|
||||
"/Keywords": "pdf, keywords",
|
||||
}
|
||||
)
|
||||
with open(file_path, "wb") as f:
|
||||
writer.write(f)
|
||||
|
||||
# Create a companion JSON file that sets some overlapping fields
|
||||
json_metadata = {"filename": "JSON Filename", "absender": "JSON Author"}
|
||||
json_path = tmp_path / "dual.json"
|
||||
json_path.write_text(json.dumps(json_metadata))
|
||||
|
||||
result = extract_metadata_from_file(str(file_path))
|
||||
|
||||
# JSON values must not be overwritten by PDF metadata
|
||||
assert result["filename"] == "JSON Filename"
|
||||
assert result["absender"] == "JSON Author"
|
||||
# Fields missing from JSON are filled from PDF metadata
|
||||
assert result["document_type"] == "PDF Subject"
|
||||
assert result["tags"] == "pdf, keywords"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAttachLogo:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
@@ -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"})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user