441 lines
17 KiB
Python
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"])
|