You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
215 lines
9.4 KiB
215 lines
9.4 KiB
import datetime
|
|
from django.test import TestCase
|
|
from django.contrib.auth import get_user_model
|
|
from rest_framework.test import APIClient
|
|
from rest_framework import status
|
|
|
|
from apps.profiles.models import Institution
|
|
from apps.projects.models import Project
|
|
from apps.lms.models import Course, CourseCategory, CourseLevel, CourseLanguage
|
|
from apps.cms.models import Post, PostCategory
|
|
from apps.events.models import Event, EventCategory, LocationType
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class Phase10UnifiedSearchAPITests(TestCase):
|
|
"""
|
|
Automated test suite for Phase 10: Unified Multi-Entity Search & Aggregator Engine
|
|
- Multi-entity natural search across Institutions, Projects, LMS Courses, CMS Posts, and Events
|
|
- Type filtering (?type=institution,project,course,post,event)
|
|
- Multilingual keywords (Persian, Arabic, English)
|
|
- Relevance sorting
|
|
- Visibility rules (Draft posts, unpublished courses, inactive events excluded)
|
|
- Pagination (limit, offset)
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.client = APIClient()
|
|
|
|
self.user = User.objects.create_user(
|
|
email="author.search@astan.org",
|
|
password="Password123!",
|
|
fullname="Search Test Author",
|
|
)
|
|
|
|
# 1. Institutions
|
|
self.inst_tehran = Institution.objects.create(
|
|
name="Tehran Islamic Knowledge Center",
|
|
country="Iran",
|
|
city="Tehran",
|
|
type=Institution.InstitutionType.INSTITUTE,
|
|
description="Leading center for Islamic diplomacy and research.",
|
|
tags=["diplomacy", "research", "tehran"],
|
|
verification_status=Institution.VerificationStatus.APPROVED,
|
|
)
|
|
self.inst_london = Institution.objects.create(
|
|
name="London Ahlulbayt Mosque",
|
|
country="United Kingdom",
|
|
city="London",
|
|
type=Institution.InstitutionType.MOSQUE,
|
|
description="Community mosque and interfaith dialogue hub.",
|
|
tags=["interfaith", "london", "community"],
|
|
verification_status=Institution.VerificationStatus.APPROVED,
|
|
)
|
|
|
|
# 2. Collaborative Projects
|
|
self.proj_manuscript = Project.objects.create(
|
|
title="Islamic Manuscript Digital Preservation",
|
|
owner_institution=self.inst_tehran,
|
|
category="Cultural Diplomacy",
|
|
description="Digitizing ancient Islamic texts and theological manuscripts.",
|
|
status=Project.Status.ACTIVE,
|
|
progress_percentage=45,
|
|
)
|
|
self.proj_youth = Project.objects.create(
|
|
title="European Muslim Youth Leadership Initiative",
|
|
owner_institution=self.inst_london,
|
|
category="Youth Empowerment",
|
|
description="Empowering young community leaders across Europe.",
|
|
status=Project.Status.ACTIVE,
|
|
progress_percentage=60,
|
|
)
|
|
|
|
# 3. LMS Courses (1 published, 1 unpublished draft)
|
|
self.course_diplomacy = Course.objects.create(
|
|
title="Islamic Cultural Diplomacy & Negotiation",
|
|
category=CourseCategory.CULTURAL_DIPLOMACY,
|
|
language=CourseLanguage.EN,
|
|
level=CourseLevel.INTERMEDIATE,
|
|
instructor_name="Dr. Mohammad Razavi",
|
|
organization_name="Imam Reza International University",
|
|
description="Advanced skills for international dialogue and cultural diplomacy.",
|
|
is_published=True,
|
|
)
|
|
self.course_draft = Course.objects.create(
|
|
title="Secret Draft Course on Diplomacy",
|
|
category=CourseCategory.RAZAVI_TEACHINGS,
|
|
language=CourseLanguage.EN,
|
|
level=CourseLevel.INTRODUCTORY,
|
|
instructor_name="Dr. Hidden",
|
|
description="Not yet published to students.",
|
|
is_published=False,
|
|
)
|
|
|
|
# 4. CMS Posts (1 published, 1 draft)
|
|
self.cat_news = PostCategory.objects.create(name="International News", language="en")
|
|
self.post_published = Post.objects.create(
|
|
title="Imam Reza Shrine Hosts International Delegation from London",
|
|
category=self.cat_news,
|
|
language="en",
|
|
status=Post.Status.PUBLISHED,
|
|
author=self.user,
|
|
institution=self.inst_tehran,
|
|
excerpt="High-level delegation explores new avenues for educational partnerships.",
|
|
content="Representatives from prominent institutions visited the sacred shrine in Mashhad.",
|
|
)
|
|
self.post_draft = Post.objects.create(
|
|
title="Draft Article on Future Diplomacy Summit",
|
|
category=self.cat_news,
|
|
language="en",
|
|
status=Post.Status.DRAFT,
|
|
author=self.user,
|
|
excerpt="Unpublished internal draft regarding summits.",
|
|
content="Draft notes.",
|
|
)
|
|
|
|
# 5. Events (1 active, 1 inactive)
|
|
self.event_active = Event.objects.create(
|
|
title="Global Interfaith Dialogue Conference 2026",
|
|
description="A comprehensive conference discussing interfaith diplomacy and coexistence.",
|
|
organizer=self.inst_london,
|
|
created_by=self.user,
|
|
category=EventCategory.CONFERENCE,
|
|
event_date=datetime.date(2026, 10, 15),
|
|
start_time=datetime.time(10, 0),
|
|
speaker_name="Sheikh Jawad Londoni",
|
|
location_type=LocationType.HYBRID,
|
|
venue_address="Ahlulbayt Cultural Center, London",
|
|
is_active=True,
|
|
)
|
|
self.event_inactive = Event.objects.create(
|
|
title="Cancelled Dialogue Workshop",
|
|
description="This event was cancelled.",
|
|
organizer=self.inst_tehran,
|
|
created_by=self.user,
|
|
category=EventCategory.WORKSHOP,
|
|
event_date=datetime.date(2026, 9, 1),
|
|
start_time=datetime.time(14, 0),
|
|
is_active=False,
|
|
)
|
|
|
|
def test_multi_entity_search_keyword_diplomacy(self):
|
|
"""Querying 'diplomacy' should find institutions, projects, courses, posts, and events."""
|
|
response = self.client.get("/api/v1/search/?q=diplomacy")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
data = response.data
|
|
|
|
self.assertEqual(data["query"], "diplomacy")
|
|
self.assertGreater(data["total_results"], 0)
|
|
|
|
# Check by_type aggregation breakdown
|
|
self.assertIn("institutions", data["by_type"])
|
|
self.assertIn("projects", data["by_type"])
|
|
self.assertIn("courses", data["by_type"])
|
|
self.assertIn("posts", data["by_type"])
|
|
self.assertIn("events", data["by_type"])
|
|
|
|
# Check returned results contain correct structure
|
|
for item in data["results"]:
|
|
self.assertIn("id", item)
|
|
self.assertIn("type", item)
|
|
self.assertIn("title", item)
|
|
self.assertIn("url_path", item)
|
|
self.assertIn("relevance_score", item)
|
|
|
|
def test_type_filter_institutions_only(self):
|
|
"""Filtering by ?type=institution should only return institution matches."""
|
|
response = self.client.get("/api/v1/search/?q=London&type=institution")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
data = response.data
|
|
|
|
self.assertEqual(data["total_results"], 1)
|
|
self.assertEqual(data["results"][0]["type"], "institution")
|
|
self.assertEqual(data["results"][0]["title"], "London Ahlulbayt Mosque")
|
|
|
|
def test_type_filter_multiple_types(self):
|
|
"""Filtering by ?type=course,event should only return course and event matches."""
|
|
response = self.client.get("/api/v1/search/?q=Dialogue&type=course,event")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
data = response.data
|
|
|
|
types_found = {item["type"] for item in data["results"]}
|
|
self.assertTrue(types_found.issubset({"course", "event"}))
|
|
|
|
def test_visibility_rules_draft_and_inactive_excluded(self):
|
|
"""Draft courses, draft posts, and inactive events should never be returned."""
|
|
response = self.client.get("/api/v1/search/?q=Draft")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
data = response.data
|
|
|
|
titles_found = [item["title"] for item in data["results"]]
|
|
self.assertNotIn("Secret Draft Course on Diplomacy", titles_found)
|
|
self.assertNotIn("Draft Article on Future Diplomacy Summit", titles_found)
|
|
|
|
response_cancelled = self.client.get("/api/v1/search/?q=Cancelled")
|
|
titles_cancelled = [item["title"] for item in response_cancelled.data["results"]]
|
|
self.assertNotIn("Cancelled Dialogue Workshop", titles_cancelled)
|
|
|
|
def test_pagination_limit_and_offset(self):
|
|
"""Test limit and offset pagination parameters."""
|
|
response_all = self.client.get("/api/v1/search/?limit=100")
|
|
total = response_all.data["total_results"]
|
|
|
|
response_limit = self.client.get("/api/v1/search/?limit=2&offset=0")
|
|
self.assertEqual(len(response_limit.data["results"]), 2)
|
|
self.assertEqual(response_limit.data["total_results"], total)
|
|
|
|
response_offset = self.client.get("/api/v1/search/?limit=2&offset=2")
|
|
self.assertEqual(len(response_offset.data["results"]), min(2, max(0, total - 2)))
|
|
|
|
def test_empty_query_returns_all_active_entities(self):
|
|
"""Empty query returns active entities up to limit."""
|
|
response = self.client.get("/api/v1/search/")
|
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
self.assertGreater(response.data["total_results"], 0)
|