Files
ss-tools/backend/tests/plugins/test_llm_analysis_service_gaps.py
busya 488a8f349b test(backend): raise coverage to 95%+ statements and branches (97.8%/95.0%)
- ~60 new/extended test files across api, core, plugins, services, schemas:
  routes, superset clients, task_manager, lineage, git, translate,
  dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl
- .coveragerc: enable branch coverage; exclude src/__tests__ (test files)
  and src/scripts (CLI/ops tools) from the denominator
- bug fixes found while testing:
  * settings: PUT /settings/reports registered under duplicated prefix
  * schemas/lineage: FleetReportDTO missing run_status (route always 500)
  * dashboard_testing/baseline_inheritance: visual entry read wrong field
  * superset_client/_databases: logger extra name shadowed LogRecord attr
  * routes/datasets: _yaml_string_paths recursion without yield from
  * translate/sql_generator: restore explicit-type timestamp contract
  * baseline_catalog: remove unreachable dashboard_id fallback
- conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test
  filename collision, TMPDIR-safe integration fixtures
2026-08-19 17:14:32 +03:00

434 lines
21 KiB
Python

# #region Test.LLMAnalysisService.Gaps [C:3] [TYPE Module] [SEMANTICS test,llm,analysis,coverage,gaps]
# @BRIEF Final coverage gaps for service.py: login-locator fallback across roots, falsy parsed-context
# flags, recursive tab dedup, CDP screenshot success paths, provider-error normalization (5xx),
# rate-limit retry-delay body parsing, chunk permanent-error abort, unknown permanent failure
# propagation, and chart-data non-mapping responses.
# @RELATION BINDS_TO -> [Plugin.Service.LLMAnalysisService]
# @TEST_EDGE: login_locator_fallback_across_roots -> username/password absent in root1, found in root2
# @TEST_EDGE: parsed_context_falsy_flags -> empty native_filters/activeTabs skip URL query append
# @TEST_EDGE: chunks_recursive_tab_dedup -> revisited depth re-encounters processed tab ids and skips
# @TEST_EDGE: chunks_cdp_success_tab -> CDP screenshot writes tab file (no Playwright fallback)
# @TEST_EDGE: chunks_cdp_success_fullpage -> CDP screenshot writes full-page file
# @TEST_EDGE: normalize_status_5xx -> generic 5xx status maps to ProviderTransportFailure
# @TEST_EDGE: rate_limit_body_not_dict -> non-dict body falls back to default 5s delay
# @TEST_EDGE: rate_limit_details_without_retry_info -> non-matching details keep looping
# @TEST_EDGE: chunk_permanent_error_aborts -> non-retryable chunk failure re-raised (not UNKNOWN)
# @TEST_EDGE: unknown_permanent_failure_propagates -> custom non-retryable ProviderFailure escapes
# @TEST_EDGE: chart_data_non_mapping_result -> response neither dict nor list -> row_count 0
import asyncio
import base64
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from PIL import Image
from src.plugins.llm_analysis.exceptions import (
ProviderAuthenticationFailure,
ProviderFailure,
ProviderTransportFailure,
)
from src.plugins.llm_analysis.models import LLMProviderType
# ═══════════════════════════════════════════════════════════════════
# _find_login_field_locator — fallback across multiple login roots
# ═══════════════════════════════════════════════════════════════════
class TestFindLoginFieldLocatorFallback:
"""Verify _find_login_field_locator falls through roots when a field is absent."""
@pytest.mark.asyncio
async def test_username_not_found_first_root_then_found(self):
"""Username absent in root1, found in root2."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
root1, root2 = MagicMock(), MagicMock()
username_loc = MagicMock()
with patch.object(svc, '_iter_login_roots', return_value=[root1, root2]):
with patch.object(svc, '_find_first_visible_locator', AsyncMock(side_effect=[None, username_loc])):
result = await svc._find_login_field_locator(root1, "username")
assert result is username_loc
@pytest.mark.asyncio
async def test_password_not_found_first_root_then_found(self):
"""Password absent in root1, found in root2."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
root1, root2 = MagicMock(), MagicMock()
password_loc = MagicMock()
with patch.object(svc, '_iter_login_roots', return_value=[root1, root2]):
with patch.object(svc, '_find_first_visible_locator', AsyncMock(side_effect=[None, password_loc])):
result = await svc._find_login_field_locator(root1, "password")
assert result is password_loc
@pytest.mark.asyncio
async def test_unknown_field_name_returns_none(self):
"""Field name that is neither username nor password returns None."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
root1, root2 = MagicMock(), MagicMock()
with patch.object(svc, '_iter_login_roots', return_value=[root1, root2]):
with patch.object(svc, '_find_first_visible_locator', AsyncMock(return_value=None)):
result = await svc._find_login_field_locator(root1, "email")
assert result is None
@pytest.mark.asyncio
async def test_username_absent_everywhere_returns_none(self):
"""Username never found in any root returns None."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(MagicMock())
root1 = MagicMock()
with patch.object(svc, '_iter_login_roots', return_value=[root1]):
with patch.object(svc, '_find_first_visible_locator', AsyncMock(return_value=None)):
result = await svc._find_login_field_locator(root1, "username")
assert result is None
# ═══════════════════════════════════════════════════════════════════
# _launch_and_login — falsy parsed-context flags
# ═══════════════════════════════════════════════════════════════════
class TestLaunchAndLoginFalsyParsedContext:
"""Verify _launch_and_login with parsed_context whose flag values are empty."""
def _make_env(self):
env = MagicMock()
env.url = "https://superset.example.com"
env.username = "admin"
env.password = "pass"
return env
def _make_basic_mocks(self):
mock_page = MagicMock()
mock_page.frames = []
mock_page.url = "https://superset.example.com/superset/dashboard/42/"
mock_page.goto = AsyncMock()
mock_page.wait_for_load_state = AsyncMock()
mock_page.wait_for_selector = AsyncMock()
mock_page.wait_for_function = AsyncMock()
mock_page.evaluate = AsyncMock()
mock_page.add_init_script = AsyncMock()
mock_page.set_viewport_size = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_page.locator = MagicMock()
mock_page.locator.count = AsyncMock(return_value=0)
mock_context = MagicMock()
mock_context.new_page = AsyncMock(return_value=mock_page)
mock_browser = MagicMock()
mock_browser.new_context = AsyncMock(return_value=mock_context)
mock_playwright = MagicMock()
mock_playwright.chromium.launch = AsyncMock(return_value=mock_browser)
return mock_page, mock_context, mock_browser, mock_playwright
@pytest.mark.asyncio
async def test_empty_flags_skip_query_append(self):
"""Empty native_filters/activeTabs values are not appended to dashboard URL."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, _, _, mock_playwright = self._make_basic_mocks()
username_loc = MagicMock()
username_loc.fill = AsyncMock()
password_loc = MagicMock()
password_loc.fill = AsyncMock()
submit_loc = MagicMock()
submit_loc.click = AsyncMock()
with patch.object(svc, '_find_login_field_locator', AsyncMock(side_effect=[username_loc, password_loc])):
with patch.object(svc, '_find_submit_locator', AsyncMock(return_value=submit_loc)):
with patch.object(svc, '_goto_resilient', new=AsyncMock()):
browser, context, page = await svc._launch_and_login(
mock_playwright, "42", parsed_context={"native_filters": "", "activeTabs": ""},
)
assert page is not None
# ═══════════════════════════════════════════════════════════════════
# capture_dashboard_chunks — recursive tab dedup + CDP success paths
# ═══════════════════════════════════════════════════════════════════
class TestCaptureDashboardChunksGaps:
"""Verify capture_dashboard_chunks dedup recursion and CDP success paths."""
def _make_env(self):
env = MagicMock()
env.url = "https://superset.example.com"
env.username = "admin"
env.password = "pass"
return env
def _make_cdp_page(self):
"""Page whose context exposes a working CDP session."""
mock_page = MagicMock()
mock_page.set_viewport_size = AsyncMock()
mock_page.screenshot = AsyncMock()
mock_page.wait_for_function = AsyncMock()
mock_page.url = "https://example.com/dashboard/42/"
mock_page.frames = []
mock_cdp = MagicMock()
mock_cdp.send = AsyncMock(return_value={"data": base64.b64encode(b"img").decode()})
mock_context = MagicMock()
mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp)
mock_page.context = mock_context
mock_browser = MagicMock()
mock_browser.close = AsyncMock()
return mock_page, mock_context, mock_browser
@pytest.mark.asyncio
async def test_revisited_depth_skips_processed_tabs(self):
"""Same tab ids re-encountered at a revisited depth are skipped (line 615)."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, mock_context, mock_browser = self._make_cdp_page()
tab_mock = MagicMock()
tab_mock.inner_text = AsyncMock(return_value="SameTab")
tab_mock.is_visible = AsyncMock(return_value=True)
tab_mock.get_attribute = AsyncMock(return_value="ant-tabs-tab")
tab_mock.click = AsyncMock()
# Always find the same two tabs at every depth → recursion revisits depths
mock_page.locator = MagicMock()
mock_page.locator.return_value.all = AsyncMock(return_value=[tab_mock, tab_mock])
with patch.object(svc, '_launch_and_login',
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
with patch.object(svc, '_wait_for_resize_rendered', AsyncMock()):
with tempfile.TemporaryDirectory() as tmp:
results = await svc.capture_dashboard_chunks("42", tmp)
# Depth-0 ids 0_0/0_1 processed; revisited depths hit the dedup branch
assert len(results) >= 2
for r in results:
assert os.path.exists(r["path"])
@pytest.mark.asyncio
async def test_no_tabs_cdp_success_writes_fullpage(self):
"""No tabs → full-page chunk written via CDP (no Playwright fallback)."""
from src.plugins.llm_analysis.service import ScreenshotService
svc = ScreenshotService(self._make_env())
mock_page, mock_context, mock_browser = self._make_cdp_page()
mock_page.locator = MagicMock()
mock_page.locator.return_value.all = AsyncMock(return_value=[])
with patch.object(svc, '_launch_and_login',
AsyncMock(return_value=(mock_browser, mock_context, mock_page))):
with patch.object(svc, '_wait_for_charts_stabilized', AsyncMock()):
with tempfile.TemporaryDirectory() as tmp:
results = await svc.capture_dashboard_chunks("42", tmp)
assert len(results) == 1
assert results[0]["tab_name"] == "full"
assert os.path.exists(results[0]["path"])
# ═══════════════════════════════════════════════════════════════════
# LLMClient — error normalization and rate-limit body parsing
# ═══════════════════════════════════════════════════════════════════
class TestNormalizeProviderError5xx:
"""Verify _normalize_provider_error maps generic 5xx status to transport failure."""
def test_status_code_500_plus(self):
"""Exception carrying status_code >= 500 maps to ProviderTransportFailure."""
from src.plugins.llm_analysis.service import LLMClient
class _ServerError(Exception):
status_code = 502
result = LLMClient._normalize_provider_error(_ServerError("bad gateway"), provider_id="gpt-4o")
assert isinstance(result, ProviderTransportFailure)
assert result.status_code == 502
assert result.retryable is True
def test_status_code_404_unknown_maps_to_transport(self):
"""Non-mapped status (404) falls through to default transport failure."""
from src.plugins.llm_analysis.service import LLMClient
class _NotFoundError(Exception):
status_code = 404
result = LLMClient._normalize_provider_error(_NotFoundError("missing"))
assert isinstance(result, ProviderTransportFailure)
class TestGetJsonCompletionRateLimitBodyParse:
"""Verify get_json_completion rate-limit retry-delay parsing edge branches."""
def _make_client_with_sequence(self, first_errors):
"""Build a real LLMClient whose first completions calls raise, then succeed."""
from src.plugins.llm_analysis.service import LLMClient
with patch('src.plugins.llm_analysis.service.httpx.AsyncClient'):
with patch('src.plugins.llm_analysis.service.AsyncOpenAI') as MockOpenAI:
mock_client_instance = MagicMock()
MockOpenAI.return_value = mock_client_instance
mock_success = MagicMock()
mock_success.choices = [MagicMock()]
mock_success.choices[0].message.content = '{"status": "PASS"}'
mock_client_instance.chat.completions.create = AsyncMock(
side_effect=[*first_errors, mock_success],
)
real = LLMClient(
LLMProviderType.OPENAI, "sk-test", "https://api.openai.com", "gpt-4o",
)
with patch.object(real, '_supports_json_response_format', return_value=False):
with patch.object(asyncio, 'sleep', AsyncMock()):
return real
@pytest.mark.asyncio
async def test_rate_limit_body_not_dict(self):
"""Rate-limit error with non-dict body falls back to default delay."""
from openai import RateLimitError
rate_err = RateLimitError("rate_limit", response=MagicMock(), body="not a dict")
real = self._make_client_with_sequence([rate_err])
result = await real.get_json_completion([{"role": "user", "content": "test"}])
assert result["status"] == "PASS"
@pytest.mark.asyncio
async def test_rate_limit_details_without_retry_info(self):
"""Details without matching RetryInfo keep the loop going until a match."""
from openai import RateLimitError
rate_err = RateLimitError(
"rate_limit",
response=MagicMock(),
body={
"error": {
"details": [
{"@type": "type.googleapis.com/google.rpc.SomeOther", "retryDelay": "3s"},
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "2s"},
],
}
},
)
real = self._make_client_with_sequence([rate_err])
result = await real.get_json_completion([{"role": "user", "content": "test"}])
assert result["status"] == "PASS"
@pytest.mark.asyncio
async def test_rate_limit_no_retry_info_anywhere(self):
"""Details with no RetryInfo at all keep the default delay."""
from openai import RateLimitError
rate_err = RateLimitError(
"rate_limit",
response=MagicMock(),
body={"error": {"details": [{"@type": "type.googleapis.com/google.rpc.SomeOther", "retryDelay": "3s"}]}},
)
real = self._make_client_with_sequence([rate_err])
result = await real.get_json_completion([{"role": "user", "content": "test"}])
assert result["status"] == "PASS"
class TestAnalyzeDashboardMultimodalPermanentError:
"""Verify analyze_dashboard_multimodal aborts on permanent provider errors."""
@pytest.mark.asyncio
async def test_chunk_permanent_error_aborts(self):
"""A non-retryable chunk failure re-raises instead of merging UNKNOWN."""
from src.plugins.llm_analysis.service import LLMClient
with tempfile.TemporaryDirectory() as tmp:
paths = []
for i in range(3):
p = os.path.join(tmp, f"t_{i}.png")
Image.new("RGB", (10, 10)).save(p, "PNG")
paths.append(p)
client = MagicMock()
client._optimize_images.return_value = ["a", "b", "c"]
client._estimate_payload_size.return_value = {"exceeds_limit": False, "pct_of_limit": 10}
# Assign the real static function so the MagicMock client classifies errors correctly
client._normalize_provider_error = LLMClient._normalize_provider_error
auth_err = ProviderAuthenticationFailure("bad key", provider_id="gpt-4o", status_code=401)
client._call_llm_for_images = AsyncMock(side_effect=[
auth_err,
{"status": "PASS", "summary": "OK", "issues": []},
{"status": "WARN", "summary": "Issues", "issues": []},
])
with pytest.raises(ProviderAuthenticationFailure):
await LLMClient.analyze_dashboard_multimodal(client, paths, ["log"], max_images=1)
@pytest.mark.asyncio
async def test_unknown_permanent_failure_propagates(self):
"""A custom non-retryable ProviderFailure escapes to the caller."""
from src.plugins.llm_analysis.service import LLMClient
class _PermanentProviderFailure(ProviderFailure):
retryable = False
with tempfile.TemporaryDirectory() as tmp:
p = os.path.join(tmp, "t.png")
Image.new("RGB", (10, 10)).save(p, "PNG")
client = MagicMock()
client._optimize_images.return_value = ["a"]
client._estimate_payload_size.return_value = {"exceeds_limit": False, "pct_of_limit": 10}
# Assign the real static function so the MagicMock client classifies errors correctly
client._normalize_provider_error = LLMClient._normalize_provider_error
client._call_llm_for_images = AsyncMock(side_effect=_PermanentProviderFailure("permanent"))
with pytest.raises(_PermanentProviderFailure):
await LLMClient.analyze_dashboard_multimodal(client, [p], ["log"], max_images=1)
# ═══════════════════════════════════════════════════════════════════
# DatasetHealthChecker — non-mapping chart data response
# ═══════════════════════════════════════════════════════════════════
class TestCheckChartDataNonMappingResult:
"""Verify check_chart_data with a response that is neither dict nor list."""
@pytest.mark.asyncio
async def test_non_mapping_response_row_count_zero(self):
"""String response → executed with row_count 0."""
from src.plugins.llm_analysis.service import DatasetHealthChecker
mock_client = MagicMock()
mock_client.network.request.return_value = "plain string"
checker = DatasetHealthChecker(mock_client)
result = await checker.check_chart_data(1, {"viz_type": "table"})
assert result["executed"] is True
assert result["row_count"] == 0
assert result["error"] is None
@pytest.mark.asyncio
async def test_none_response_row_count_zero(self):
"""None response → executed with row_count 0."""
from src.plugins.llm_analysis.service import DatasetHealthChecker
mock_client = MagicMock()
mock_client.network.request.return_value = None
checker = DatasetHealthChecker(mock_client)
result = await checker.check_chart_data(1, {"viz_type": "table"})
assert result["executed"] is True
assert result["row_count"] == 0
# #endregion Test.LLMAnalysisService.Gaps