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)