8e8a64969f
- Add test_extract_metadata_gpt.py with 16 tests (7 passing) - Add test_convert_pdf.py with 13 tests achieving 90.56% coverage - Add test_embed_pdf_metadata.py with 10 tests achieving 46.81% coverage - Add test_finalize_storage.py with 9 tests - Add test_auth_module.py with 14 tests achieving 43.48% coverage Coverage improvements: - convert_to_pdf: 10% → 90.56% - embed_metadata_into_pdf: 14.89% → 46.81% - extract_metadata_with_gpt: 21.84% → 36.78% - auth.py: 27.83% → 43.48% 43 tests passing, working on fixing remaining tests for full coverage Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
288 lines
13 KiB
Python
288 lines
13 KiB
Python
"""Comprehensive unit tests for app/tasks/extract_metadata_with_gpt.py module."""
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, Mock, 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
|
|
|
|
# Mock the task context
|
|
mock_task = MagicMock()
|
|
mock_task.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)
|
|
|
|
# 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
|
|
|
|
mock_task = MagicMock()
|
|
mock_task.request.id = "test-task-id"
|
|
|
|
result = extract_metadata_with_gpt.__wrapped__(mock_task, "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
|
|
|
|
mock_task = MagicMock()
|
|
mock_task.request.id = "test-task-id"
|
|
|
|
result = extract_metadata_with_gpt.__wrapped__(mock_task, "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")
|
|
|
|
mock_task = MagicMock()
|
|
mock_task.request.id = "test-task-id"
|
|
|
|
result = extract_metadata_with_gpt.__wrapped__(mock_task, "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"):
|
|
mock_task = MagicMock()
|
|
mock_task.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
|
|
)
|
|
|
|
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
|
|
|
|
mock_task = MagicMock()
|
|
mock_task.request.id = "test-task-id"
|
|
|
|
result = extract_metadata_with_gpt.__wrapped__(mock_task, "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
|
|
|
|
mock_task = MagicMock()
|
|
mock_task.request.id = "test-task-id"
|
|
|
|
result = extract_metadata_with_gpt.__wrapped__(mock_task, "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
|
|
|
|
mock_task = MagicMock()
|
|
mock_task.request.id = "test-task-id"
|
|
|
|
result = extract_metadata_with_gpt.__wrapped__(mock_task, "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
|
|
|
|
mock_task = MagicMock()
|
|
mock_task.request.id = "test-task-id"
|
|
|
|
result = extract_metadata_with_gpt.__wrapped__(mock_task, "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"
|