"""High-value edge case tests for the domain_classifier module. Covers gaps not addressed by existing tests: - DOMAIN_CATEGORIES structure validation - DomainClassification model to_dict - _get_domain_samples with None content_preview - _build_classification_prompt sample truncation - classify_all_domains URL parsing and www stripping """ from unittest.mock import MagicMock, patch from local_deep_research.domain_classifier.classifier import ( DomainClassifier, DOMAIN_CATEGORIES, ) from local_deep_research.domain_classifier.models import DomainClassification class TestDomainCategoriesStructure: """Validate the DOMAIN_CATEGORIES constant.""" def test_has_ten_main_categories(self): """There should be exactly 10 main categories.""" assert len(DOMAIN_CATEGORIES) == 10 def test_no_subcategory_overlap_across_categories(self): """No subcategory name appears in more than one main category.""" all_subcats = [] for subcats in DOMAIN_CATEGORIES.values(): all_subcats.extend(subcats) # Check for duplicates assert len(all_subcats) == len(set(all_subcats)) def test_other_category_has_unknown(self): """The 'Other' category contains 'Unknown' subcategory.""" assert "Unknown" in DOMAIN_CATEGORIES["Other"] def test_all_values_are_lists_of_strings(self): """All category values are lists of non-empty strings.""" for cat, subcats in DOMAIN_CATEGORIES.items(): assert isinstance(subcats, list), f"{cat} subcategories not a list" for s in subcats: assert isinstance(s, str) and len(s) > 0 class TestDomainClassificationModel: """Test the DomainClassification SQLAlchemy model.""" def test_tablename(self): """Model uses the expected table name.""" assert DomainClassification.__tablename__ == "domain_classifications" def test_to_dict_has_expected_keys(self): """to_dict returns all expected keys.""" obj = DomainClassification( id=1, domain="example.com", category="Technology", subcategory="Software Development", confidence=0.9, reasoning="test", sample_titles='["Title"]', sample_count=1, ) d = obj.to_dict() expected_keys = { "id", "domain", "category", "subcategory", "confidence", "reasoning", "sample_titles", "sample_count", "created_at", "updated_at", } assert set(d.keys()) == expected_keys def test_to_dict_none_timestamps(self): """to_dict handles None timestamps gracefully.""" obj = DomainClassification( id=1, domain="test.com", category="Other", ) obj.created_at = None obj.updated_at = None d = obj.to_dict() assert d["created_at"] is None assert d["updated_at"] is None class TestGetDomainSamples: """Test _get_domain_samples method.""" @patch( "local_deep_research.domain_classifier.classifier.get_user_db_session" ) def test_none_content_preview_returns_none_in_sample( self, mock_session_ctx ): """When content_preview is None, sample preview is None.""" classifier = DomainClassifier(username="test") mock_session = MagicMock() mock_resource = MagicMock() mock_resource.title = "Test Title" mock_resource.url = "http://example.com/page" mock_resource.content_preview = None mock_session.query.return_value.filter.return_value.limit.return_value.all.return_value = [ mock_resource ] samples = classifier._get_domain_samples("example.com", mock_session) assert len(samples) == 1 assert samples[0]["title"] == "Test Title" assert samples[0]["preview"] is None @patch( "local_deep_research.domain_classifier.classifier.get_user_db_session" ) def test_empty_resources_returns_empty_list(self, mock_session_ctx): """When no resources match, returns empty list.""" classifier = DomainClassifier(username="test") mock_session = MagicMock() mock_session.query.return_value.filter.return_value.limit.return_value.all.return_value = [] samples = classifier._get_domain_samples("no-match.com", mock_session) assert samples == [] @patch( "local_deep_research.domain_classifier.classifier.get_user_db_session" ) def test_none_title_becomes_untitled(self, mock_session_ctx): """When resource.title is None, it becomes 'Untitled'.""" classifier = DomainClassifier(username="test") mock_session = MagicMock() mock_resource = MagicMock() mock_resource.title = None mock_resource.url = "http://example.com" mock_resource.content_preview = "some preview" mock_session.query.return_value.filter.return_value.limit.return_value.all.return_value = [ mock_resource ] samples = classifier._get_domain_samples("example.com", mock_session) assert samples[0]["title"] == "Untitled" @patch( "local_deep_research.domain_classifier.classifier.get_user_db_session" ) def test_preview_truncated_to_200_chars(self, mock_session_ctx): """content_preview is truncated to 200 characters.""" classifier = DomainClassifier(username="test") mock_session = MagicMock() mock_resource = MagicMock() mock_resource.title = "Title" mock_resource.url = "http://example.com" mock_resource.content_preview = "x" * 500 mock_session.query.return_value.filter.return_value.limit.return_value.all.return_value = [ mock_resource ] samples = classifier._get_domain_samples("example.com", mock_session) assert len(samples[0]["preview"]) == 200 class TestGetDomainSamplesEscaping: """Integration tests: LIKE wildcards in the domain must be literal. Uses a real in-memory SQLite session so SQLite itself evaluates the ``LIKE ... ESCAPE`` pattern. The other TestGetDomainSamples tests mock the session, so they pass regardless of escaping; these prove the escape path actually works. Regression coverage for the security fix in PR #3094 (classifier branch), which the mocked tests do not enforce. """ @staticmethod def _session_with(resources): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from local_deep_research.database.models import ResearchResource from local_deep_research.database.models.research import Base engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) session = sessionmaker(bind=engine)() # This bare engine has no foreign_keys PRAGMA (the app enables it # per-engine in encrypted_db/auth_db, not globally), so the orphan # research_id values below commit without a real research_history row. session.add_all( ResearchResource( research_id=f"r{i}", url=url, title=f"resource {i}", created_at="2026-01-01T00:00:00", ) for i, url in enumerate(resources) ) session.commit() return session def test_percent_treated_as_literal(self): """Sampling domain '100%.com' must not pull '100pages.com' rows.""" session = self._session_with( ["https://100%.com/paper", "https://100pages.com/article"] ) classifier = DomainClassifier(username="test") urls = [ s["url"] for s in classifier._get_domain_samples("100%.com", session) ] assert "https://100%.com/paper" in urls assert "https://100pages.com/article" not in urls def test_underscore_treated_as_literal(self): """Sampling domain 's3_bucket.example.com' must not pull 's3Xbucket…'.""" session = self._session_with( [ "https://s3_bucket.example.com/a", "https://s3Xbucket.example.com/b", ] ) classifier = DomainClassifier(username="test") urls = [ s["url"] for s in classifier._get_domain_samples( "s3_bucket.example.com", session ) ] assert "https://s3_bucket.example.com/a" in urls assert "https://s3Xbucket.example.com/b" not in urls class TestBuildClassificationPromptEdgeCases: """Test _build_classification_prompt edge cases.""" def test_samples_without_preview_omit_preview_line(self): """Samples with no preview don't include a Preview line.""" classifier = DomainClassifier(username="test") samples = [ {"title": "Test", "url": "http://example.com", "preview": None} ] prompt = classifier._build_classification_prompt("example.com", samples) assert "Preview:" not in prompt def test_only_first_five_samples_included(self): """Only the first 5 samples are included in the prompt.""" classifier = DomainClassifier(username="test") samples = [ { "title": f"Title {i}", "url": f"http://example.com/{i}", "preview": f"preview {i}", } for i in range(10) ] prompt = classifier._build_classification_prompt("example.com", samples) assert "Title 4" in prompt assert ( "Title 5" not in prompt ) # 0-indexed: 5th sample (index 4) is last def test_preview_truncated_at_100_chars(self): """Sample previews in prompt are truncated to 100 chars.""" classifier = DomainClassifier(username="test") long_preview = "a" * 200 samples = [ {"title": "T", "url": "http://ex.com", "preview": long_preview} ] prompt = classifier._build_classification_prompt("example.com", samples) # The prompt should contain the truncated preview followed by "..." assert "a" * 100 + "..." in prompt def test_all_main_categories_in_prompt(self): """All main category names appear in the prompt.""" classifier = DomainClassifier(username="test") prompt = classifier._build_classification_prompt("test.com", []) for category in DOMAIN_CATEGORIES.keys(): assert category in prompt