Files
copilot-swe-agent[bot] 5bf0a4c0b9 feat(search): add content-finding filters, saved searches, and text quality to Search view
- Add tags, sender, text_quality filters to search API and Meilisearch client
- Add sender and ocr_text_length to Meilisearch filterable attributes
- Expand saved search allowed filter keys to include q, document_type, language, sender, text_quality
- Add filters panel and saved searches UI to the Search view template
- Add tests for new search filters, saved search keys, and search view elements

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-01 13:46:15 +00:00

403 lines
17 KiB
Python

"""Tests for the full-text search API (app/api/search.py) and Meilisearch
client utilities (app/utils/meilisearch_client.py).
"""
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# app/utils/meilisearch_client tests
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMeilisearchClientDisabled:
"""Tests when search is disabled or Meilisearch is unavailable."""
def test_get_client_returns_none_when_disabled(self):
"""get_meilisearch_client returns None when enable_search=False."""
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=None):
from app.utils.meilisearch_client import index_document, search_documents
class _FakeRecord:
id = 1
original_filename = "test.pdf"
mime_type = "application/pdf"
file_size = 1024
created_at = None
result = index_document(_FakeRecord(), "some text", {})
assert result is False
result = search_documents("invoice")
assert result["results"] == []
assert result["total"] == 0
def test_search_documents_import_error(self):
"""search_documents returns empty dict when meilisearch not installed."""
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=None):
from app.utils.meilisearch_client import search_documents
result = search_documents("test")
assert result["results"] == []
assert result["total"] == 0
assert result["page"] == 1
assert result["query"] == "test"
def test_delete_document_no_client(self):
"""delete_document returns False when client unavailable."""
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=None):
from app.utils.meilisearch_client import delete_document
result = delete_document(99)
assert result is False
@pytest.mark.unit
class TestMeilisearchIndexDocument:
"""Tests for index_document function."""
def test_index_document_success(self):
"""index_document returns True when Meilisearch succeeds."""
mock_client = MagicMock()
mock_index = MagicMock()
mock_task = MagicMock()
mock_task.task_uid = 1
mock_client.get_index.return_value = mock_index
mock_index.add_documents.return_value = mock_task
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import index_document
class _FakeRecord:
id = 1
original_filename = "invoice.pdf"
mime_type = "application/pdf"
file_size = 2048
created_at = None
result = index_document(
_FakeRecord(),
"This is an invoice for services rendered",
{
"title": "Invoice January 2026",
"document_type": "Invoice",
"tags": ["invoice", "services"],
"absender": "ACME Corp",
"language": "en",
},
)
assert result is True
mock_index.add_documents.assert_called_once()
call_docs = mock_index.add_documents.call_args[0][0]
assert len(call_docs) == 1
doc = call_docs[0]
assert doc["file_id"] == 1
assert doc["document_title"] == "Invoice January 2026"
assert "invoice" in doc["tags"]
assert doc["ocr_text_length"] == len("This is an invoice for services rendered")
def test_index_document_meilisearch_error(self):
"""index_document returns False on Meilisearch exception."""
mock_client = MagicMock()
mock_index = MagicMock()
mock_client.get_index.return_value = mock_index
mock_index.add_documents.side_effect = RuntimeError("Meilisearch down")
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import index_document
class _FakeRecord:
id = 2
original_filename = "test.pdf"
mime_type = "application/pdf"
file_size = 512
created_at = None
result = index_document(_FakeRecord(), "some text", {})
assert result is False
@pytest.mark.unit
class TestMeilisearchSearchDocuments:
"""Tests for search_documents function."""
def _make_mock_client(self, hits=None, total=None):
mock_client = MagicMock()
mock_index = MagicMock()
mock_client.get_index.return_value = mock_index
mock_index.search.return_value = {
"hits": hits or [],
"estimatedTotalHits": total if total is not None else len(hits or []),
}
return mock_client, mock_index
def test_search_returns_results(self):
"""search_documents returns hits from Meilisearch."""
hits = [
{
"file_id": 42,
"original_filename": "2026-01-15_Invoice_Amazon.pdf",
"document_title": "Amazon Invoice",
"document_type": "Invoice",
"tags": ["amazon", "invoice"],
"ocr_text": "Amazon invoice content here",
"_formatted": {
"document_title": "Amazon <mark>Invoice</mark>",
"ocr_text": "…Amazon <mark>invoice</mark> content here…",
},
}
]
mock_client, mock_index = self._make_mock_client(hits=hits, total=1)
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import search_documents
result = search_documents("invoice", page=1, per_page=20)
assert result["total"] == 1
assert result["pages"] == 1
assert result["query"] == "invoice"
assert len(result["results"]) == 1
# Raw ocr_text should be stripped from result (only _formatted snippet kept)
assert "ocr_text" not in result["results"][0]
assert result["results"][0]["file_id"] == 42
def test_search_with_filters(self):
"""search_documents passes filter expressions to Meilisearch."""
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import search_documents
search_documents("contract", mime_type="application/pdf", language="de", page=1, per_page=10)
call_kwargs = mock_index.search.call_args
search_params = call_kwargs[0][1]
assert "filter" in search_params
assert 'mime_type = "application/pdf"' in search_params["filter"]
assert 'language = "de"' in search_params["filter"]
def test_search_with_tags_filter(self):
"""search_documents passes tags filter to Meilisearch."""
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import search_documents
search_documents("test", tags="invoice", page=1, per_page=10)
call_kwargs = mock_index.search.call_args
search_params = call_kwargs[0][1]
assert "filter" in search_params
assert 'tags = "invoice"' in search_params["filter"]
def test_search_with_sender_filter(self):
"""search_documents passes sender filter to Meilisearch."""
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import search_documents
search_documents("test", sender="ACME Corp", page=1, per_page=10)
call_kwargs = mock_index.search.call_args
search_params = call_kwargs[0][1]
assert "filter" in search_params
assert 'sender = "ACME Corp"' in search_params["filter"]
def test_search_with_text_quality_high(self):
"""search_documents translates text_quality=high to ocr_text_length filter."""
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import search_documents
search_documents("test", text_quality="high", page=1, per_page=10)
call_kwargs = mock_index.search.call_args
search_params = call_kwargs[0][1]
assert "filter" in search_params
assert "ocr_text_length >= 2000" in search_params["filter"]
def test_search_with_text_quality_no_text(self):
"""search_documents translates text_quality=no_text to ocr_text_length filter."""
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import search_documents
search_documents("test", text_quality="no_text", page=1, per_page=10)
call_kwargs = mock_index.search.call_args
search_params = call_kwargs[0][1]
assert "filter" in search_params
assert "ocr_text_length = 0" in search_params["filter"]
def test_search_pagination(self):
"""search_documents applies correct offset for page 2."""
mock_client, mock_index = self._make_mock_client(hits=[], total=50)
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import search_documents
result = search_documents("test", page=3, per_page=10)
call_kwargs = mock_index.search.call_args
search_params = call_kwargs[0][1]
assert search_params["offset"] == 20 # (3-1) * 10
assert search_params["limit"] == 10
assert result["pages"] == 5
def test_search_empty_results(self):
"""search_documents returns proper empty structure."""
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import search_documents
result = search_documents("nothing")
assert result["results"] == []
assert result["total"] == 0
assert result["pages"] == 0
def test_search_exception_returns_empty(self):
"""search_documents returns empty dict on Meilisearch exception."""
mock_client = MagicMock()
mock_index = MagicMock()
mock_client.get_index.side_effect = RuntimeError("Meilisearch unavailable")
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
from app.utils.meilisearch_client import search_documents
result = search_documents("invoice")
assert result["results"] == []
assert result["total"] == 0
# ---------------------------------------------------------------------------
# Search API endpoint tests (GET /api/search)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSearchAPIEndpoint:
"""Tests for GET /api/search endpoint."""
def test_search_endpoint_success(self, client):
"""GET /api/search?q=... returns search results."""
mock_result = {
"results": [{"file_id": 1, "document_title": "Test Invoice", "document_type": "Invoice"}],
"total": 1,
"page": 1,
"pages": 1,
"query": "invoice",
}
with patch("app.api.search.search_documents", return_value=mock_result):
response = client.get("/api/search?q=invoice")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["query"] == "invoice"
assert len(data["results"]) == 1
def test_search_endpoint_missing_query(self, client):
"""GET /api/search without q returns 422."""
response = client.get("/api/search")
assert response.status_code == 422
def test_search_endpoint_empty_query(self, client):
"""GET /api/search?q= (empty) returns 422 due to min_length=1."""
response = client.get("/api/search?q=")
assert response.status_code == 422
def test_search_endpoint_with_filters(self, client):
"""GET /api/search with optional filters passes them to search_documents."""
mock_result = {"results": [], "total": 0, "page": 1, "pages": 0, "query": "invoice"}
with patch("app.api.search.search_documents", return_value=mock_result) as mock_search:
response = client.get("/api/search?q=invoice&mime_type=application/pdf&language=en&page=2&per_page=10")
assert response.status_code == 200
mock_search.assert_called_once_with(
"invoice",
mime_type="application/pdf",
document_type=None,
language="en",
tags=None,
sender=None,
text_quality=None,
date_from=None,
date_to=None,
page=2,
per_page=10,
)
def test_search_endpoint_per_page_max(self, client):
"""GET /api/search with per_page > 100 returns 422."""
response = client.get("/api/search?q=test&per_page=200")
assert response.status_code == 422
def test_search_endpoint_pagination_defaults(self, client):
"""GET /api/search uses default page=1 per_page=20."""
mock_result = {"results": [], "total": 0, "page": 1, "pages": 0, "query": "test"}
with patch("app.api.search.search_documents", return_value=mock_result) as mock_search:
response = client.get("/api/search?q=test")
assert response.status_code == 200
mock_search.assert_called_once_with(
"test",
mime_type=None,
document_type=None,
language=None,
tags=None,
sender=None,
text_quality=None,
date_from=None,
date_to=None,
page=1,
per_page=20,
)
def test_search_endpoint_date_filters(self, client):
"""GET /api/search with date_from and date_to passes them as int."""
mock_result = {"results": [], "total": 0, "page": 1, "pages": 0, "query": "contract"}
with patch("app.api.search.search_documents", return_value=mock_result) as mock_search:
response = client.get("/api/search?q=contract&date_from=1704067200&date_to=1735689600")
assert response.status_code == 200
call_kwargs = mock_search.call_args
assert call_kwargs[1]["date_from"] == 1704067200
assert call_kwargs[1]["date_to"] == 1735689600
def test_search_endpoint_tags_filter(self, client):
"""GET /api/search?q=...&tags=invoice passes tags to search_documents."""
mock_result = {"results": [], "total": 0, "page": 1, "pages": 0, "query": "test"}
with patch("app.api.search.search_documents", return_value=mock_result) as mock_search:
response = client.get("/api/search?q=test&tags=invoice")
assert response.status_code == 200
assert mock_search.call_args[1]["tags"] == "invoice"
def test_search_endpoint_sender_filter(self, client):
"""GET /api/search?q=...&sender=ACME passes sender to search_documents."""
mock_result = {"results": [], "total": 0, "page": 1, "pages": 0, "query": "test"}
with patch("app.api.search.search_documents", return_value=mock_result) as mock_search:
response = client.get("/api/search?q=test&sender=ACME")
assert response.status_code == 200
assert mock_search.call_args[1]["sender"] == "ACME"
def test_search_endpoint_text_quality_filter(self, client):
"""GET /api/search?q=...&text_quality=high passes text_quality to search_documents."""
mock_result = {"results": [], "total": 0, "page": 1, "pages": 0, "query": "test"}
with patch("app.api.search.search_documents", return_value=mock_result) as mock_search:
response = client.get("/api/search?q=test&text_quality=high")
assert response.status_code == 200
assert mock_search.call_args[1]["text_quality"] == "high"