Merge pull request #266 from christianlouis/copilot/fix-test-suite-issues

fix(test): fix test suite failures from Celery task signatures, auth imports, and incorrect mocks
This commit is contained in:
Christian Krakau-Louis
2026-02-12 14:33:05 +01:00
committed by GitHub
10 changed files with 258 additions and 306 deletions
+94 -89
View File
@@ -66,105 +66,110 @@ def get_gravatar_url(email):
return f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
if AUTH_ENABLED:
async def login(request: Request):
"""Show login page with appropriate authentication options"""
return templates.TemplateResponse(
"login.html",
{
"request": request,
"error": request.query_params.get("error"),
"message": request.query_params.get("message"),
"show_oauth": OAUTH_CONFIGURED,
"oauth_provider_name": OAUTH_PROVIDER_NAME,
"app_version": settings.version, # Changed from app_version to version
},
)
@router.get("/login")
async def login(request: Request):
"""Show login page with appropriate authentication options"""
return templates.TemplateResponse(
"login.html",
{
"request": request,
"error": request.query_params.get("error"),
"message": request.query_params.get("message"),
"show_oauth": OAUTH_CONFIGURED,
"oauth_provider_name": OAUTH_PROVIDER_NAME,
"app_version": settings.version, # Changed from app_version to version
},
)
@router.get("/oauth-login")
async def oauth_login(request: Request):
"""Handle OAuth login flow"""
if not OAUTH_CONFIGURED:
return RedirectResponse(url="/login?error=OAuth+not+configured", status_code=status.HTTP_302_FOUND)
async def oauth_login(request: Request):
"""Handle OAuth login flow"""
if not OAUTH_CONFIGURED:
return RedirectResponse(url="/login?error=OAuth+not+configured", status_code=status.HTTP_302_FOUND)
redirect_uri = request.url_for("oauth_callback")
return await oauth.authentik.authorize_redirect(request, redirect_uri)
redirect_uri = request.url_for("oauth_callback")
return await oauth.authentik.authorize_redirect(request, redirect_uri)
@router.get("/oauth-callback")
async def oauth_callback(request: Request):
"""Handle OAuth callback from provider"""
try:
token = await oauth.authentik.authorize_access_token(request)
userinfo = token.get("userinfo")
if not userinfo:
return RedirectResponse(
url="/login?error=Failed+to+retrieve+user+information", status_code=status.HTTP_302_FOUND
)
# Store user info in session
user_data = dict(userinfo)
# Add Gravatar picture if no picture is provided
if not user_data.get("picture") and user_data.get("email"):
user_data["picture"] = get_gravatar_url(user_data["email"])
# Check if user is admin based on OAuth groups or specific email
# You can customize this logic based on your OAuth provider's attributes
# For example, check if user has an "admin" group or specific email domain
is_admin = False
if "groups" in user_data:
# Check if user is in admin group
groups = user_data.get("groups", [])
admin_group = (settings.admin_group_name or "admin").strip().lower()
is_admin = admin_group in [group.lower() for group in groups]
# Set is_admin flag (defaults to False for OAuth users unless they're in admin group)
user_data["is_admin"] = is_admin
request.session["user"] = user_data
# Log the successful authentication
print(f"User authenticated via OAuth: {user_data.get('email', 'No email')} (admin: {is_admin})")
# Redirect to original destination or default
redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url)
except Exception as e:
print(f"OAuth authentication error: {str(e)}")
async def oauth_callback(request: Request):
"""Handle OAuth callback from provider"""
try:
token = await oauth.authentik.authorize_access_token(request)
userinfo = token.get("userinfo")
if not userinfo:
return RedirectResponse(
url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND
url="/login?error=Failed+to+retrieve+user+information", status_code=status.HTTP_302_FOUND
)
@router.post("/auth")
async def auth(request: Request):
"""Handle local username/password authentication"""
form_data = await request.form()
username = form_data.get("username")
password = form_data.get("password")
# Store user info in session
user_data = dict(userinfo)
if username == settings.admin_username and password == settings.admin_password:
# Create user session
request.session["user"] = {
"id": "admin",
"name": "Administrator",
"email": f"{username}@local.docuelevate",
"preferred_username": username,
"picture": "/static/images/default-avatar.svg",
"is_admin": True,
}
# Redirect to original destination or default
redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302)
else:
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
# Add Gravatar picture if no picture is provided
if not user_data.get("picture") and user_data.get("email"):
user_data["picture"] = get_gravatar_url(user_data["email"])
@router.get("/logout")
async def logout(request: Request):
"""Handle user logout"""
request.session.pop("user", None)
return RedirectResponse(url="/login?message=You+have+been+logged+out+successfully", status_code=302)
# Check if user is admin based on OAuth groups or specific email
# You can customize this logic based on your OAuth provider's attributes
# For example, check if user has an "admin" group or specific email domain
is_admin = False
if "groups" in user_data:
# Check if user is in admin group
groups = user_data.get("groups", [])
admin_group = (settings.admin_group_name or "admin").strip().lower()
is_admin = admin_group in [group.lower() for group in groups]
# Set is_admin flag (defaults to False for OAuth users unless they're in admin group)
user_data["is_admin"] = is_admin
request.session["user"] = user_data
# Log the successful authentication
print(f"User authenticated via OAuth: {user_data.get('email', 'No email')} (admin: {is_admin})")
# Redirect to original destination or default
redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
except Exception as e:
print(f"OAuth authentication error: {str(e)}")
return RedirectResponse(
url=f"/login?error=Authentication+failed:+{str(e)}", status_code=status.HTTP_302_FOUND
)
async def auth(request: Request):
"""Handle local username/password authentication"""
form_data = await request.form()
username = form_data.get("username")
password = form_data.get("password")
if username == settings.admin_username and password == settings.admin_password:
# Create user session
request.session["user"] = {
"id": "admin",
"name": "Administrator",
"email": f"{username}@local.docuelevate",
"preferred_username": username,
"picture": "/static/images/default-avatar.svg",
"is_admin": True,
}
# Redirect to original destination or default
redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302)
else:
return RedirectResponse(url="/login?error=Invalid+username+or+password", status_code=302)
async def logout(request: Request):
"""Handle user logout"""
request.session.pop("user", None)
return RedirectResponse(url="/login?message=You+have+been+logged+out+successfully", status_code=302)
if AUTH_ENABLED:
router.add_api_route("/login", login, methods=["GET"])
router.add_api_route("/oauth-login", oauth_login, methods=["GET"])
router.add_api_route("/oauth-callback", oauth_callback, methods=["GET"])
router.add_api_route("/auth", auth, methods=["POST"])
router.add_api_route("/logout", logout, methods=["GET"])
@router.get("/api/auth/whoami")
+45 -50
View File
@@ -182,57 +182,52 @@ def oauth_enabled_app(oauth_config: Dict[str, str]):
Configured test client
"""
import os
from unittest.mock import patch
# Save original values
original_auth_enabled = os.environ.get("AUTH_ENABLED")
original_client_id = os.environ.get("AUTHENTIK_CLIENT_ID")
original_client_secret = os.environ.get("AUTHENTIK_CLIENT_SECRET")
original_config_url = os.environ.get("AUTHENTIK_CONFIG_URL")
from app.main import app
import app.auth as auth_module
from app.auth import login, oauth_login, oauth_callback, auth, logout
# Save original state
original_auth_enabled = auth_module.AUTH_ENABLED
original_oauth_configured = auth_module.OAUTH_CONFIGURED
original_oauth_provider = auth_module.OAUTH_PROVIDER_NAME
original_route_count = len(app.router.routes)
try:
# Enable auth and configure OAuth
os.environ["AUTH_ENABLED"] = "True"
os.environ["AUTHENTIK_CLIENT_ID"] = oauth_config["client_id"]
os.environ["AUTHENTIK_CLIENT_SECRET"] = oauth_config["client_secret"]
os.environ["AUTHENTIK_CONFIG_URL"] = oauth_config["server_metadata_url"]
# Need to reload the app module to pick up new config
import importlib
from app import auth
importlib.reload(auth)
# Enable auth and configure OAuth flags
auth_module.AUTH_ENABLED = True
auth_module.OAUTH_CONFIGURED = True
auth_module.OAUTH_PROVIDER_NAME = oauth_config.get("provider_name", "Test SSO")
# Register OAuth client
auth_module.oauth.register(
name="authentik",
client_id=oauth_config["client_id"],
client_secret=oauth_config["client_secret"],
server_metadata_url=oauth_config["server_metadata_url"],
client_kwargs={"scope": "openid profile email"},
)
# Add auth routes directly to the app (since include_router was called at startup
# with AUTH_ENABLED=False, routes weren't registered)
app.add_api_route("/login", login, methods=["GET"])
app.add_api_route("/oauth-login", oauth_login, methods=["GET"])
app.add_api_route("/oauth-callback", oauth_callback, methods=["GET"], name="oauth_callback")
app.add_api_route("/auth", auth, methods=["POST"])
app.add_api_route("/logout", logout, methods=["GET"])
from fastapi.testclient import TestClient
from app.main import app
# Create test client
client = TestClient(app)
# Create test client with base_url to satisfy TrustedHostMiddleware
client = TestClient(app, base_url="http://localhost")
yield client
finally:
# Restore original values
if original_auth_enabled is not None:
os.environ["AUTH_ENABLED"] = original_auth_enabled
else:
os.environ.pop("AUTH_ENABLED", None)
if original_client_id is not None:
os.environ["AUTHENTIK_CLIENT_ID"] = original_client_id
else:
os.environ.pop("AUTHENTIK_CLIENT_ID", None)
if original_client_secret is not None:
os.environ["AUTHENTIK_CLIENT_SECRET"] = original_client_secret
else:
os.environ.pop("AUTHENTIK_CLIENT_SECRET", None)
if original_config_url is not None:
os.environ["AUTHENTIK_CONFIG_URL"] = original_config_url
else:
os.environ.pop("AUTHENTIK_CONFIG_URL", None)
# Reload auth module to restore original state
import importlib
from app import auth
importlib.reload(auth)
# Restore original auth state
auth_module.AUTH_ENABLED = original_auth_enabled
auth_module.OAUTH_CONFIGURED = original_oauth_configured
auth_module.OAUTH_PROVIDER_NAME = original_oauth_provider
# Remove added routes
app.router.routes = app.router.routes[:original_route_count]
+23 -24
View File
@@ -163,7 +163,7 @@ class TestRequireLogin:
mock_request.session = {"user": {"id": "123"}}
mock_request.url = "http://localhost/test"
result = test_sync_endpoint(mock_request)
result = await test_sync_endpoint(mock_request)
assert result == {"message": "success"}
@@ -180,7 +180,7 @@ class TestRequireLogin:
mock_request.session = {}
mock_request.url = "http://localhost/protected"
result = test_sync_endpoint(mock_request)
result = await test_sync_endpoint(mock_request)
assert isinstance(result, RedirectResponse)
assert result.status_code == status.HTTP_302_FOUND
@@ -192,43 +192,42 @@ class TestOAuthConfiguration:
def test_oauth_not_configured_without_credentials(self):
"""Test OAuth is not configured when credentials are missing."""
with patch("app.auth.settings") as mock_settings:
mock_settings.auth_enabled = True
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
import importlib
# Re-import to trigger configuration logic
import importlib
import app.auth
import app.auth
try:
with patch("app.config.settings") as mock_settings:
mock_settings.auth_enabled = True
mock_settings.authentik_client_id = None
mock_settings.authentik_client_secret = None
importlib.reload(app.auth)
assert app.auth.OAUTH_CONFIGURED is False
finally:
importlib.reload(app.auth)
from app.auth import OAUTH_CONFIGURED
assert OAUTH_CONFIGURED is False
def test_oauth_configured_with_credentials(self):
"""Test OAuth is configured when credentials are provided."""
with patch("app.auth.settings") as mock_settings:
with patch("app.auth.oauth") as mock_oauth:
import importlib
import app.auth
try:
with patch("app.config.settings") as mock_settings:
mock_settings.auth_enabled = True
mock_settings.authentik_client_id = "test_client_id"
mock_settings.authentik_client_secret = "test_secret"
mock_settings.authentik_config_url = "https://auth.example.com/.well-known/openid-configuration"
mock_settings.oauth_provider_name = "Test SSO"
# Re-import to trigger configuration logic
import importlib
import app.auth
importlib.reload(app.auth)
from app.auth import OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
assert OAUTH_CONFIGURED is True
assert OAUTH_PROVIDER_NAME == "Test SSO"
assert app.auth.OAUTH_CONFIGURED is True
assert app.auth.OAUTH_PROVIDER_NAME == "Test SSO"
finally:
importlib.reload(app.auth)
@pytest.mark.unit
+18 -37
View File
@@ -178,10 +178,6 @@ class TestConvertToPdf:
mock_response.content = b"%PDF-1.4 converted content"
mock_post.return_value = mock_response
# Mock task context
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
# Mock file type detection
with patch("app.tasks.convert_to_pdf._detect_mime_type") as mock_detect_mime:
with patch("app.tasks.convert_to_pdf._detect_extension") as mock_detect_ext:
@@ -194,7 +190,8 @@ class TestConvertToPdf:
)
mock_detect_ext.return_value = ".docx"
result = convert_to_pdf.__wrapped__(mock_task, "/tmp/test.docx", "document.docx")
convert_to_pdf.request.id = "test-task-id"
result = convert_to_pdf.__wrapped__("/tmp/test.docx", "document.docx")
# Verify Gotenberg was called
mock_post.assert_called_once()
@@ -214,13 +211,11 @@ class TestConvertToPdf:
@patch("app.tasks.convert_to_pdf.log_task_progress")
def test_returns_none_when_gotenberg_url_not_configured(self, mock_log_progress):
"""Test returns None when Gotenberg URL is not configured."""
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
with patch("app.tasks.convert_to_pdf.settings") as mock_settings:
mock_settings.gotenberg_url = None
result = convert_to_pdf.__wrapped__(mock_task, "/tmp/test.docx")
convert_to_pdf.request.id = "test-task-id"
result = convert_to_pdf.__wrapped__("/tmp/test.docx")
assert result is None
# Verify error was logged
@@ -230,9 +225,6 @@ class TestConvertToPdf:
@patch("app.tasks.convert_to_pdf.log_task_progress")
def test_returns_none_when_file_type_unknown(self, mock_log_progress):
"""Test returns None when file type cannot be determined."""
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
with patch("app.tasks.convert_to_pdf._detect_mime_type") as mock_detect_mime:
with patch("app.tasks.convert_to_pdf._detect_extension") as mock_detect_ext:
with patch("app.tasks.convert_to_pdf.settings") as mock_settings:
@@ -240,7 +232,8 @@ class TestConvertToPdf:
mock_detect_mime.return_value = (None, None)
mock_detect_ext.return_value = ""
result = convert_to_pdf.__wrapped__(mock_task, "/tmp/unknown_file")
convert_to_pdf.request.id = "test-task-id"
result = convert_to_pdf.__wrapped__("/tmp/unknown_file")
assert result is None
@@ -255,9 +248,6 @@ class TestConvertToPdf:
mock_response.content = b"%PDF-1.4 converted image"
mock_post.return_value = mock_response
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
with patch("app.tasks.convert_to_pdf._detect_mime_type") as mock_detect_mime:
with patch("app.tasks.convert_to_pdf._detect_extension") as mock_detect_ext:
with patch("app.tasks.convert_to_pdf.settings") as mock_settings:
@@ -266,7 +256,8 @@ class TestConvertToPdf:
mock_detect_mime.return_value = ("image/jpeg", None)
mock_detect_ext.return_value = ".jpg"
result = convert_to_pdf.__wrapped__(mock_task, "/tmp/photo.jpg")
convert_to_pdf.request.id = "test-task-id"
result = convert_to_pdf.__wrapped__("/tmp/photo.jpg")
# Verify LibreOffice endpoint was used for images
mock_post.assert_called_once()
@@ -285,9 +276,6 @@ class TestConvertToPdf:
mock_response.content = b"%PDF-1.4 converted html"
mock_post.return_value = mock_response
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
with patch("app.tasks.convert_to_pdf._detect_mime_type") as mock_detect_mime:
with patch("app.tasks.convert_to_pdf._detect_extension") as mock_detect_ext:
with patch("app.tasks.convert_to_pdf.settings") as mock_settings:
@@ -296,7 +284,8 @@ class TestConvertToPdf:
mock_detect_mime.return_value = ("text/html", None)
mock_detect_ext.return_value = ".html"
result = convert_to_pdf.__wrapped__(mock_task, "/tmp/page.html")
convert_to_pdf.request.id = "test-task-id"
result = convert_to_pdf.__wrapped__("/tmp/page.html")
# Verify Chromium endpoint was used
mock_post.assert_called_once()
@@ -314,9 +303,6 @@ class TestConvertToPdf:
mock_response.content = b"%PDF-1.4 converted markdown"
mock_post.return_value = mock_response
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
with patch("app.tasks.convert_to_pdf._detect_mime_type") as mock_detect_mime:
with patch("app.tasks.convert_to_pdf._detect_extension") as mock_detect_ext:
with patch("app.tasks.convert_to_pdf.settings") as mock_settings:
@@ -330,7 +316,8 @@ class TestConvertToPdf:
mock_exists.return_value = True
mock_dirname.return_value = "/tmp"
result = convert_to_pdf.__wrapped__(mock_task, "/tmp/readme.md")
convert_to_pdf.request.id = "test-task-id"
result = convert_to_pdf.__wrapped__("/tmp/readme.md")
# Verify Chromium markdown endpoint was used
mock_post.assert_called_once()
@@ -347,9 +334,6 @@ class TestConvertToPdf:
mock_response.text = "Internal Server Error"
mock_post.return_value = mock_response
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
with patch("app.tasks.convert_to_pdf._detect_mime_type") as mock_detect_mime:
with patch("app.tasks.convert_to_pdf._detect_extension") as mock_detect_ext:
with patch("app.tasks.convert_to_pdf.settings") as mock_settings:
@@ -358,7 +342,8 @@ class TestConvertToPdf:
mock_detect_mime.return_value = ("application/pdf", None)
mock_detect_ext.return_value = ".pdf"
result = convert_to_pdf.__wrapped__(mock_task, "/tmp/test.pdf")
convert_to_pdf.request.id = "test-task-id"
result = convert_to_pdf.__wrapped__("/tmp/test.pdf")
assert result is None
# Verify error was logged
@@ -372,9 +357,6 @@ class TestConvertToPdf:
"""Test handling of network exceptions during conversion."""
mock_post.side_effect = Exception("Connection timeout")
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
with patch("app.tasks.convert_to_pdf._detect_mime_type") as mock_detect_mime:
with patch("app.tasks.convert_to_pdf._detect_extension") as mock_detect_ext:
with patch("app.tasks.convert_to_pdf.settings") as mock_settings:
@@ -383,7 +365,8 @@ class TestConvertToPdf:
mock_detect_mime.return_value = ("application/pdf", None)
mock_detect_ext.return_value = ".pdf"
result = convert_to_pdf.__wrapped__(mock_task, "/tmp/test.pdf")
convert_to_pdf.request.id = "test-task-id"
result = convert_to_pdf.__wrapped__("/tmp/test.pdf")
assert result is None
@@ -398,9 +381,6 @@ class TestConvertToPdf:
mock_response.content = b"%PDF-1.4"
mock_post.return_value = mock_response
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
with patch("app.tasks.convert_to_pdf._detect_mime_type") as mock_detect_mime:
with patch("app.tasks.convert_to_pdf._detect_extension") as mock_detect_ext:
with patch("app.tasks.convert_to_pdf.settings") as mock_settings:
@@ -409,7 +389,8 @@ class TestConvertToPdf:
mock_detect_mime.return_value = ("application/vnd.ms-excel", None)
mock_detect_ext.return_value = ".xls"
result = convert_to_pdf.__wrapped__(mock_task, "/tmp/uuid.xls", "report.xls")
convert_to_pdf.request.id = "test-task-id"
result = convert_to_pdf.__wrapped__("/tmp/uuid.xls", "report.xls")
# Verify process_document was called with modified original filename
mock_process.delay.assert_called_once()
+21 -27
View File
@@ -125,9 +125,7 @@ class TestEmbedMetadataIntoPdf:
with patch("app.tasks.embed_metadata_into_pdf.settings") as mock_settings:
mock_settings.workdir = "/workdir"
# Mock task context
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
embed_metadata_into_pdf.request.id = "test-task-id"
metadata = {
"filename": "2024-01-15_Invoice.pdf",
@@ -137,7 +135,7 @@ class TestEmbedMetadataIntoPdf:
}
result = embed_metadata_into_pdf.__wrapped__(
mock_task, "/workdir/tmp/test.pdf", "Sample text", metadata, file_id=123
"/workdir/tmp/test.pdf", "Sample text", metadata, file_id=123
)
# Verify PDF metadata was set
@@ -167,11 +165,10 @@ class TestEmbedMetadataIntoPdf:
with patch("app.tasks.embed_metadata_into_pdf.settings") as mock_settings:
mock_settings.workdir = "/workdir"
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
embed_metadata_into_pdf.request.id = "test-task-id"
result = embed_metadata_into_pdf.__wrapped__(
mock_task, "/nonexistent/file.pdf", "text", {"filename": "test.pdf"}, file_id=123
"/nonexistent/file.pdf", "text", {"filename": "test.pdf"}, file_id=123
)
assert result == {"error": "File not found"}
@@ -220,11 +217,10 @@ class TestEmbedMetadataIntoPdf:
with patch("app.tasks.embed_metadata_into_pdf.tempfile.NamedTemporaryFile"):
mock_settings.workdir = "/workdir"
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
embed_metadata_into_pdf.request.id = "test-task-id"
result = embed_metadata_into_pdf.__wrapped__(
mock_task, "/workdir/tmp/test.pdf", "text", {"filename": "test.pdf"}
"/workdir/tmp/test.pdf", "text", {"filename": "test.pdf"}
)
# Verify database was queried
@@ -253,11 +249,10 @@ class TestEmbedMetadataIntoPdf:
with patch("app.tasks.embed_metadata_into_pdf.settings") as mock_settings:
mock_settings.workdir = "/workdir"
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
embed_metadata_into_pdf.request.id = "test-task-id"
result = embed_metadata_into_pdf.__wrapped__(
mock_task, "/workdir/tmp/test.pdf", "text", {"filename": "test.pdf"}, file_id=789
"/workdir/tmp/test.pdf", "text", {"filename": "test.pdf"}, file_id=789
)
assert "error" in result
@@ -318,14 +313,13 @@ class TestEmbedMetadataIntoPdf:
with patch("app.tasks.embed_metadata_into_pdf.settings") as mock_settings:
mock_settings.workdir = "/workdir"
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
embed_metadata_into_pdf.request.id = "test-task-id"
# Try to embed metadata with malicious filename
metadata = {"filename": "../../../etc/passwd"}
result = embed_metadata_into_pdf.__wrapped__(
mock_task, "/workdir/tmp/test.pdf", "text", metadata, file_id=111
"/workdir/tmp/test.pdf", "text", metadata, file_id=111
)
# Verify sanitize_filename was called
@@ -383,14 +377,13 @@ class TestEmbedMetadataIntoPdf:
with patch("app.tasks.embed_metadata_into_pdf.settings") as mock_settings:
mock_settings.workdir = "/workdir"
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
embed_metadata_into_pdf.request.id = "test-task-id"
# Metadata with missing fields
metadata = {}
result = embed_metadata_into_pdf.__wrapped__(
mock_task, "/workdir/tmp/test.pdf", "text", metadata, file_id=222
"/workdir/tmp/test.pdf", "text", metadata, file_id=222
)
# Verify PDF metadata was set with defaults
@@ -450,21 +443,22 @@ class TestEmbedMetadataIntoPdf:
with patch("app.tasks.embed_metadata_into_pdf.Path") as mock_path_class:
# Mock Path for deletion logic
mock_original_path = MagicMock()
mock_original_path.exists.return_value = True
mock_original_path.is_relative_to.return_value = True
mock_resolved_path = MagicMock()
mock_resolved_path.exists.return_value = True
mock_resolved_path.is_relative_to.return_value = True
mock_original_path.resolve.return_value = mock_resolved_path
mock_workdir_path = MagicMock()
mock_path_class.side_effect = [mock_workdir_path, mock_original_path, mock_workdir_path]
mock_path_class.side_effect = [mock_workdir_path, mock_original_path]
with patch("app.tasks.embed_metadata_into_pdf.os.remove"):
with patch("app.tasks.embed_metadata_into_pdf.settings") as mock_settings:
mock_settings.workdir = "/workdir"
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
embed_metadata_into_pdf.request.id = "test-task-id"
result = embed_metadata_into_pdf.__wrapped__(
mock_task, "/workdir/tmp/test.pdf", "text", {"filename": "test.pdf"}, file_id=333
"/workdir/tmp/test.pdf", "text", {"filename": "test.pdf"}, file_id=333
)
# Verify unlink (delete) was called
mock_original_path.unlink.assert_called_once()
# Verify unlink (delete) was called on the resolved path
mock_resolved_path.unlink.assert_called_once()
+19 -29
View File
@@ -1,7 +1,7 @@
"""Comprehensive unit tests for app/tasks/extract_metadata_with_gpt.py module."""
import json
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
import pytest
@@ -89,12 +89,11 @@ class TestExtractMetadataWithGpt:
})
mock_client.chat.completions.create.return_value = mock_completion
# Mock the task context
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
# Set task request context directly on the Celery task
extract_metadata_with_gpt.request.id = "test-task-id"
# Call the underlying function directly (not through Celery)
result = extract_metadata_with_gpt.__wrapped__(mock_task, "test_invoice.pdf", "Invoice from Amazon for 99.99 EUR", 123)
result = extract_metadata_with_gpt.__wrapped__("test_invoice.pdf", "Invoice from Amazon for 99.99 EUR", 123)
# Verify OpenAI was called
mock_client.chat.completions.create.assert_called_once()
@@ -123,10 +122,9 @@ class TestExtractMetadataWithGpt:
mock_completion.choices[0].message.content = '```json\n{"filename": "test.pdf", "document_type": "Unknown"}\n```'
mock_client.chat.completions.create.return_value = mock_completion
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(mock_task, "test.pdf", "Sample text", 456)
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 456)
assert result["metadata"]["filename"] == "test.pdf"
assert result["metadata"]["document_type"] == "Unknown"
@@ -141,10 +139,9 @@ class TestExtractMetadataWithGpt:
mock_completion.choices[0].message.content = "This is not valid JSON at all"
mock_client.chat.completions.create.return_value = mock_completion
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(mock_task, "test.pdf", "Sample text", 789)
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 789)
assert result == {}
mock_embed_task.delay.assert_not_called()
@@ -159,10 +156,9 @@ class TestExtractMetadataWithGpt:
"""Test handling of OpenAI API exceptions."""
mock_client.chat.completions.create.side_effect = Exception("API Error: Rate limit exceeded")
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(mock_task, "test.pdf", "Sample text", 101)
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 101)
assert result == {}
mock_embed_task.delay.assert_not_called()
@@ -192,11 +188,9 @@ class TestExtractMetadataWithGpt:
# Mock file existence
with patch("app.tasks.extract_metadata_with_gpt.os.path.exists", return_value=True):
with patch("app.tasks.extract_metadata_with_gpt.settings.workdir", "/tmp"):
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(
mock_task,
filename="test.pdf",
cleaned_text="Sample text",
file_id=None # Not provided
@@ -219,10 +213,9 @@ class TestExtractMetadataWithGpt:
})
mock_client.chat.completions.create.return_value = mock_completion
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(mock_task, "test.pdf", "Sample text", 202)
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 202)
# Filename should be sanitized (empty or safe)
assert result["metadata"]["filename"] == ""
@@ -240,10 +233,9 @@ class TestExtractMetadataWithGpt:
})
mock_client.chat.completions.create.return_value = mock_completion
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(mock_task, "test.pdf", "Sample text", 303)
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 303)
# Filename with .. should be rejected
assert result["metadata"]["filename"] == ""
@@ -260,10 +252,9 @@ class TestExtractMetadataWithGpt:
})
mock_client.chat.completions.create.return_value = mock_completion
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(mock_task, "test.pdf", "Sample text", 404)
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 404)
# Valid filename should be preserved
assert result["metadata"]["filename"] == "2024-01-15_Invoice_Amazon.pdf"
@@ -277,10 +268,9 @@ class TestExtractMetadataWithGpt:
mock_completion.choices[0].message.content = '{"unexpected_field": "value"}'
mock_client.chat.completions.create.return_value = mock_completion
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(mock_task, "test.pdf", "Sample text", 505)
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 505)
# Should still extract the JSON even if fields are unexpected
assert "metadata" in result
+10 -26
View File
@@ -44,8 +44,7 @@ class TestFinalizeDocumentStorage:
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=102400):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="test_document.pdf"):
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
finalize_document_storage.request.id = "test-task-id"
metadata = {
"filename": "test_document.pdf",
@@ -54,7 +53,6 @@ class TestFinalizeDocumentStorage:
}
result = finalize_document_storage.__wrapped__(
mock_task,
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/test_document.pdf",
metadata=metadata,
@@ -108,11 +106,9 @@ class TestFinalizeDocumentStorage:
with patch("app.tasks.finalize_document_storage.settings") as mock_settings:
mock_settings.workdir = "/tmp"
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
finalize_document_storage.request.id = "test-task-id"
result = finalize_document_storage.__wrapped__(
mock_task,
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/doc.pdf",
metadata={"filename": "doc.pdf"},
@@ -154,11 +150,9 @@ class TestFinalizeDocumentStorage:
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="test.pdf"):
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
finalize_document_storage.request.id = "test-task-id"
result = finalize_document_storage.__wrapped__(
mock_task,
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/test.pdf",
metadata={"filename": "test.pdf"},
@@ -171,8 +165,8 @@ class TestFinalizeDocumentStorage:
# Should still send notification
mock_notify.assert_called_once()
notify_args = mock_notify.call_args[1]
# Should have fallback destination text
assert len(notify_args["destinations"]) > 0
# No services configured means empty destinations list
assert notify_args["destinations"] == []
@patch("app.tasks.finalize_document_storage.notify_file_processed")
@patch("app.tasks.finalize_document_storage.send_to_all_destinations")
@@ -198,11 +192,9 @@ class TestFinalizeDocumentStorage:
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"):
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
finalize_document_storage.request.id = "test-task-id"
result = finalize_document_storage.__wrapped__(
mock_task,
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/file.pdf",
metadata={"filename": "file.pdf"},
@@ -243,11 +235,9 @@ class TestFinalizeDocumentStorage:
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=4096):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="doc.pdf"):
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
finalize_document_storage.request.id = "test-task-id"
result = finalize_document_storage.__wrapped__(
mock_task,
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/doc.pdf",
metadata={"filename": "doc.pdf"},
@@ -283,11 +273,9 @@ class TestFinalizeDocumentStorage:
# File doesn't exist
with patch("app.tasks.finalize_document_storage.os.path.exists", return_value=False):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="missing.pdf"):
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
finalize_document_storage.request.id = "test-task-id"
result = finalize_document_storage.__wrapped__(
mock_task,
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/missing.pdf",
metadata={"filename": "missing.pdf"},
@@ -330,11 +318,9 @@ class TestFinalizeDocumentStorage:
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=8192):
with patch("app.tasks.finalize_document_storage.os.path.basename", return_value="test.pdf"):
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
finalize_document_storage.request.id = "test-task-id"
result = finalize_document_storage.__wrapped__(
mock_task,
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/test.pdf",
metadata={"filename": "test.pdf"},
@@ -372,11 +358,9 @@ class TestFinalizeDocumentStorage:
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="file.pdf"):
mock_task = MagicMock()
mock_task.request.id = "test-task-id"
finalize_document_storage.request.id = "test-task-id"
result = finalize_document_storage.__wrapped__(
mock_task,
original_file="/tmp/original.pdf",
processed_file="/workdir/processed/file.pdf",
metadata={"filename": "file.pdf"},
+11 -4
View File
@@ -50,7 +50,7 @@ class TestOAuthLoginFlow:
try:
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
client = TestClient(app, base_url="http://localhost")
response = client.get("/oauth-login", follow_redirects=False)
# Should either redirect to error page or show login page
@@ -64,6 +64,7 @@ class TestOAuthLoginFlow:
class TestOAuthCallback:
"""Test OAuth callback handling."""
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_with_valid_token(
self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict
@@ -86,6 +87,7 @@ class TestOAuthCallback:
# Should redirect after successful login
assert response.status_code == 302
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_stores_user_in_session(
self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict
@@ -108,6 +110,7 @@ class TestOAuthCallback:
# Should set session cookie
assert "set-cookie" in response.headers or response.status_code == 302
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_with_admin_user(
self, mock_authorize, oauth_enabled_app: TestClient
@@ -131,11 +134,12 @@ class TestOAuthCallback:
# Should successfully authenticate
assert response.status_code == 302
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_rejects_non_admin(
self, mock_authorize, oauth_enabled_app: TestClient
):
"""Test that OAuth callback rejects users without admin group."""
"""Test that OAuth callback authenticates non-admin users with is_admin=False."""
mock_authorize.return_value = {
"access_token": "mock-access-token",
"userinfo": {
@@ -151,15 +155,15 @@ class TestOAuthCallback:
follow_redirects=False,
)
# Should redirect to error page
# Non-admin users are still authenticated but with is_admin=False
assert response.status_code == 302
assert "error" in response.headers.get("location", "").lower()
@pytest.mark.integration
class TestOAuthSessionManagement:
"""Test session management with OAuth authentication."""
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_authenticated_user_can_access_protected_routes(
self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict
@@ -192,6 +196,7 @@ class TestOAuthSessionManagement:
if response.status_code == 302:
assert "/login" in response.headers.get("location", "")
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_logout_clears_session(
self, mock_authorize, oauth_enabled_app: TestClient, test_user_info: dict
@@ -226,6 +231,7 @@ class TestOAuthErrorHandling:
# Should handle error gracefully
assert response.status_code in [302, 400]
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_with_invalid_token(
self, mock_authorize, oauth_enabled_app: TestClient
@@ -244,6 +250,7 @@ class TestOAuthErrorHandling:
location = response.headers.get("location", "")
assert "error" in location.lower() or "login" in location.lower()
@pytest.mark.asyncio
@patch("app.auth.oauth.authentik.authorize_access_token")
async def test_oauth_callback_without_userinfo(
self, mock_authorize, oauth_enabled_app: TestClient
+1 -1
View File
@@ -136,7 +136,7 @@ class TestRateLimitDecorators:
# Mock limiter to return a simple passthrough decorator
mock_limiter = MagicMock()
mock_limiter.exempt.return_value = lambda f: f
mock_limiter.exempt.side_effect = lambda f: f
mock_get_limiter.return_value = mock_limiter
# Decorate function
+16 -19
View File
@@ -44,8 +44,8 @@ class TestStepTimeout:
# Mock database session with proper query chain
mock_db = MagicMock()
# Set up the query chain to return empty list
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = []
# Set up the query chain to return empty list (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = []
# Run function
count = mark_stalled_steps_as_failed(mock_db)
@@ -74,8 +74,8 @@ class TestStepTimeout:
# Mock database session
mock_db = MagicMock()
# Set up the query chain to return stalled steps
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = [step1, step2]
# Set up the query chain to return stalled steps (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = [step1, step2]
# Run function
count = mark_stalled_steps_as_failed(mock_db)
@@ -105,8 +105,8 @@ class TestStepTimeout:
# Mock database session
mock_db = MagicMock()
# Set up the query chain to return stalled step
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = [step]
# Set up the query chain to return stalled step (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = [step]
# Run function with 150 second timeout
count = mark_stalled_steps_as_failed(mock_db, timeout_seconds=150)
@@ -131,9 +131,8 @@ class TestStepTimeout:
# Mock database session with file filter
mock_db = MagicMock()
# Set up the query chain with file filter
mock_query_chain = mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value
mock_query_chain.filter.return_value.all.return_value = [step]
# Set up the query chain with file filter (first .filter() for conditions, second for file_id)
mock_db.query.return_value.filter.return_value.filter.return_value.all.return_value = [step]
# Run function for specific file
count = mark_stalled_steps_as_failed(mock_db, file_id=42)
@@ -158,8 +157,8 @@ class TestStepTimeout:
# Mock database session
mock_db = MagicMock()
# Set up the query chain to return stalled step
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = [step]
# Set up the query chain to return stalled step (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = [step]
# Run function
count = mark_stalled_steps_as_failed(mock_db, timeout_seconds=600)
@@ -186,8 +185,8 @@ class TestStepTimeout:
# Mock database session
mock_db = MagicMock()
# Set up the query chain to return stalled step
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.all.return_value = [step]
# Set up the query chain to return stalled step (single .filter() call with multiple conditions)
mock_db.query.return_value.filter.return_value.all.return_value = [step]
# Run function
count = mark_stalled_steps_as_failed(mock_db)
@@ -212,9 +211,8 @@ class TestStepTimeout:
# Mock database session
mock_db = MagicMock()
# Set up the query chain with file filter
mock_query_chain = mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value
mock_query_chain.filter.return_value.all.return_value = [step]
# Set up the query chain with file filter (first .filter() for conditions, second for file_id)
mock_db.query.return_value.filter.return_value.filter.return_value.all.return_value = [step]
# Run function
result = check_and_recover_stalled_file(mock_db, 42)
@@ -229,9 +227,8 @@ class TestStepTimeout:
# Mock database session with no stalled steps
mock_db = MagicMock()
# Set up the query chain with file filter
mock_query_chain = mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value
mock_query_chain.filter.return_value.all.return_value = []
# Set up the query chain with file filter (first .filter() for conditions, second for file_id)
mock_db.query.return_value.filter.return_value.filter.return_value.all.return_value = []
# Run function
result = check_and_recover_stalled_file(mock_db, 42)