Files
gh-christianlouis-docuelevate/tests/test_extract_metadata_gpt.py
T
2026-02-14 00:06:55 +00:00

353 lines
16 KiB
Python

"""Comprehensive unit tests for app/tasks/extract_metadata_with_gpt.py module."""
import json
from unittest.mock import MagicMock, patch
import pytest
from app.tasks.extract_metadata_with_gpt import extract_json_from_text, extract_metadata_with_gpt
@pytest.mark.unit
class TestExtractJsonFromText:
"""Tests for extract_json_from_text function."""
def test_extracts_json_from_backticks_with_json_tag(self):
"""Test extraction of JSON from triple-backtick block with json tag."""
text = '```json\n{"key": "value", "num": 123}\n```'
result = extract_json_from_text(text)
assert result == '{"key": "value", "num": 123}'
def test_extracts_json_from_backticks_no_lang(self):
"""Test extraction from backticks without language tag."""
text = '```\n{"key": "value"}\n```'
result = extract_json_from_text(text)
assert result == '{"key": "value"}'
def test_extracts_json_from_raw_text(self):
"""Test extraction from raw text with JSON."""
text = 'Here is the result: {"key": "value", "nested": {"a": 1}} end.'
result = extract_json_from_text(text)
assert result == '{"key": "value", "nested": {"a": 1}}'
def test_returns_none_for_no_json(self):
"""Test returns None when no JSON found."""
text = "No JSON here at all, just plain text."
result = extract_json_from_text(text)
assert result is None
def test_returns_none_for_incomplete_json(self):
"""Test returns None for incomplete JSON structures."""
text = "Only opening brace: { but no closing"
result = extract_json_from_text(text)
assert result is None
def test_extracts_complex_nested_json(self):
"""Test extraction of complex nested JSON."""
text = '{"filename": "2024-01-01_Invoice", "tags": ["test", "invoice"], "metadata": {"amount": 100, "currency": "USD"}}'
result = extract_json_from_text(text)
parsed = json.loads(result)
assert parsed["filename"] == "2024-01-01_Invoice"
assert "tags" in parsed
assert "metadata" in parsed
assert parsed["metadata"]["amount"] == 100
def test_extracts_first_json_when_multiple_present(self):
"""Test that extraction finds the outermost JSON object."""
text = 'First: {"a": 1} and second: {"b": 2}'
result = extract_json_from_text(text)
# Should extract from first { to last }
assert result is not None
assert "{" in result and "}" in result
@pytest.mark.unit
class TestExtractMetadataWithGpt:
"""Tests for extract_metadata_with_gpt Celery task."""
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
def test_successful_metadata_extraction(self, mock_client, mock_log_progress, mock_embed_task):
"""Test successful metadata extraction with valid GPT response."""
# Mock the OpenAI client response
mock_completion = MagicMock()
mock_completion.choices[0].message.content = json.dumps(
{
"filename": "2024-01-15_Invoice_Amazon",
"empfaenger": "John Doe",
"absender": "Amazon",
"correspondent": "Amazon",
"kommunikationsart": "Rechnung",
"kommunikationskategorie": "Finanz_und_Vertragsdokumente",
"document_type": "Invoice",
"tags": ["invoice", "amazon", "online-shopping"],
"language": "de",
"title": "Amazon Purchase Invoice",
"confidence_score": 95,
"reference_number": "INV-2024-001",
"monetary_amounts": ["99.99 EUR"],
}
)
mock_client.chat.completions.create.return_value = mock_completion
# 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__("test_invoice.pdf", "Invoice from Amazon for 99.99 EUR", 123)
# Verify OpenAI was called
mock_client.chat.completions.create.assert_called_once()
call_args = mock_client.chat.completions.create.call_args
assert call_args[1]["temperature"] == 0
assert len(call_args[1]["messages"]) == 2
# Verify metadata was extracted correctly
assert result["s3_file"] == "test_invoice.pdf"
assert "metadata" in result
assert result["metadata"]["document_type"] == "Invoice"
assert result["metadata"]["correspondent"] == "Amazon"
# Verify embed task was queued
mock_embed_task.delay.assert_called_once()
# Verify task progress was logged
assert mock_log_progress.call_count >= 3
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
def test_handles_json_in_backticks(self, mock_client, mock_log_progress, mock_embed_task):
"""Test extraction handles JSON wrapped in markdown code blocks."""
mock_completion = MagicMock()
mock_completion.choices[
0
].message.content = '```json\n{"filename": "test.pdf", "document_type": "Unknown"}\n```'
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 456)
assert result["metadata"]["filename"] == "test.pdf"
assert result["metadata"]["document_type"] == "Unknown"
mock_embed_task.delay.assert_called_once()
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
def test_handles_invalid_json_response(self, mock_client, mock_log_progress, mock_embed_task):
"""Test handling of invalid JSON in GPT response."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = "This is not valid JSON at all"
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 789)
assert result == {}
mock_embed_task.delay.assert_not_called()
# Verify failure was logged
failure_calls = [call for call in mock_log_progress.call_args_list if "failure" in str(call)]
assert len(failure_calls) > 0
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
def test_handles_openai_api_exception(self, mock_client, mock_log_progress, mock_embed_task):
"""Test handling of OpenAI API exceptions."""
mock_client.chat.completions.create.side_effect = Exception("API Error: Rate limit exceeded")
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 101)
assert result == {}
mock_embed_task.delay.assert_not_called()
# Verify exception was logged
failure_calls = [call for call in mock_log_progress.call_args_list if "failure" in str(call)]
assert len(failure_calls) > 0
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
@patch("app.tasks.extract_metadata_with_gpt.SessionLocal")
def test_retrieves_file_id_from_database_when_not_provided(
self, mock_session_local, mock_client, mock_log_progress, mock_embed_task
):
"""Test file_id retrieval from database when not provided."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = '{"filename": "test.pdf", "document_type": "Unknown"}'
mock_client.chat.completions.create.return_value = mock_completion
# Mock database session
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_file_record = MagicMock()
mock_file_record.id = 999
mock_db.query.return_value.filter_by.return_value.first.return_value = mock_file_record
# 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"):
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(
filename="test.pdf",
cleaned_text="Sample text",
file_id=None, # Not provided
)
assert result["metadata"]["filename"] == "test.pdf"
# Verify database was queried
mock_db.query.assert_called_once()
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
def test_validates_filename_security(self, mock_client, mock_log_progress, mock_embed_task):
"""Test filename validation to prevent path traversal."""
mock_completion = MagicMock()
# Try to inject a malicious filename
mock_completion.choices[0].message.content = json.dumps(
{"filename": "../../../etc/passwd", "document_type": "Invoice"}
)
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 202)
# Filename should be sanitized (empty or safe)
assert result["metadata"]["filename"] == ""
mock_embed_task.delay.assert_called_once()
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
def test_validates_filename_with_dots(self, mock_client, mock_log_progress, mock_embed_task):
"""Test filename validation rejects '..' in filenames."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = json.dumps(
{"filename": "test..invoice.pdf", "document_type": "Invoice"}
)
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__("test.pdf", "Sample text", 303)
# Filename with .. should be rejected
assert result["metadata"]["filename"] == ""
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
def test_accepts_valid_filename(self, mock_client, mock_log_progress, mock_embed_task):
"""Test that valid filenames are accepted."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = json.dumps(
{"filename": "2024-01-15_Invoice_Amazon.pdf", "document_type": "Invoice"}
)
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
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"
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
def test_handles_malformed_json_with_valid_structure(self, mock_client, mock_log_progress, mock_embed_task):
"""Test handling of JSON that's parseable but missing expected fields."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = '{"unexpected_field": "value"}'
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
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
assert result["metadata"]["unexpected_field"] == "value"
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
def test_handles_absolute_path_filename(self, mock_client, mock_log_progress, mock_embed_task):
"""Test handling when filename is provided as an absolute path (line 73)."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = '{"filename": "test.pdf", "document_type": "Unknown"}'
mock_client.chat.completions.create.return_value = mock_completion
extract_metadata_with_gpt.request.id = "test-task-id"
# Provide an absolute path as filename
absolute_path = "/absolute/path/to/test.pdf"
result = extract_metadata_with_gpt.__wrapped__(absolute_path, "Sample text", 606)
# Should handle absolute path correctly
assert result["s3_file"] == "test.pdf" # Should extract basename
assert "metadata" in result
@patch("app.tasks.extract_metadata_with_gpt.embed_metadata_into_pdf")
@patch("app.tasks.extract_metadata_with_gpt.log_task_progress")
@patch("app.tasks.extract_metadata_with_gpt.client")
@patch("app.tasks.extract_metadata_with_gpt.SessionLocal")
def test_database_lookup_with_existing_file(
self, mock_session_local, mock_client, mock_log_progress, mock_embed_task
):
"""Test file_id retrieval when file exists on disk and in database (branches 76->82, 79->82)."""
mock_completion = MagicMock()
mock_completion.choices[0].message.content = '{"filename": "test.pdf", "document_type": "Unknown"}'
mock_client.chat.completions.create.return_value = mock_completion
# Mock database session
mock_db = MagicMock()
mock_session_local.return_value.__enter__.return_value = mock_db
mock_file_record = MagicMock()
mock_file_record.id = 888
mock_db.query.return_value.filter_by.return_value.first.return_value = mock_file_record
# Mock file existence check
with patch("app.tasks.extract_metadata_with_gpt.os.path.exists", return_value=True):
with patch("app.tasks.extract_metadata_with_gpt.os.path.isabs", return_value=False):
with patch("app.tasks.extract_metadata_with_gpt.settings.workdir", "/tmp"):
extract_metadata_with_gpt.request.id = "test-task-id"
result = extract_metadata_with_gpt.__wrapped__(
filename="test.pdf",
cleaned_text="Sample text",
file_id=None, # Not provided, should look up
)
assert result["metadata"]["filename"] == "test.pdf"
# Verify database was queried
mock_db.query.assert_called_once()
@pytest.mark.unit
class TestClientInitialization:
"""Tests for OpenAI client initialization error handling."""
def test_client_initialization_imports_successfully(self):
"""Test that module imports successfully even if client initialization fails (lines 25-27).
The module has a try/except block for client initialization that sets client to None
on failure. This test verifies the module can be imported without crashing,
regardless of whether the client initializes successfully or not.
"""
# Import should succeed regardless of client initialization success
from app.tasks.extract_metadata_with_gpt import client
# Client will be either an OpenAI client instance or None
# Both are valid states - the important thing is the import doesn't crash
# We verify the client variable exists and has a defined type
assert hasattr(client, "__class__") or client is None