Files
ss-tools/backend/tests/plugins/test_search.py
busya 8a4310169b v4: 7643 tests passing, 83% raw / 96.9% real coverage (excl __tests__).
12 known failures — all from Agent 3 new unverified tests (mock setup issues):
- 3 dataset_review_routes_extended (DTO field mismatches)
- 1 settings_consolidated (dict key access)
- 1 llm_analysis_service_coverage (rate_limit mock)
- 1 migration_plugin (SessionLocal side_effect exhaustion)
- 1 preview (DB query vs dict key)
- 5 scheduler (datetime timezone + async mock mismatches)

NEW TEST FILES THIS SESSION:
- test_batch_insert_coverage.py — 3 tests
- test_storage_plugin.py — +3 tests
- test_search.py — +2 tests
- test_mapper.py — already 100%
- test_llm_analysis_migration_v1_to_v2.py — 14 tests
- test_llm_async_http.py — +1 test
- test_prompt_builder.py — +1 test
- test_service_datasource.py — +1 test
- test_lang_detect.py — +1 test
- test_scheduler.py — +6 tests
- test_llm_analysis_service_coverage.py — +15 tests
- test_dataset_review_routes_extended.py — +14 tests
- test_settings_consolidated.py — +13 tests

Modules pushed to 100%: _batch_insert, dictionary_entries, service_datasource,
_llm_async_http, prompt_builder, dictionary_crud, _batch_sizer, storage/plugin,
mapper.py

Session: 7194→7643 tests (+449), 80%→83% raw (+3pp), 93.4%→96.9% real (+3.5pp).
Remaining: 12 failures to fix + ~300 statements to reach 98% real coverage.
2026-06-16 09:34:10 +03:00

402 lines
15 KiB
Python

# #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