relay/app/search/tests.py
2025-11-05 15:03:20 +01:00

441 lines
17 KiB
Python

from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework import status
from app.feeds.models import Profile, Post
class SearchViewTest(TestCase):
"""Test cases for the SearchView API using Given/When/Then structure."""
def setUp(self):
self.client = APIClient()
self.search_url = "/search/"
# Create test profiles
self.profile1 = Profile.objects.create(
feed="https://example.com/social.org",
title="Example Profile",
nick="example_user",
description="Test profile 1",
)
self.profile2 = Profile.objects.create(
feed="https://test.com/social.org",
title="Test Profile",
nick="test_user",
description="Test profile 2",
)
self.profile3 = Profile.objects.create(
feed="https://third.com/social.org",
title="Third Profile",
nick="third_user",
description="Test profile 3",
)
# Create test posts with different content and tags
self.post1 = Post.objects.create(
profile=self.profile1,
post_id="2025-01-01T12:00:00+00:00",
content="This post is about Emacs and org-mode",
tags="emacs org-mode",
)
self.post2 = Post.objects.create(
profile=self.profile2,
post_id="2025-01-01T13:00:00+00:00",
content="Learning Python programming language",
tags="python programming",
)
self.post3 = Post.objects.create(
profile=self.profile3,
post_id="2025-01-01T14:00:00+00:00",
content="Django web framework with Python",
tags="django python web",
)
self.post4 = Post.objects.create(
profile=self.profile1,
post_id="2025-01-01T15:00:00+00:00",
content="Emacs configuration and setup",
tags="emacs configuration",
)
self.post5 = Post.objects.create(
profile=self.profile2,
post_id="2025-01-01T16:00:00+00:00",
content="JavaScript and React development",
tags="javascript react frontend",
)
# Add more posts for pagination testing
self.post6 = Post.objects.create(
profile=self.profile3,
post_id="2025-01-01T17:00:00+00:00",
content="Advanced Python concepts and patterns",
tags="python advanced",
)
self.post7 = Post.objects.create(
profile=self.profile1,
post_id="2025-01-01T18:00:00+00:00",
content="Python data science libraries",
tags="python data-science",
)
def test_search_by_text_query_success(self):
"""Test GET /search/?q=<query> returns matching posts."""
# Given: Posts with content containing "Emacs"
# When: We search for "Emacs"
response = self.client.get(self.search_url, {"q": "Emacs"})
# Then: We should get posts containing "Emacs"
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["type"], "Success")
self.assertEqual(response.data["errors"], [])
# Then: Should return 2 posts (ordered by most recent first)
data = response.data["data"]
self.assertEqual(len(data), 2)
expected_urls = [
f"{self.profile1.feed}#{self.post4.post_id}", # Most recent
f"{self.profile1.feed}#{self.post1.post_id}",
]
self.assertEqual(data, expected_urls)
# Then: Meta should contain correct information
meta = response.data["meta"]
self.assertEqual(meta["query"], "Emacs")
self.assertEqual(meta["total"], 2)
self.assertEqual(meta["page"], 1)
self.assertEqual(meta["perPage"], 10)
self.assertFalse(meta["hasNext"])
self.assertFalse(meta["hasPrevious"])
# Then: Should have ETag and Last-Modified headers
self.assertIn("ETag", response)
self.assertIn("Last-Modified", response)
def test_search_by_tag_success(self):
"""Test GET /search/?tag=<tag> returns posts with specific tag."""
# Given: Posts with "python" tag
# When: We search for tag "python"
response = self.client.get(self.search_url, {"tag": "python"})
# Then: We should get posts tagged with "python"
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["type"], "Success")
self.assertEqual(response.data["errors"], [])
# Then: Should return 4 posts (all posts with python tag)
data = response.data["data"]
self.assertEqual(len(data), 4)
# Check that all returned posts contain python-related content
expected_posts = [
self.post7,
self.post6,
self.post3,
self.post2,
] # Most recent first
expected_urls = [
f"{post.profile.feed}#{post.post_id}" for post in expected_posts
]
self.assertEqual(data, expected_urls)
# Then: Meta should contain tag information
meta = response.data["meta"]
self.assertEqual(meta["tag"], "python")
self.assertEqual(meta["total"], 4)
def test_search_pagination(self):
"""Test search pagination works correctly."""
# Given: Multiple posts and small page size
# When: We search with perPage=2 and page=1 for content that appears in multiple posts
response = self.client.get(
self.search_url, {"q": "Python", "perPage": 2, "page": 1}
)
# Then: We should get first 2 results
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data["data"]
self.assertEqual(len(data), 2)
# Then: Meta should show pagination info
meta = response.data["meta"]
self.assertEqual(meta["page"], 1)
self.assertEqual(meta["perPage"], 2)
self.assertTrue(meta["hasNext"])
self.assertFalse(meta["hasPrevious"])
# Check _links instead of meta.links
links = response.data["_links"]
self.assertIsNotNone(links["next"])
self.assertIsNone(links["previous"])
# When: We request page 2
response2 = self.client.get(
self.search_url, {"q": "Python", "perPage": 2, "page": 2}
)
# Then: We should get next 2 results
self.assertEqual(response2.status_code, status.HTTP_200_OK)
data2 = response2.data["data"]
self.assertEqual(len(data2), 2)
# Then: Results should be different from page 1
self.assertNotEqual(data, data2)
def test_search_case_insensitive(self):
"""Test that search is case insensitive."""
# Given: Posts with mixed case content
# When: We search with lowercase
response1 = self.client.get(self.search_url, {"q": "emacs"})
# When: We search with uppercase
response2 = self.client.get(self.search_url, {"q": "EMACS"})
# Then: Both should return same results
self.assertEqual(response1.status_code, status.HTTP_200_OK)
self.assertEqual(response2.status_code, status.HTTP_200_OK)
self.assertEqual(response1.data["data"], response2.data["data"])
def test_search_no_results(self):
"""Test search with no matching results."""
# Given: Search term that doesn't exist
# When: We search for non-existent term
response = self.client.get(self.search_url, {"q": "nonexistent"})
# Then: We should get empty results
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["type"], "Success")
self.assertEqual(response.data["data"], [])
self.assertEqual(response.data["meta"]["total"], 0)
def test_search_missing_parameters(self):
"""Test that missing search parameters return 400 error."""
# Given: Search endpoint
# When: We request without q or tag parameters
response = self.client.get(self.search_url)
# Then: Should return 400 error
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data["type"], "Error")
self.assertIn("required", response.data["errors"][0])
def test_search_invalid_page_number(self):
"""Test invalid page numbers return appropriate errors."""
# Given: Search with valid query
# When: We request page 0
response = self.client.get(self.search_url, {"q": "test", "page": 0})
# Then: Should return 400 error
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data["type"], "Error")
self.assertIn("Page number must be 1 or greater", response.data["errors"][0])
# When: We request page beyond available results
response2 = self.client.get(self.search_url, {"q": "emacs", "page": 999})
# Then: Should return 400 error
self.assertEqual(response2.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response2.data["type"], "Error")
self.assertIn("does not exist", response2.data["errors"][0])
def test_search_per_page_limits(self):
"""Test perPage parameter respects maximum limit."""
# Given: Search with large perPage value
# When: We request more than 50 items per page
response = self.client.get(self.search_url, {"q": "post", "perPage": 100})
# Then: Should be limited to 50
self.assertEqual(response.status_code, status.HTTP_200_OK)
meta = response.data["meta"]
self.assertEqual(meta["perPage"], 50)
def test_search_response_format_compliance(self):
"""Test search response format compliance with README specification."""
# Given: Search with valid parameters
# When: We perform a search
response = self.client.get(self.search_url, {"q": "emacs"})
# Then: Response should match expected format
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("type", response.data)
self.assertIn("errors", response.data)
self.assertIn("data", response.data)
self.assertIn("meta", response.data)
self.assertEqual(response.data["type"], "Success")
self.assertIsInstance(response.data["errors"], list)
self.assertIsInstance(response.data["data"], list)
self.assertIsInstance(response.data["meta"], dict)
# Then: Meta should contain all required fields
meta = response.data["meta"]
required_meta_fields = [
"query",
"total",
"page",
"perPage",
"hasNext",
"hasPrevious",
]
for field in required_meta_fields:
self.assertIn(field, meta)
# Then: Should have ETag and Last-Modified headers
self.assertIn("ETag", response)
self.assertIn("Last-Modified", response)
# Check that _links exists
self.assertIn("_links", response.data)
links = response.data["_links"]
self.assertIn("self", links)
def test_search_view_methods_allowed(self):
"""Test that only GET method is allowed on search endpoint."""
# Given: A valid search URL
params = {"q": "test"}
# When: We try different HTTP methods
post_response = self.client.post(self.search_url, params)
put_response = self.client.put(self.search_url, params)
delete_response = self.client.delete(self.search_url, params)
patch_response = self.client.patch(self.search_url, params)
# Then: Unsupported methods should return 405
self.assertEqual(post_response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
self.assertEqual(put_response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
self.assertEqual(
delete_response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED
)
self.assertEqual(patch_response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
def test_search_url_generation(self):
"""Test that pagination URLs are generated correctly."""
# Given: Search with pagination
# When: We search with specific parameters
response = self.client.get(
self.search_url, {"q": "post", "perPage": 2, "page": 1}
)
# Then: Links should be properly formatted
links = response.data["_links"]
if links["next"]:
self.assertIn("q=post", links["next"]["href"])
self.assertIn("perPage=2", links["next"]["href"])
self.assertIn("page=2", links["next"]["href"])
def test_search_both_content_and_tags(self):
"""Test that search looks in both content and tags fields."""
# Given: Post with "emacs" in content and "configuration" in tags
# When: We search for "configuration"
response = self.client.get(self.search_url, {"q": "configuration"})
# Then: Should find post4 which has "configuration" in tags
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data["data"]
expected_url = f"{self.profile1.feed}#{self.post4.post_id}"
self.assertIn(expected_url, data)
def test_search_tag_vs_query_difference(self):
"""Test difference between tag search and text query search."""
# Given: Posts with tags
# When: We search by tag "emacs"
tag_response = self.client.get(self.search_url, {"tag": "emacs"})
# When: We search by query "emacs"
query_response = self.client.get(self.search_url, {"q": "emacs"})
# Then: Both should return results but meta should be different
self.assertEqual(tag_response.status_code, status.HTTP_200_OK)
self.assertEqual(query_response.status_code, status.HTTP_200_OK)
# Then: Meta should indicate different search types
self.assertIn("tag", tag_response.data["meta"])
self.assertIn("query", query_response.data["meta"])
def test_search_tag_exact_word_match(self):
"""Test that tag search matches exact words only, not substrings."""
# Given: Posts with tags that contain similar strings
post_emacs = Post.objects.create(
profile=self.profile1,
post_id="2025-01-02T10:00:00+00:00",
content="Post about emacs",
tags="emacs",
)
post_emacs_org = Post.objects.create(
profile=self.profile1,
post_id="2025-01-02T10:01:00+00:00",
content="Post about emacs and org-mode",
tags="emacs org-mode",
)
post_emacs_lisp = Post.objects.create(
profile=self.profile1,
post_id="2025-01-02T10:02:00+00:00",
content="Post about emacs-lisp",
tags="emacs-lisp",
)
post_notemacs = Post.objects.create(
profile=self.profile1,
post_id="2025-01-02T10:03:00+00:00",
content="Post with notemacs tag",
tags="notemacs",
)
post_python = Post.objects.create(
profile=self.profile1,
post_id="2025-01-02T10:04:00+00:00",
content="Post about python",
tags="python",
)
# When: We search for tag "emacs"
response = self.client.get(self.search_url, {"tag": "emacs"})
# Then: Should return only posts with "emacs" as complete word
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data["data"]
# Should include posts with "emacs" as complete word
self.assertIn(f"{post_emacs.profile.feed}#{post_emacs.post_id}", data)
self.assertIn(f"{post_emacs_org.profile.feed}#{post_emacs_org.post_id}", data)
# Should NOT include posts where "emacs" is part of another word
self.assertNotIn(
f"{post_emacs_lisp.profile.feed}#{post_emacs_lisp.post_id}", data
)
self.assertNotIn(f"{post_notemacs.profile.feed}#{post_notemacs.post_id}", data)
self.assertNotIn(f"{post_python.profile.feed}#{post_python.post_id}", data)
def test_search_tag_case_insensitive(self):
"""Test that tag search is case insensitive."""
# Given: Post with lowercase tag
post = Post.objects.create(
profile=self.profile1,
post_id="2025-01-02T11:00:00+00:00",
content="Test post",
tags="python django",
)
# When: We search with different case variations
response_lower = self.client.get(self.search_url, {"tag": "python"})
response_upper = self.client.get(self.search_url, {"tag": "PYTHON"})
response_mixed = self.client.get(self.search_url, {"tag": "PyThOn"})
# Then: All should find the post
expected_url = f"{post.profile.feed}#{post.post_id}"
self.assertIn(expected_url, response_lower.data["data"])
self.assertIn(expected_url, response_upper.data["data"])
self.assertIn(expected_url, response_mixed.data["data"])