#region TestURLReparse [C:3] [TYPE Module] [SEMANTICS test, validation, url, reparse, source-invalid] # @BRIEF Tests for URL source re-parse failure handling: deleted dashboards are detected gracefully. # @RELATION BINDS_TO -> [ValidationService._parse_superset_link] # @RELATION BINDS_TO -> [ValidationService._resolve_sources] # @TEST_SCENARIO parse_failure -> URL with deleted dashboard → ValueError with descriptive message # @TEST_SCENARIO source_invalid -> parse failure on existing source → source marked "invalid" + error logged # @TEST_EDGE: missing_env -> URL parse fails when environment doesn't exist # @TEST_EDGE: missing_config -> URL parse fails when config_manager is None # @TEST_EDGE: invalid_url -> completely unparseable URL raises ValueError # @INVARIANT A URL source whose dashboard was deleted must be detected and sources marked invalid # rather than silently skipped during validation runs. # @NOTE The _resolve_sources method currently propagates parse failures as ValueError. # The desired behavior (FR-055) is to catch the error, set source.status="invalid" # and source.last_error, then continue. The tests below document both current # and expected behavior. import pytest from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from src.schemas.validation import ( SourceInput, ValidationTaskCreate, ) from src.services.validation_service import ValidationTaskService #region _make_service [C:2] [TYPE Function] # @BRIEF Build a ValidationTaskService with mock DB and config for isolated testing. def _make_service(): """Create ValidationTaskService with mock dependencies for URL re-parse tests.""" mock_db = MagicMock() mock_config = MagicMock() # Mock environment lookup — env-1 exists, env-2 does not env1 = SimpleNamespace( id="env-1", name="Test Env", url="http://superset.example", username="user", password="pass", verify_ssl=True, timeout=30, ) mock_config.get_environment.side_effect = lambda eid: env1 if eid == "env-1" else None service = ValidationTaskService(db=mock_db, config_manager=mock_config, username="testuser") return service, mock_db, mock_config #endregion _make_service #region test_url_reparse_failure_marks_source_invalid [C:2] [TYPE Function] # @BRIEF T055: URL source with deleted dashboard → _parse_superset_link raises ValueError. # @TEST_INVARIANT url_parse_fails_on_deleted_dashboard -> VERIFIED_BY: test_url_reparse_failure_marks_source_invalid # @RATIONALE When the Superset dashboard referenced by a URL source is deleted, # SupersetContextExtractor.parse_superset_link raises ValueError. # The ValidationTaskService must propagate this error so the caller # can decide whether to mark the source invalid or abort. async def test_url_reparse_failure_marks_source_invalid(): """URL pointing to deleted dashboard → ValueError with descriptive message. The current behavior propagates the parse error as a ValueError. The source is NOT auto-marked invalid in _parse_superset_link — the caller (_resolve_sources or trigger_run) must catch and handle it. """ service, _mock_db, _mock_config = _make_service() # Mock SupersetContextExtractor — it's imported inside _parse_superset_link with patch( "src.core.utils.superset_context_extractor.SupersetContextExtractor" ) as mock_extractor_cls: mock_extractor_instance = MagicMock() mock_extractor_cls.return_value = mock_extractor_instance # Simulate a deleted dashboard: extractor raises ValueError mock_extractor_instance.parse_superset_link.side_effect = ValueError( "Dashboard '42' not found on environment 'env-1' — " "the dashboard may have been deleted or the URL is invalid." ) # _parse_superset_link should propagate the ValueError with pytest.raises(ValueError) as exc_info: await service._parse_superset_link( url="http://superset.example/superset/dashboard/42/", environment_id="env-1", ) assert "not found" in str(exc_info.value).lower() assert "deleted" in str(exc_info.value).lower() #endregion test_url_reparse_failure_marks_source_invalid #region test_url_reparse_missing_environment [C:2] [TYPE Function] # @BRIEF T055 edge: non-existent environment → ValueError with clear message. async def test_url_reparse_missing_environment(): """URL re-parse with non-existent environment → ValueError.""" service, _mock_db, _mock_config = _make_service() with pytest.raises(ValueError) as exc_info: await service._parse_superset_link( url="http://superset.example/superset/dashboard/1/", environment_id="env-nonexistent", ) assert "not found" in str(exc_info.value).lower() #endregion test_url_reparse_missing_environment #region test_url_reparse_no_config [C:2] [TYPE Function] # @BRIEF T055 edge: config_manager is None → ValueError. async def test_url_reparse_no_config(): """URL re-parse with no config_manager available → ValueError.""" service = ValidationTaskService(db=MagicMock(), config_manager=None, username="testuser") with pytest.raises(ValueError) as exc_info: await service._parse_superset_link( url="http://superset.example/superset/dashboard/1/", environment_id="env-1", ) assert "config manager" in str(exc_info.value).lower() #endregion test_url_reparse_no_config #region test_url_reparse_create_task_propagates_error [C:2] [TYPE Function] # @BRIEF T055: create_task with unresolvable URL source returns error — source NOT auto-created. # @NOTE This documents current behavior: parse failure in _resolve_sources prevents # task creation. FR-055 expects the source to be created with status="invalid" # and the task to proceed with remaining valid sources. async def test_url_reparse_create_task_propagates_error(): """Creating task with unresolvable URL → ValueError from _resolve_sources. Currently, _resolve_sources does NOT catch parse failures. When a URL source's dashboard is deleted, the entire task creation fails. Future behavior should mark the specific source as 'invalid' and continue. """ service, _mock_db, _mock_config = _make_service() # Patch at the source module — import is inside _parse_superset_link with patch( "src.core.utils.superset_context_extractor.SupersetContextExtractor" ) as mock_extractor_cls: mock_extractor_instance = MagicMock() mock_extractor_cls.return_value = mock_extractor_instance mock_extractor_instance.parse_superset_link = AsyncMock( side_effect=ValueError("Dashboard '42' not found") ) # Mock the LLM provider validation to succeed mock_provider = MagicMock() mock_provider.is_active = True mock_provider.is_multimodal = True mock_provider.name = "Test Provider" with patch.object(service, "_validate_provider", return_value=mock_provider): with pytest.raises(ValueError) as exc_info: payload = ValidationTaskCreate( name="URL-Only Task", environment_id="env-1", provider_id="provider-1", sources=[SourceInput(type="dashboard_url", value="http://superset.example/superset/dashboard/42/")], ) await service.create_task(payload) assert "not found" in str(exc_info.value) #endregion test_url_reparse_create_task_propagates_error #region test_url_reparse_with_source_recovery [C:2] [TYPE Function] # @BRIEF T055: URL with partial-recovery indicator sets source status to "partial_recovery". async def test_url_reparse_with_source_recovery(): """URL source with partial_recovery → source status 'partial_recovery' (via _resolve_sources).""" service, _mock_db, _mock_config = _make_service() # Patch at the source module — import is inside _parse_superset_link with patch( "src.core.utils.superset_context_extractor.SupersetContextExtractor" ) as mock_extractor_cls: mock_extractor_instance = MagicMock() mock_extractor_cls.return_value = mock_extractor_instance # Simulate partial recovery: dashboard_id resolved but dataset_ref unclear from src.core.utils.superset_context_extractor import SupersetParsedContext mock_extractor_instance.parse_superset_link = AsyncMock( return_value=SupersetParsedContext( source_url="http://superset.example/superset/dashboard/42/", dataset_ref="dataset:?table=unknown", dataset_id=None, dashboard_id=42, resource_type="dashboard", partial_recovery=True, unresolved_references=["dataset_1"], ) ) # Directly test _resolve_sources — avoids creating a full policy with mock DB sources = await service._resolve_sources( sources_input=[ SourceInput(type="dashboard_url", value="http://superset.example/superset/dashboard/42/"), ], dashboard_ids=None, policy_id="test-policy-id", environment_id="env-1", ) # Verify source was created with partial_recovery status assert len(sources) == 1 assert sources[0].type == "dashboard_url" assert sources[0].status == "partial_recovery" assert sources[0].resolved_dashboard_id == "42" #endregion test_url_reparse_with_source_recovery #endregion TestURLReparse