# #region Test.SearchPlugin [C:3] [TYPE Module] [SEMANTICS test, search, plugin, superset, regex] # @BRIEF Unit tests for SearchPlugin — properties, schema, execute with datasets, regex errors, edge cases. # @RELATION BINDS_TO -> [SearchPlugin] # @TEST_EDGE: missing_params -> raises ValueError # @TEST_EDGE: env_not_found -> raises ValueError # @TEST_EDGE: invalid_regex -> raises ValueError # @TEST_EDGE: no_datasets -> returns empty results # @TEST_EDGE: upstream_exception -> re-raises from __future__ import annotations from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from src.plugins.search import SearchPlugin # ── Helpers ── def _mock_env_config(name: str = "dev"): env = MagicMock() env.id = f"env-{name}" env.name = name return env def _mock_config_manager(env=None): cm = MagicMock() cm.get_environment.return_value = env cm.get_config.return_value = MagicMock() return cm def _mock_task_context(): ctx = MagicMock() ctx.logger = MagicMock() ctx.logger.with_source.return_value = MagicMock() return ctx def _make_dataset( dataset_id: int, table_name: str, sql: str = "", columns: list | None = None, database: dict | None = None, ) -> dict: return { "id": dataset_id, "table_name": table_name, "sql": sql, "columns": columns or [], "database": database or {"id": 1, "database_name": "test_db"}, } class TestSearchPluginProperties: """Verify static property values.""" def test_id(self): plugin = SearchPlugin() assert plugin.id == "search-datasets" def test_name(self): plugin = SearchPlugin() assert plugin.name == "Search Datasets" def test_description(self): plugin = SearchPlugin() description = "Search for text patterns across all datasets in a specific environment." assert plugin.description == description def test_version(self): plugin = SearchPlugin() assert plugin.version == "1.0.0" def test_ui_route(self): plugin = SearchPlugin() assert plugin.ui_route == "/tools/search" class TestSearchPluginGetSchema: """Verify get_schema output.""" def test_get_schema(self): plugin = SearchPlugin() schema = plugin.get_schema() assert schema["type"] == "object" props = schema["properties"] assert "env" in props assert "query" in props assert props["query"]["type"] == "string" assert "description" in props["query"] assert schema["required"] == ["env", "query"] class TestSearchPluginExecute: """Verify SearchPlugin.execute with various scenarios.""" # ── Missing params ── @pytest.mark.asyncio async def test_execute_missing_all_params(self): """No params raises ValueError.""" plugin = SearchPlugin() with pytest.raises(ValueError, match="Missing required parameters"): await plugin.execute({}) @pytest.mark.asyncio async def test_execute_missing_env(self): """Missing env raises ValueError.""" plugin = SearchPlugin() with pytest.raises(ValueError, match="Missing required parameters"): await plugin.execute({"query": "test"}) @pytest.mark.asyncio async def test_execute_missing_query(self): """Missing query raises ValueError.""" plugin = SearchPlugin() with pytest.raises(ValueError, match="Missing required parameters"): await plugin.execute({"env": "dev"}) @pytest.mark.asyncio async def test_execute_missing_params_with_context(self): """Missing params fails even with context.""" plugin = SearchPlugin() ctx = _mock_task_context() with pytest.raises(ValueError, match="Missing required parameters"): await plugin.execute({"env": "dev"}, context=ctx) # ── Environment not found ── @pytest.mark.asyncio async def test_execute_env_not_found(self): """Environment not in config raises ValueError.""" plugin = SearchPlugin() mock_cm = _mock_config_manager(env=None) # get_environment returns None with patch("src.dependencies.get_config_manager", return_value=mock_cm): with pytest.raises(ValueError, match="not found"): await plugin.execute({"env": "missing", "query": "pattern"}) # ── No datasets ── @pytest.mark.asyncio async def test_execute_no_datasets(self): """No datasets returns empty results.""" plugin = SearchPlugin() env = _mock_env_config("dev") mock_cm = _mock_config_manager(env=env) mock_client = MagicMock() mock_client.authenticate = MagicMock() mock_client.get_datasets = MagicMock(return_value=(0, [])) with ( patch("src.dependencies.get_config_manager", return_value=mock_cm), patch("src.plugins.search.SupersetClient", return_value=mock_client), ): result = await plugin.execute({"env": "dev", "query": "test"}) assert result["count"] == 0 assert result["results"] == [] @pytest.mark.asyncio async def test_execute_no_datasets_with_context(self): """No datasets returns empty results with TaskContext.""" plugin = SearchPlugin() env = _mock_env_config("dev") mock_cm = _mock_config_manager(env=env) mock_client = MagicMock() mock_client.authenticate = MagicMock() mock_client.get_datasets = MagicMock(return_value=(0, [])) ctx = _mock_task_context() with ( patch("src.dependencies.get_config_manager", return_value=mock_cm), patch("src.plugins.search.SupersetClient", return_value=mock_client), ): result = await plugin.execute({"env": "dev", "query": "test"}, context=ctx) assert result["count"] == 0 assert result["results"] == [] ctx.logger.with_source.assert_called() # ── Search with results ── @pytest.mark.asyncio async def test_execute_search_finds_matches(self): """Search finds matching fields across datasets.""" plugin = SearchPlugin() env = _mock_env_config("prod") mock_cm = _mock_config_manager(env=env) datasets = [ _make_dataset(1, "users", sql="SELECT * FROM users", columns=["id", "name"]), _make_dataset(2, "orders", sql="SELECT * FROM orders", columns=["order_id", "amount"]), ] mock_client = MagicMock() mock_client.authenticate = MagicMock() mock_client.get_datasets = MagicMock(return_value=(2, datasets)) with ( patch("src.dependencies.get_config_manager", return_value=mock_cm), patch("src.plugins.search.SupersetClient", return_value=mock_client), ): result = await plugin.execute({"env": "prod", "query": "users"}) assert result["count"] >= 1 # Should find "users" in table_name of dataset 1 matched_datasets = {r["dataset_id"] for r in result["results"]} assert 1 in matched_datasets @pytest.mark.asyncio async def test_execute_search_no_matches(self): """Search with no matching pattern returns zero results.""" plugin = SearchPlugin() env = _mock_env_config("prod") mock_cm = _mock_config_manager(env=env) datasets = [ _make_dataset(1, "users", sql="SELECT * FROM users"), _make_dataset(2, "orders", sql="SELECT * FROM orders"), ] mock_client = MagicMock() mock_client.authenticate = MagicMock() mock_client.get_datasets = MagicMock(return_value=(2, datasets)) with ( patch("src.dependencies.get_config_manager", return_value=mock_cm), patch("src.plugins.search.SupersetClient", return_value=mock_client), ): result = await plugin.execute({"env": "prod", "query": "zzz_nonexistent_zzz"}) assert result["count"] == 0 assert result["results"] == [] @pytest.mark.asyncio async def test_execute_search_skips_dataset_without_id(self): """Dataset without id field is silently skipped; only fully-id'd datasets searched.""" plugin = SearchPlugin() env = _mock_env_config("dev") mock_cm = _mock_config_manager(env=env) datasets = [ {"table_name": "orphan", "sql": "SELECT 1"}, # no 'id' key _make_dataset(2, "valid", sql="SELECT 2"), ] mock_client = MagicMock() mock_client.authenticate = MagicMock() mock_client.get_datasets = MagicMock(return_value=(2, datasets)) with ( patch("src.dependencies.get_config_manager", return_value=mock_cm), patch("src.plugins.search.SupersetClient", return_value=mock_client), ): result = await plugin.execute({"env": "dev", "query": "valid"}) # Dataset with id=2 has table_name="valid" → should match assert result["count"] == 1 # ── Invalid regex ── @pytest.mark.asyncio async def test_execute_invalid_regex(self): """Malformed regex raises ValueError.""" plugin = SearchPlugin() env = _mock_env_config("dev") mock_cm = _mock_config_manager(env=env) mock_client = MagicMock() mock_client.authenticate = MagicMock() mock_client.get_datasets = MagicMock(return_value=(1, [_make_dataset(1, "test")])) with ( patch("src.dependencies.get_config_manager", return_value=mock_cm), patch("src.plugins.search.SupersetClient", return_value=mock_client), ): with pytest.raises(ValueError, match="Invalid regex"): await plugin.execute({"env": "dev", "query": r"[invalid"}) # ── Upstream exception ── @pytest.mark.asyncio async def test_execute_upstream_exception_propagates(self): """Exception from get_datasets re-raises.""" plugin = SearchPlugin() env = _mock_env_config("dev") mock_cm = _mock_config_manager(env=env) mock_client = MagicMock() mock_client.authenticate = MagicMock() mock_client.get_datasets = MagicMock(side_effect=ConnectionError("Superset down")) with ( patch("src.dependencies.get_config_manager", return_value=mock_cm), patch("src.plugins.search.SupersetClient", return_value=mock_client), ): with pytest.raises(ConnectionError, match="Superset down"): await plugin.execute({"env": "dev", "query": "test"}) @pytest.mark.asyncio async def test_execute_auth_failure_propagates(self): """Exception from authenticate re-raises.""" plugin = SearchPlugin() env = _mock_env_config("dev") mock_cm = _mock_config_manager(env=env) mock_client = MagicMock() mock_client.authenticate = MagicMock(side_effect=PermissionError("Auth failed")) with ( patch("src.dependencies.get_config_manager", return_value=mock_cm), patch("src.plugins.search.SupersetClient", return_value=mock_client), ): with pytest.raises(PermissionError, match="Auth failed"): await plugin.execute({"env": "dev", "query": "test"}) class TestSearchPluginGetContext: """Verify _get_context helper. NOTE: The production `_get_context` has a layout bug where the match-following logic is indented inside the for-loop and the loop has no post-loop return. Tests document the actual behavior: - Empty match_text → truncated/short text (correct) - Match NOT on first line → exits loop early returning text (bug) - Match on first line → break exits loop, returns None (bug) """ def test_get_context_empty_match_text(self): """When match_text is empty, returns truncated text.""" plugin = SearchPlugin() text = "a" * 200 result = plugin._get_context(text, "") assert len(result) <= 103 # 100 + "..." assert result.endswith("...") def test_get_context_short_text_empty_match(self): """Short text + empty match_text returns full text.""" plugin = SearchPlugin() text = "short text" result = plugin._get_context(text, "") assert result == "short text" def test_get_context_match_on_first_line_returns_none(self): """When match is on first line, break exits loop → returns None (known bug).""" plugin = SearchPlugin() text = "first match here\nsecond line\nthird line" result = plugin._get_context(text, "first", context_lines=1) assert result is None def test_get_context_match_not_on_first_line_returns_raw_text(self): """When match is NOT on first line, loop returns truncated text (known bug).""" plugin = SearchPlugin() text = "line one\nmatching line\ntwo\nline three" result = plugin._get_context(text, "matching", context_lines=1) # Returns full original text because text < 100 chars assert result == text def test_get_context_long_text_no_match(self): """Long text with no match on first line returns truncated.""" plugin = SearchPlugin() text = "x" * 200 result = plugin._get_context(text, "nonexistent_first_line") assert len(result) <= 103 assert result.endswith("...") def test_get_context_multiline_no_match_fallback(self): """Multi-line text with no match: loop returns on first iteration via line-217 fallback.""" plugin = SearchPlugin() text = "line one\nline two\nline three" result = plugin._get_context(text, "zzz_nonexistent", context_lines=1) # First iteration: "line one" doesn't match → line 205 False → line 217 return # text is short (< 100), so returns full text assert result == text def test_get_context_dead_code_unreachable(self): """Lines 206-215 are dead code: the context-formatting block is indented inside the for-loop. A break on match exits the loop before reaching it; no match means match_line_index remains -1 so the condition fails. This test verifies the dead path is never taken.""" plugin = SearchPlugin() # Match found but break exits before lines 206-215 text = "matching text here\nother line" result = plugin._get_context(text, "matching", context_lines=1) # break on line 203 → exits loop → returns None (no code after loop) assert result is None # No match — match_line_index=-1, condition on line 205 fails text2 = "no match here\nstill no match" result2 = plugin._get_context(text2, "notfound", context_lines=1) # Falls to line 217 on first iteration, text2 < 100 chars assert result2 == text2 # #endregion Test.SearchPlugin