- Configure Wagtail database search backend with English search config - Add django.contrib.postgres to INSTALLED_APPS for full PG FTS support - Expand ArticlePage.search_fields: body_text (excl. code blocks), AutocompleteField(title), RelatedFields(tags), FilterFields - Add search view at /search/?q= with query guards (strip, max 200 chars, empty/whitespace handling) and pagination preserving query param - Replace nav Subscribe CTA with compact search box (desktop + mobile) - Add search box to article index page alongside category/tag filters - Create search results template reusing article_card component - Add update_index to deploy entrypoint for automated reindexing - Update existing tests for nav change, add comprehensive search tests Closes #41 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
141 lines
5.2 KiB
Python
141 lines
5.2 KiB
Python
import pytest
|
|
|
|
from apps.blog.models import ArticleIndexPage, ArticlePage
|
|
from apps.blog.tests.factories import AuthorFactory
|
|
from apps.blog.views import MAX_QUERY_LENGTH
|
|
|
|
|
|
@pytest.fixture
|
|
def search_articles(home_page):
|
|
"""Create an article index with searchable articles."""
|
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
|
home_page.add_child(instance=index)
|
|
author = AuthorFactory()
|
|
articles = []
|
|
for title, summary in [
|
|
("Understanding LLM Benchmarks", "A deep dive into how language models are evaluated"),
|
|
("Local Models on Apple Silicon", "Running open-source models on your MacBook"),
|
|
("Agent Frameworks Compared", "Comparing LangChain, CrewAI, and AutoGen"),
|
|
]:
|
|
a = ArticlePage(
|
|
title=title,
|
|
slug=title.lower().replace(" ", "-"),
|
|
author=author,
|
|
summary=summary,
|
|
body=[("rich_text", f"<p>{summary} in detail.</p>")],
|
|
)
|
|
index.add_child(instance=a)
|
|
a.save_revision().publish()
|
|
articles.append(a)
|
|
return articles
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestSearchView:
|
|
def test_empty_query_returns_no_results(self, client, home_page):
|
|
resp = client.get("/search/")
|
|
assert resp.status_code == 200
|
|
assert resp.context["query"] == ""
|
|
assert resp.context["results"] is None
|
|
|
|
def test_whitespace_query_returns_no_results(self, client, home_page):
|
|
resp = client.get("/search/?q= ")
|
|
assert resp.status_code == 200
|
|
assert resp.context["query"] == ""
|
|
assert resp.context["results"] is None
|
|
|
|
def test_search_returns_matching_articles(self, client, search_articles):
|
|
resp = client.get("/search/?q=benchmarks")
|
|
assert resp.status_code == 200
|
|
assert resp.context["query"] == "benchmarks"
|
|
assert resp.context["results"] is not None
|
|
|
|
def test_search_no_match_returns_empty_page(self, client, search_articles):
|
|
resp = client.get("/search/?q=zzzznonexistent")
|
|
assert resp.status_code == 200
|
|
assert resp.context["query"] == "zzzznonexistent"
|
|
# Either None or empty page object
|
|
results = resp.context["results"]
|
|
if results is not None:
|
|
assert len(list(results)) == 0
|
|
|
|
def test_query_is_truncated_to_max_length(self, client, home_page):
|
|
long_query = "a" * 500
|
|
resp = client.get(f"/search/?q={long_query}")
|
|
assert resp.status_code == 200
|
|
assert len(resp.context["query"]) <= MAX_QUERY_LENGTH
|
|
|
|
def test_query_preserved_in_template(self, client, search_articles):
|
|
resp = client.get("/search/?q=LLM")
|
|
html = resp.content.decode()
|
|
assert 'value="LLM"' in html
|
|
|
|
def test_search_results_page_renders(self, client, search_articles):
|
|
resp = client.get("/search/?q=models")
|
|
assert resp.status_code == 200
|
|
html = resp.content.decode()
|
|
assert "Search" in html
|
|
|
|
def test_search_url_resolves(self, client, home_page):
|
|
from django.urls import reverse
|
|
assert reverse("search") == "/search/"
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestSearchFields:
|
|
def test_search_fields_include_summary(self):
|
|
field_names = [
|
|
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
|
|
]
|
|
assert "summary" in field_names
|
|
|
|
def test_search_fields_include_body_text(self):
|
|
field_names = [
|
|
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
|
|
]
|
|
assert "body_text" in field_names
|
|
|
|
def test_search_fields_include_autocomplete_title(self):
|
|
from wagtail.search.index import AutocompleteField
|
|
autocomplete_fields = [
|
|
f for f in ArticlePage.search_fields if isinstance(f, AutocompleteField)
|
|
]
|
|
assert any(f.field_name == "title" for f in autocomplete_fields)
|
|
|
|
def test_search_fields_include_related_tags(self):
|
|
from wagtail.search.index import RelatedFields
|
|
related = [f for f in ArticlePage.search_fields if isinstance(f, RelatedFields)]
|
|
assert any(f.field_name == "tags" for f in related)
|
|
|
|
def test_body_text_excludes_code_blocks(self):
|
|
author = AuthorFactory()
|
|
article = ArticlePage(
|
|
title="Test",
|
|
slug="test",
|
|
author=author,
|
|
summary="summary",
|
|
body=[
|
|
("rich_text", "<p>prose content here</p>"),
|
|
("code", {"language": "python", "filename": "", "raw_code": "def secret(): pass"}),
|
|
],
|
|
)
|
|
assert "prose content here" in article.body_text
|
|
assert "secret" not in article.body_text
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestSearchNavIntegration:
|
|
def test_nav_contains_search_form(self, client, home_page):
|
|
resp = client.get("/")
|
|
html = resp.content.decode()
|
|
assert 'role="search"' in html
|
|
assert 'name="q"' in html
|
|
assert 'placeholder="Search articles..."' in html
|
|
|
|
def test_article_index_contains_search_form(self, client, home_page):
|
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
|
home_page.add_child(instance=index)
|
|
resp = client.get("/articles/")
|
|
html = resp.content.decode()
|
|
assert 'name="q"' in html
|