Skip to content

Commit 99f619f

Browse files
authored
Add missing test coverage for full text search filter (#9949)
1 parent 65aa4b8 commit 99f619f

File tree

4 files changed

+164
-6
lines changed

4 files changed

+164
-6
lines changed

.github/workflows/main.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ jobs:
2828
name: Python ${{ matrix.python-version }}
2929
runs-on: ubuntu-24.04
3030

31+
services:
32+
postgres:
33+
image: postgres:16
34+
env:
35+
POSTGRES_DB: postgres
36+
POSTGRES_USER: postgres
37+
POSTGRES_PASSWORD: postgres
38+
ports:
39+
- 5432:5432
40+
options: >-
41+
--health-cmd pg_isready
42+
--health-interval 10s
43+
--health-timeout 5s
44+
--health-retries 5
45+
3146
strategy:
3247
matrix:
3348
python-version:
@@ -37,6 +52,9 @@ jobs:
3752
- '3.13'
3853
- '3.14'
3954

55+
env:
56+
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
57+
4058
steps:
4159
- uses: actions/checkout@v6
4260

@@ -52,6 +70,9 @@ jobs:
5270
- name: Install dependencies
5371
run: python -m pip install --upgrade tox
5472

73+
- name: Create unaccent extension
74+
run: PGPASSWORD=postgres psql -h localhost -U postgres -d postgres -c 'CREATE EXTENSION IF NOT EXISTS unaccent;'
75+
5576
- name: Run tox targets for ${{ matrix.python-version }}
5677
run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d . | cut -f 1 -d '-')
5778

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dev = [
4444
{ include-group = "test" },
4545
]
4646
test = [
47+
"dj-database-url>=3.1.0",
4748
"importlib-metadata<10.0",
4849
# Pytest for running the tests.
4950
"pytest==9.*",
@@ -116,6 +117,9 @@ keep_full_version = true
116117
[tool.pytest.ini_options]
117118
addopts = "--tb=short --strict-markers -ra"
118119
testpaths = [ "tests" ]
120+
markers = [
121+
"requires_postgres: marks tests as requiring a PostgreSQL database backend",
122+
]
119123
filterwarnings = [
120124
"ignore:'cgi' is deprecated:DeprecationWarning",
121125
]

tests/conftest.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import os
22

3+
import dj_database_url
34
import django
5+
import pytest
46
from django.core import management
57

68

@@ -13,19 +15,27 @@ def pytest_addoption(parser):
1315
def pytest_configure(config):
1416
from django.conf import settings
1517

16-
settings.configure(
17-
DEBUG_PROPAGATE_EXCEPTIONS=True,
18-
DEFAULT_AUTO_FIELD="django.db.models.AutoField",
19-
DATABASES={
18+
if os.getenv('DATABASE_URL'):
19+
databases = {
20+
'default': dj_database_url.config(),
21+
'secondary': dj_database_url.config(),
22+
}
23+
else:
24+
databases = {
2025
'default': {
2126
'ENGINE': 'django.db.backends.sqlite3',
2227
'NAME': ':memory:'
2328
},
2429
'secondary': {
2530
'ENGINE': 'django.db.backends.sqlite3',
2631
'NAME': ':memory:'
27-
}
28-
},
32+
},
33+
}
34+
35+
settings.configure(
36+
DEBUG_PROPAGATE_EXCEPTIONS=True,
37+
DEFAULT_AUTO_FIELD="django.db.models.AutoField",
38+
DATABASES=databases,
2939
SITE_ID=1,
3040
SECRET_KEY='not very secret in tests',
3141
USE_I18N=True,
@@ -65,6 +75,12 @@ def pytest_configure(config):
6575
),
6676
)
6777

78+
# Add django.contrib.postgres when using a PostgreSQL database
79+
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql':
80+
settings.INSTALLED_APPS += (
81+
'django.contrib.postgres',
82+
)
83+
6884
# guardian is optional
6985
try:
7086
import guardian # NOQA
@@ -91,3 +107,13 @@ def pytest_configure(config):
91107

92108
if config.getoption('--staticfiles'):
93109
management.call_command('collectstatic', verbosity=0, interactive=False)
110+
111+
112+
def pytest_collection_modifyitems(config, items):
113+
from django.conf import settings
114+
115+
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.postgresql':
116+
skip_postgres = pytest.mark.skip(reason='Requires PostgreSQL database backend')
117+
for item in items:
118+
if 'requires_postgres' in item.keywords:
119+
item.add_marker(skip_postgres)

tests/test_filters.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,109 @@ class SearchListView(generics.ListAPIView):
291291
]
292292

293293

