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

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="[email protected]",
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)