""" Tests for research_service helper functions. Tests cover: - _parse_research_metadata: dict/JSON/invalid input handling - get_citation_formatter: citation mode mapping from settings - export_report_to_memory: exporter registry integration """ from unittest.mock import MagicMock, patch import pytest from local_deep_research.web.services.research_service import ( _parse_research_metadata, export_report_to_memory, get_citation_formatter, ) # --------------------------------------------------------------------------- # _parse_research_metadata # --------------------------------------------------------------------------- class TestParseResearchMetadata: """Tests for _parse_research_metadata helper.""" def test_returns_dict_as_is(self): """A plain dict input is returned unchanged (as a copy).""" original = {"key": "value", "num": 42} result = _parse_research_metadata(original) assert result == original # Should be a copy, not the same object assert result is not original def test_parses_json_string(self): """A valid JSON string is parsed into a dict.""" json_str = '{"key": "value", "num": 42}' result = _parse_research_metadata(json_str) assert result == {"key": "value", "num": 42} def test_invalid_json_returns_empty_dict(self): """An invalid JSON string returns an empty dict.""" result = _parse_research_metadata("not valid json {{{") assert result == {} def test_none_returns_empty_dict(self): """None input returns an empty dict.""" result = _parse_research_metadata(None) assert result == {} def test_integer_returns_empty_dict(self): """An integer input returns an empty dict.""" result = _parse_research_metadata(999) assert result == {} def test_empty_string_returns_empty_dict(self): """An empty string (invalid JSON) returns an empty dict.""" result = _parse_research_metadata("") assert result == {} def test_preserves_nested_dict_structure(self): """Nested dict structures survive the round-trip.""" nested = { "outer": {"inner": [1, 2, 3]}, "list": [{"a": 1}, {"b": 2}], } result = _parse_research_metadata(nested) assert result == nested # --------------------------------------------------------------------------- # get_citation_formatter # --------------------------------------------------------------------------- class TestGetCitationFormatter: """Tests for get_citation_formatter with mocked settings.""" @patch( "local_deep_research.config.search_config.get_setting_from_snapshot", return_value="number_hyperlinks", ) def test_number_hyperlinks_setting(self, mock_get_setting): """'number_hyperlinks' maps to CitationMode.NUMBER_HYPERLINKS.""" from local_deep_research.text_optimization import CitationMode formatter = get_citation_formatter() assert formatter.mode == CitationMode.NUMBER_HYPERLINKS @patch( "local_deep_research.config.search_config.get_setting_from_snapshot", return_value="domain_hyperlinks", ) def test_domain_hyperlinks_setting(self, mock_get_setting): """'domain_hyperlinks' maps to CitationMode.DOMAIN_HYPERLINKS.""" from local_deep_research.text_optimization import CitationMode formatter = get_citation_formatter() assert formatter.mode == CitationMode.DOMAIN_HYPERLINKS @patch( "local_deep_research.config.search_config.get_setting_from_snapshot", return_value="no_hyperlinks", ) def test_no_hyperlinks_setting(self, mock_get_setting): """'no_hyperlinks' maps to CitationMode.NO_HYPERLINKS.""" from local_deep_research.text_optimization import CitationMode formatter = get_citation_formatter() assert formatter.mode == CitationMode.NO_HYPERLINKS @patch( "local_deep_research.config.search_config.get_setting_from_snapshot", return_value="totally_unknown_format", ) def test_unknown_setting_defaults_to_number_hyperlinks( self, mock_get_setting ): """An unrecognised setting value falls back to NUMBER_HYPERLINKS.""" from local_deep_research.text_optimization import CitationMode formatter = get_citation_formatter() assert formatter.mode == CitationMode.NUMBER_HYPERLINKS @patch( "local_deep_research.config.search_config.get_setting_from_snapshot", return_value="number_hyperlinks", ) def test_default_setting_returns_number_hyperlinks(self, mock_get_setting): """When the setting returns its default, formatter uses NUMBER_HYPERLINKS.""" from local_deep_research.text_optimization import CitationMode formatter = get_citation_formatter() # Verify the setting was requested with the correct default mock_get_setting.assert_called_once_with( "report.citation_format", "number_hyperlinks" ) assert formatter.mode == CitationMode.NUMBER_HYPERLINKS # --------------------------------------------------------------------------- # export_report_to_memory # --------------------------------------------------------------------------- class TestExportReportToMemory: """Tests for export_report_to_memory with mocked exporter registry.""" @patch("local_deep_research.exporters.ExportOptions") @patch("local_deep_research.exporters.ExporterRegistry") def test_calls_get_exporter_with_lowercase_format( self, mock_registry, mock_options_cls ): """The format string is lowered before being passed to get_exporter.""" mock_exporter = MagicMock() mock_result = MagicMock() mock_result.content = b"data" mock_result.filename = "report.pdf" mock_result.mimetype = "application/pdf" mock_exporter.export.return_value = mock_result mock_registry.get_exporter.return_value = mock_exporter export_report_to_memory("# Hello", "PDF", title="My Report") mock_registry.get_exporter.assert_called_once_with("pdf") @patch("local_deep_research.exporters.ExportOptions") @patch("local_deep_research.exporters.ExporterRegistry") def test_raises_value_error_for_unsupported_format( self, mock_registry, mock_options_cls ): """When ExporterRegistry.get_exporter returns None, ValueError is raised.""" mock_registry.get_exporter.return_value = None mock_registry.get_available_formats.return_value = ["pdf", "odt"] with pytest.raises(ValueError, match="Unsupported export format"): export_report_to_memory("# Hello", "bmp") @patch("local_deep_research.exporters.ExportOptions") @patch("local_deep_research.exporters.ExporterRegistry") def test_returns_tuple_of_content_filename_mimetype( self, mock_registry, mock_options_cls ): """Successful export returns (content, filename, mimetype).""" mock_exporter = MagicMock() mock_result = MagicMock() mock_result.content = b"pdf-bytes" mock_result.filename = "report.pdf" mock_result.mimetype = "application/pdf" mock_exporter.export.return_value = mock_result mock_registry.get_exporter.return_value = mock_exporter content, filename, mimetype = export_report_to_memory( "# Report", "pdf", title="Test" ) assert content == b"pdf-bytes" assert filename == "report.pdf" assert mimetype == "application/pdf" @patch("local_deep_research.exporters.ExportOptions") @patch("local_deep_research.exporters.ExporterRegistry") def test_normalizes_format_to_lowercase( self, mock_registry, mock_options_cls ): """Mixed-case format strings are normalised before lookup.""" mock_exporter = MagicMock() mock_result = MagicMock() mock_result.content = b"" mock_result.filename = "report.odt" mock_result.mimetype = "application/vnd.oasis.opendocument.text" mock_exporter.export.return_value = mock_result mock_registry.get_exporter.return_value = mock_exporter export_report_to_memory("# Hello", "OdT") mock_registry.get_exporter.assert_called_once_with("odt")