294+
@pytest.mark.requires_postgres
295+
class SearchFilterFullTextTests(TestCase):
296+
@classmethod
297+
def setUpTestData(cls):
298+
SearchFilterModel.objects.create(title='The quick brown fox', text='jumps over the lazy dog')
299+
SearchFilterModel.objects.create(title='The slow brown turtle', text='crawls under the fence')
300+
SearchFilterModel.objects.create(title='A bright sunny day', text='in the park with friends')
301+
302+
def test_full_text_search_single_term(self):
303+
class SearchListView(generics.ListAPIView):
304+
queryset = SearchFilterModel.objects.all()
305+
serializer_class = SearchFilterSerializer
306+
filter_backends = (filters.SearchFilter,)
307+
search_fields = ('@title',)
308+
309+
view = SearchListView.as_view()
310+
request = factory.get('/', {'search': 'fox'})
311+
response = view(request)
312+
assert len(response.data) == 1
313+
assert response.data[0]['title'] == 'The quick brown fox'
314+
315+
def test_full_text_search_multiple_results(self):
316+
class SearchListView(generics.ListAPIView):
317+
queryset = SearchFilterModel.objects.all()
318+
serializer_class = SearchFilterSerializer
319+
filter_backends = (filters.SearchFilter,)
320+
search_fields = ('@title',)
321+
322+
view = SearchListView.as_view()
323+
request = factory.get('/', {'search': 'brown'})
324+
response = view(request)
325+
assert len(response.data) == 2
326+
titles = {item['title'] for item in response.data}
327+
assert titles == {'The quick brown fox', 'The slow brown turtle'}
328+
329+
def test_full_text_search_no_results(self):
330+
class SearchListView(generics.ListAPIView):
331+
queryset = SearchFilterModel.objects.all()
332+
serializer_class = SearchFilterSerializer
333+
filter_backends = (filters.SearchFilter,)
334+
search_fields = ('@title',)
335+
336+
view = SearchListView.as_view()
337+
request = factory.get('/', {'search': 'elephant'})
338+
response = view(request)
339+
assert len(response.data) == 0
340+
341+
def test_full_text_search_multiple_fields(self):
342+
class SearchListView(generics.ListAPIView):
343+
queryset = SearchFilterModel.objects.all()
344+
serializer_class = SearchFilterSerializer
345+
filter_backends = (filters.SearchFilter,)
346+
search_fields = ('@title', '@text')
347+
348+
view = SearchListView.as_view()
349+
request = factory.get('/', {'search': 'lazy'})
350+
response = view(request)
351+
assert len(response.data) == 1
352+
assert response.data[0]['title'] == 'The quick brown fox'
353+
354+
def test_full_text_search_stemming(self):
355+
"""Full text search should match stemmed words (e.g. 'jumping' matches 'jumps')."""
356+
class SearchListView(generics.ListAPIView):
357+
queryset = SearchFilterModel.objects.all()
358+
serializer_class = SearchFilterSerializer
359+
filter_backends = (filters.SearchFilter,)
360+
search_fields = ('@text',)
361+
362+
view = SearchListView.as_view()
363+
request = factory.get('/', {'search': 'jumping'})
364+
response = view(request)
365+
assert len(response.data) == 1
366+
assert response.data[0]['text'] == 'jumps over the lazy dog'
367+
368+
def test_full_text_search_multiple_terms(self):
369+
"""Each search term must match (AND semantics across terms)."""
370+
class SearchListView(generics.ListAPIView):
371+
queryset = SearchFilterModel.objects.all()
372+
serializer_class = SearchFilterSerializer
373+
filter_backends = (filters.SearchFilter,)
374+
search_fields = ('@title', '@text')
375+
376+
view = SearchListView.as_view()
377+
request = factory.get('/', {'search': 'brown lazy'})
378+
response = view(request)
379+
assert len(response.data) == 1
380+
assert response.data[0]['title'] == 'The quick brown fox'
381+
382+
def test_full_text_search_mixed_with_icontains(self):
383+
"""Full text search fields can be mixed with regular icontains fields."""
384+
class SearchListView(generics.ListAPIView):
385+
queryset = SearchFilterModel.objects.all()
386+
serializer_class = SearchFilterSerializer
387+
filter_backends = (filters.SearchFilter,)
388+
search_fields = ('@title', 'text')
389+
390+
view = SearchListView.as_view()
391+
request = factory.get('/', {'search': 'park'})
392+
response = view(request)
393+
assert len(response.data) == 1
394+
assert response.data[0]['title'] == 'A bright sunny day'
395+
396+
294397
class AttributeModel(models.Model):
295398
label = models.CharField(max_length=32)
296399

@@ -339,6 +442,10 @@ def test_custom_lookup_to_related_model(self):
339442
assert 'attribute__label__icontains' == filter_.construct_search('attribute__label', SearchFilterModelFk._meta)
340443
assert 'attribute__label__iendswith' == filter_.construct_search('attribute__label__iendswith', SearchFilterModelFk._meta)
341444

445+
def test_construct_search_with_at_prefix(self):
446+
filter_ = filters.SearchFilter()
447+
assert 'title__search' == filter_.construct_search('@title', SearchFilterModelFk._meta)
448+
342449

343450
class SearchFilterModelM2M(models.Model):
344451
title = models.CharField(max_length=20)

0 commit comments

Comments
 (0)